pitroom 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/pitroom.mjs CHANGED
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // src/cli.ts
4
+ import fs35 from "node:fs";
5
+
3
6
  // src/core/errors.ts
4
7
  var UserError = class extends Error {
5
8
  constructor(message, code = 2) {
@@ -1417,7 +1420,8 @@ var SCHEMA = {
1417
1420
  tiers: "record",
1418
1421
  costs: "numbers",
1419
1422
  audit: "number",
1420
- readIn: "string"
1423
+ readIn: "string",
1424
+ cacheDays: "number"
1421
1425
  };
1422
1426
  function configPath() {
1423
1427
  if (process.env.PITROOM_CONFIG) return path7.resolve(process.env.PITROOM_CONFIG);
@@ -1475,6 +1479,10 @@ var rate = (v) => {
1475
1479
  const n = typeof v === "string" && v.trim() !== "" ? Number(v) : v;
1476
1480
  return typeof n === "number" && Number.isFinite(n) && n >= 0 ? Math.min(n, 1) : void 0;
1477
1481
  };
1482
+ var days = (v) => {
1483
+ const n = typeof v === "string" && v.trim() !== "" ? Number(v) : v;
1484
+ return typeof n === "number" && Number.isInteger(n) && n >= 0 ? n : void 0;
1485
+ };
1478
1486
  var readIn = (v) => v === "auto" || v === "snapshot" || v === "project" ? v : void 0;
1479
1487
  var list = (s) => s?.split(",").map((x) => x.trim()).filter(Boolean);
1480
1488
  function setting(flag2, env, conf, fallback) {
@@ -1501,7 +1509,8 @@ function effective(flags = {}) {
1501
1509
  tiers: setting(void 0, void 0, c.tiers, {}),
1502
1510
  costs: setting(void 0, void 0, c.costs, {}),
1503
1511
  audit: setting(void 0, rate(e.PITROOM_AUDIT), rate(c.audit), 0),
1504
- readIn: setting(void 0, readIn(e.PITROOM_READ_IN), readIn(c.readIn), "auto")
1512
+ readIn: setting(void 0, readIn(e.PITROOM_READ_IN), readIn(c.readIn), "auto"),
1513
+ cacheDays: setting(void 0, days(e.PITROOM_CACHE_DAYS), days(c.cacheDays), 7)
1505
1514
  };
1506
1515
  }
1507
1516
 
@@ -1578,6 +1587,8 @@ var BOOL_FLAGS = {
1578
1587
  "--stop": "stop",
1579
1588
  "--open": "open",
1580
1589
  "--serve": "serve",
1590
+ "--fresh": "fresh",
1591
+ "--http": "http",
1581
1592
  "-h": "help",
1582
1593
  "--help": "help",
1583
1594
  "-v": "version",
@@ -1680,7 +1691,6 @@ function effortFlag(p) {
1680
1691
  }
1681
1692
 
1682
1693
  // src/cli/mcp.ts
1683
- import { spawn as spawn3 } from "node:child_process";
1684
1694
  import readline from "node:readline";
1685
1695
 
1686
1696
  // src/core/run.ts
@@ -1727,16 +1737,16 @@ function repoRoot(dir) {
1727
1737
  const r = git(dir, ["rev-parse", "--show-toplevel"]);
1728
1738
  return r.code === 0 ? path8.resolve(r.stdout.trim()) : void 0;
1729
1739
  }
1730
- function snapshotTree(root, exclude = [], drop = []) {
1740
+ function snapshotTree(root, exclude = [], drop2 = []) {
1731
1741
  const tmp = path8.join(os8.tmpdir(), `pitroom-index-${process.pid}-${crypto2.randomBytes(4).toString("hex")}`);
1732
1742
  const real = path8.resolve(root, must(root, ["rev-parse", "--git-path", "index"]).trim());
1733
1743
  const env = { ...process.env, GIT_INDEX_FILE: tmp };
1734
1744
  try {
1735
1745
  if (fs9.existsSync(real)) fs9.copyFileSync(real, tmp);
1736
1746
  else must(root, ["read-tree", "--empty"], env);
1737
- must(root, ["add", "-A", "--", ":/", ...exclude.map((p) => `:(top,exclude)${p}`), ...drop.map((p) => `:(top,literal,exclude)${p}`)], env);
1738
- if (drop.length) {
1739
- const r = spawnSync5("git", ["update-index", "--force-remove", "-z", "--stdin"], { cwd: root, env, input: `${drop.join("\0")}\0`, encoding: "utf8" });
1747
+ must(root, ["add", "-A", "--", ":/", ...exclude.map((p) => `:(top,exclude)${p}`), ...drop2.map((p) => `:(top,literal,exclude)${p}`)], env);
1748
+ if (drop2.length) {
1749
+ const r = spawnSync5("git", ["update-index", "--force-remove", "-z", "--stdin"], { cwd: root, env, input: `${drop2.join("\0")}\0`, encoding: "utf8" });
1740
1750
  if (r.status !== 0) throw new Error(`git update-index failed: ${r.stderr.trim()}`);
1741
1751
  }
1742
1752
  return must(root, ["write-tree"], env).trim();
@@ -1801,6 +1811,7 @@ ${must(root, [...DIFF, "-U10", a, b])}`;
1801
1811
  }
1802
1812
  function rangeDiff(root, a, b) {
1803
1813
  const range = `${a}..${b}`;
1814
+ const since2 = git(root, ["merge-base", a, b]).code === 0 ? `${a}...${b}` : range;
1804
1815
  const log2 = must(root, ["log", "--oneline", "--no-decorate", range]).trim();
1805
1816
  return [
1806
1817
  `## COMMITS
@@ -1808,10 +1819,10 @@ function rangeDiff(root, a, b) {
1808
1819
  ${log2 || "(none)"}`,
1809
1820
  `## FILES CHANGED
1810
1821
 
1811
- ${must(root, [...DIFF, "--stat", range]).trim() || "(none)"}`,
1822
+ ${must(root, [...DIFF, "--stat", since2]).trim() || "(none)"}`,
1812
1823
  `## DIFF
1813
1824
 
1814
- ${must(root, [...DIFF, "-U10", range])}`
1825
+ ${must(root, [...DIFF, "-U10", since2])}`
1815
1826
  ].join("\n\n");
1816
1827
  }
1817
1828
  function commitOf(root, ref) {
@@ -2046,7 +2057,7 @@ async function spawnWorker(inv, opts) {
2046
2057
  process.once("SIGINT", stop);
2047
2058
  process.once("SIGTERM", stop);
2048
2059
  let killer;
2049
- const timer = setTimeout(() => {
2060
+ const timer2 = setTimeout(() => {
2050
2061
  res.timedOut = true;
2051
2062
  child.kill("SIGTERM");
2052
2063
  killer = setTimeout(() => child.kill("SIGKILL"), 1e4);
@@ -2058,7 +2069,7 @@ async function spawnWorker(inv, opts) {
2058
2069
  });
2059
2070
  child.on("close", (c) => resolve2(c));
2060
2071
  });
2061
- clearTimeout(timer);
2072
+ clearTimeout(timer2);
2062
2073
  if (killer) clearTimeout(killer);
2063
2074
  process.off("SIGINT", stop);
2064
2075
  process.off("SIGTERM", stop);
@@ -2231,8 +2242,8 @@ function wantSnapshot(readIn2, root, dir) {
2231
2242
  return readIn2 === "snapshot" || secrets.length ? { secrets } : void 0;
2232
2243
  }
2233
2244
  function readSnapshot(root) {
2234
- const drop = findSecretFilesInTree(root, root);
2235
- const tree = snapshotTree(root, [], drop);
2245
+ const drop2 = findSecretFilesInTree(root, root);
2246
+ const tree = snapshotTree(root, [], drop2);
2236
2247
  const dest = path12.join(snapshotsDir(), tree);
2237
2248
  if (fs14.existsSync(path12.join(dest, ".git"))) {
2238
2249
  touch(dest);
@@ -2374,6 +2385,9 @@ function auditTask(meta, answer) {
2374
2385
  return [
2375
2386
  "You are auditing another worker's answer to a read-only question about this project. Do not take the answer on trust:",
2376
2387
  "check its key claims yourself against the files here (open the cited files and lines, search for what it says exists or is missing).",
2388
+ "Check the answer against the QUESTION, not only its own claims: every condition the question sets (a directory or scope,",
2389
+ 'what to leave out, "all", "only", "exactly", a count) must hold. Valid references do not make an answer right:',
2390
+ "for a list, look for items that are missing and items that do not belong; for a count, count again yourself.",
2377
2391
  "Do not edit anything and do not run anything that changes files.",
2378
2392
  "",
2379
2393
  "QUESTION:",
@@ -2388,7 +2402,7 @@ function auditTask(meta, answer) {
2388
2402
  "DISPUTED:",
2389
2403
  "- <the claim> \u2014 <what is actually true, with path:line>",
2390
2404
  "",
2391
- "AGREE: every key claim you checked holds. PARTIAL: some are wrong or could not be verified. DISAGREE: the main conclusion is wrong.",
2405
+ "AGREE: every key claim you checked holds and the answer meets every condition of the question (no extra or missing items, the right count). PARTIAL: some are wrong, unverified, or the answer goes beyond or falls short of the question's scope. DISAGREE: the main conclusion is wrong.",
2392
2406
  'Under DISPUTED list only claims you checked and found wrong or unsupported; write "- (none)" when there are none.'
2393
2407
  ].join("\n");
2394
2408
  }
@@ -2574,18 +2588,18 @@ function place(src, dest, opts) {
2574
2588
  }
2575
2589
  function install(opts) {
2576
2590
  const root = packageRoot();
2577
- const skills = opts.skills === false ? [] : skillNames(root);
2578
- if (opts.skills !== false && !skills.length) throw new Error(`no skills found under ${path14.join(root, "skills")}`);
2591
+ const skills2 = opts.skills === false ? [] : skillNames(root);
2592
+ if (opts.skills !== false && !skills2.length) throw new Error(`no skills found under ${path14.join(root, "skills")}`);
2579
2593
  const out = [];
2580
2594
  for (const base2 of opts.skills === false ? [] : skillTargets()) {
2581
2595
  for (const legacy of LEGACY) {
2582
2596
  const l = path14.join(base2, legacy);
2583
- if (!skills.includes(legacy) && linksInto(l, root)) {
2597
+ if (!skills2.includes(legacy) && linksInto(l, root)) {
2584
2598
  fs16.unlinkSync(l);
2585
2599
  out.push(`\u2714 removed old link ${l}`);
2586
2600
  }
2587
2601
  }
2588
- for (const name of skills) out.push(place(path14.join(root, "skills", name), path14.join(base2, name), opts));
2602
+ for (const name of skills2) out.push(place(path14.join(root, "skills", name), path14.join(base2, name), opts));
2589
2603
  }
2590
2604
  const bundle = path14.join(root, "dist", "pitroom.mjs");
2591
2605
  if (fs16.existsSync(bundle)) {
@@ -3129,12 +3143,12 @@ function hookCards(input) {
3129
3143
  const output = typeof response === "string" ? response : [response?.stdout, response?.stderr].filter(Boolean).join("\n");
3130
3144
  const ids = [...new Set(`${command}
3131
3145
  ${output}`.match(RUN_ID) ?? [])].filter((id) => fs21.existsSync(`${runsDir()}/${id}`));
3132
- const since = Date.now() - RECENT_HOURS * 36e5;
3146
+ const since2 = Date.now() - RECENT_HOURS * 36e5;
3133
3147
  for (const id of listRunIds().slice(-RECENT)) {
3134
3148
  if (ids.includes(id) || fs21.existsSync(runFile(id, "card-ended"))) continue;
3135
3149
  try {
3136
3150
  const m = readMeta(id);
3137
- if (!isActive(m.state) && Date.parse(m.endedAt ?? m.startedAt) > since) ids.push(id);
3151
+ if (!isActive(m.state) && Date.parse(m.endedAt ?? m.startedAt) > since2) ids.push(id);
3138
3152
  } catch {
3139
3153
  }
3140
3154
  }
@@ -3347,6 +3361,7 @@ function archivedId(ref) {
3347
3361
  return void 0;
3348
3362
  }
3349
3363
  }
3364
+ var VERIFY_FAILED = "(r.state = 'done' AND json_extract(r.meta_json, '$.verifyResult.ok') = 0)";
3350
3365
  var ftsQuery = (text) => text.split(/\s+/).filter(Boolean).map((w) => `"${w.replace(/"/g, '""')}"*`).join(" ");
3351
3366
  function listHistory(q = {}) {
3352
3367
  const db = openDb();
@@ -3366,7 +3381,7 @@ function listHistory(q = {}) {
3366
3381
  args.push(q.backend);
3367
3382
  }
3368
3383
  if (q.state) {
3369
- where.push(q.state === "problem" ? "r.state IN ('failed','timeout','stopped')" : "r.state = ?");
3384
+ where.push(q.state === "problem" ? `(r.state IN ('failed','timeout','stopped') OR ${VERIFY_FAILED})` : "r.state = ?");
3370
3385
  if (q.state !== "problem") args.push(q.state);
3371
3386
  }
3372
3387
  if (q.group) {
@@ -3381,7 +3396,7 @@ function listHistory(q = {}) {
3381
3396
  try {
3382
3397
  const total = db.prepare(`SELECT COUNT(*) AS n FROM runs r ${base2}`).get(...args).n;
3383
3398
  const page = q.beforeId ? `${base2 ? `${base2} AND` : "WHERE"} r.id < ?` : base2;
3384
- const rows = db.prepare(`SELECT r.*, json_extract(r.meta_json, '$.audit.state') AS audit_state, json_extract(r.meta_json, '$.audit.verdict') AS audit_verdict FROM runs r ${page} ORDER BY r.id DESC LIMIT ?`).all(...args, ...q.beforeId ? [q.beforeId] : [], Math.min(Math.max(q.limit ?? 30, 1), 200));
3399
+ const rows = db.prepare(`SELECT r.*, json_extract(r.meta_json, '$.audit.state') AS audit_state, json_extract(r.meta_json, '$.audit.verdict') AS audit_verdict, ${VERIFY_FAILED} AS verify_failed FROM runs r ${page} ORDER BY r.id DESC LIMIT ?`).all(...args, ...q.beforeId ? [q.beforeId] : [], Math.min(Math.max(q.limit ?? 30, 1), 200));
3385
3400
  return {
3386
3401
  total,
3387
3402
  rows: rows.map((r) => ({
@@ -3395,6 +3410,7 @@ function listHistory(q = {}) {
3395
3410
  group: r.grp ?? void 0,
3396
3411
  verdict: r.verdict ?? void 0,
3397
3412
  audit: auditBadge({ audit: r.audit_state ? { id: "", state: r.audit_state, verdict: r.audit_verdict ?? void 0 } : void 0 }),
3413
+ verifyFailed: r.verify_failed ? true : void 0,
3398
3414
  seconds: r.seconds ?? void 0,
3399
3415
  steps: r.steps ?? void 0,
3400
3416
  tokens: r.tokens ?? void 0,
@@ -3408,17 +3424,17 @@ function listHistory(q = {}) {
3408
3424
  }
3409
3425
  }
3410
3426
  var NOT_AUDIT = "json_extract(meta_json, '$.auditOf') IS NULL";
3411
- function auditStats(db, since) {
3427
+ function auditStats(db, since2) {
3412
3428
  const total = { runs: 0, agree: 0, partial: 0, disagree: 0, unclear: 0, tokens: 0 };
3413
3429
  const byWorker = /* @__PURE__ */ new Map();
3414
3430
  try {
3415
- const t = db.prepare("SELECT COUNT(*) runs, COALESCE(SUM(tokens),0) tokens FROM runs WHERE started_at >= ? AND json_extract(meta_json, '$.auditOf') IS NOT NULL").get(since);
3431
+ const t = db.prepare("SELECT COUNT(*) runs, COALESCE(SUM(tokens),0) tokens FROM runs WHERE started_at >= ? AND json_extract(meta_json, '$.auditOf') IS NOT NULL").get(since2);
3416
3432
  total.runs = t.runs;
3417
3433
  total.tokens = t.tokens;
3418
3434
  const rows = db.prepare(`SELECT r.backend, r.model, json_extract(a.meta_json, '$.auditVerdict') v, COUNT(*) n
3419
3435
  FROM runs a JOIN runs r ON r.id = json_extract(a.meta_json, '$.auditOf')
3420
3436
  WHERE a.started_at >= ? AND a.state = 'done' AND json_extract(a.meta_json, '$.auditOf') IS NOT NULL
3421
- GROUP BY r.backend, r.model, v`).all(since);
3437
+ GROUP BY r.backend, r.model, v`).all(since2);
3422
3438
  for (const r of rows) {
3423
3439
  const v = r.v ?? "unclear";
3424
3440
  if (v in total && v !== "runs" && v !== "tokens") total[v] += r.n;
@@ -3456,12 +3472,12 @@ function historyStats(sinceMs2) {
3456
3472
  const empty = { totals: { runs: 0, ok: 0, failed: 0, seconds: 0, tokens: 0, saved: 0 }, audits: { runs: 0, agree: 0, partial: 0, disagree: 0, unclear: 0, tokens: 0 }, byWorker: [], byDay: [] };
3457
3473
  const db = openDb();
3458
3474
  if (!db) return empty;
3459
- const since = sinceMs2 ? new Date(sinceMs2).toISOString() : "";
3475
+ const since2 = sinceMs2 ? new Date(sinceMs2).toISOString() : "";
3460
3476
  try {
3461
- const t = db.prepare(`SELECT COUNT(*) runs, COALESCE(SUM(state='done'),0) ok, COALESCE(SUM(seconds),0) seconds, COALESCE(SUM(tokens),0) tokens, COALESCE(SUM(saved),0) saved FROM runs WHERE started_at >= ? AND ${NOT_AUDIT}`).get(since);
3462
- const w = db.prepare(`SELECT backend, model, COUNT(*) runs, SUM(state='done') ok, AVG(seconds) avg_s, AVG(tokens) avg_t, COALESCE(SUM(saved),0) saved FROM runs WHERE started_at >= ? AND ${NOT_AUDIT} GROUP BY backend, model ORDER BY runs DESC LIMIT 40`).all(since);
3463
- const d = db.prepare(`SELECT substr(started_at,1,10) day, COUNT(*) runs, SUM(state='done') ok, COALESCE(SUM(saved),0) saved FROM runs WHERE started_at >= ? AND ${NOT_AUDIT} GROUP BY day ORDER BY day DESC LIMIT 60`).all(since);
3464
- const audits = auditStats(db, since);
3477
+ const t = db.prepare(`SELECT COUNT(*) runs, COALESCE(SUM(state='done'),0) ok, COALESCE(SUM(seconds),0) seconds, COALESCE(SUM(tokens),0) tokens, COALESCE(SUM(saved),0) saved FROM runs WHERE started_at >= ? AND ${NOT_AUDIT}`).get(since2);
3478
+ const w = db.prepare(`SELECT backend, model, COUNT(*) runs, SUM(state='done') ok, AVG(seconds) avg_s, AVG(tokens) avg_t, COALESCE(SUM(saved),0) saved FROM runs WHERE started_at >= ? AND ${NOT_AUDIT} GROUP BY backend, model ORDER BY runs DESC LIMIT 40`).all(since2);
3479
+ const d = db.prepare(`SELECT substr(started_at,1,10) day, COUNT(*) runs, SUM(state='done') ok, COALESCE(SUM(saved),0) saved FROM runs WHERE started_at >= ? AND ${NOT_AUDIT} GROUP BY day ORDER BY day DESC LIMIT 60`).all(since2);
3480
+ const audits = auditStats(db, since2);
3465
3481
  const ledger = readLedger(sinceMs2);
3466
3482
  const byDay = /* @__PURE__ */ new Map();
3467
3483
  for (const e of ledger) byDay.set(e.at.slice(0, 10), (byDay.get(e.at.slice(0, 10)) ?? 0) + e.saved);
@@ -3541,7 +3557,9 @@ function readSummary(meta) {
3541
3557
  }
3542
3558
  function formatReport(meta, finalText = readSummary(meta), maxLines = 400) {
3543
3559
  const out = [];
3544
- out.push(`${bold("pitroom")} ${stateColour(meta.state, `${ICON2[meta.state]} ${meta.state}`)} \xB7 ${meta.mode} \xB7 ${duration(meta)} \xB7 run ${dim(meta.id)}`);
3560
+ const verifyFailed = meta.state === "done" && meta.verifyResult && !meta.verifyResult.ok;
3561
+ const head = verifyFailed ? stateColour("failed", "\u26A0 done \xB7 verify failed") : stateColour(meta.state, `${ICON2[meta.state]} ${meta.state}`);
3562
+ out.push(`${bold("pitroom")} ${head} \xB7 ${meta.mode} \xB7 ${duration(meta)} \xB7 run ${dim(meta.id)}`);
3545
3563
  const ran = meta.ran ?? meta.worker;
3546
3564
  const ids = [`worker ${ran.backend}`, meta.resolvedModel && `model ${meta.resolvedModel}`, meta.sessionId && `session ${meta.sessionId}`];
3547
3565
  out.push(ids.filter(Boolean).join(" \xB7 "));
@@ -3607,7 +3625,9 @@ function formatReport(meta, finalText = readSummary(meta), maxLines = 400) {
3607
3625
  }
3608
3626
  if (meta.verifyResult) {
3609
3627
  const v = meta.verifyResult;
3610
- out.push(`\u2500\u2500 verify: \`${meta.verify}\` ${v.ok ? "\u2714 passed" : `\u2718 failed (exit ${v.code})`}`);
3628
+ const notFound = !v.ok && v.code === 127 && /not found|no such file/i.test(v.tail);
3629
+ out.push(`\u2500\u2500 verify: \`${meta.verify}\` ${v.ok ? "\u2714 passed" : notFound ? "\u2718 could not run (exit 127: command not found)" : `\u2718 failed (exit ${v.code})`}`);
3630
+ if (notFound) out.push(" the command is not on the PATH Pitroom runs with (an app such as an MCP client may start it without your shell's PATH): give its full path, or set PATH in the command");
3611
3631
  if (!v.ok && v.tail) out.push(...v.tail.split("\n").map((l) => ` ${l}`));
3612
3632
  }
3613
3633
  if (isActive(meta.state)) {
@@ -3631,7 +3651,7 @@ function progress(meta) {
3631
3651
  }
3632
3652
 
3633
3653
  // src/core/run.ts
3634
- var VERSION2 = true ? "0.15.0" : "0.0.0-dev";
3654
+ var VERSION2 = true ? "0.17.0" : "0.0.0-dev";
3635
3655
  function planWork(o) {
3636
3656
  if (!o.plan) return { task: o.task.trim(), tier: o.tier };
3637
3657
  if (o.continueFrom) throw new UserError("a follow-up continues its parent's task; drop --plan/--step");
@@ -3729,6 +3749,7 @@ function prepareRun(o) {
3729
3749
  auditOf: o.audit?.of,
3730
3750
  auditRate: o.auditRate,
3731
3751
  inPlace: o.inPlace || void 0,
3752
+ cache: o.cache,
3732
3753
  snapshot: parent?.snapshot ?? (snap ? { dir: "", tree: "", left: snap.secrets } : void 0),
3733
3754
  plan: work.plan ?? o.review?.plan ?? parent?.plan,
3734
3755
  reviewKind: o.review?.kind,
@@ -4000,6 +4021,12 @@ function captureChanges(meta) {
4000
4021
  meta.stats = d.stats;
4001
4022
  fs24.writeFileSync(runFile(meta.id, "changes.patch"), d.patch);
4002
4023
  }
4024
+ function verifyPath() {
4025
+ const extra = [path20.dirname(process.execPath), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"];
4026
+ const parts = (process.env.PATH ?? "").split(path20.delimiter).filter(Boolean);
4027
+ for (const d of process.platform === "win32" ? [path20.dirname(process.execPath)] : extra) if (!parts.includes(d)) parts.push(d);
4028
+ return parts.join(path20.delimiter);
4029
+ }
4003
4030
  function runVerify(meta) {
4004
4031
  const r = spawnSync6(meta.verify, {
4005
4032
  cwd: meta.cwd,
@@ -4008,7 +4035,7 @@ function runVerify(meta) {
4008
4035
  timeout: 15 * 6e4,
4009
4036
  stdio: ["ignore", "pipe", "pipe"],
4010
4037
  maxBuffer: 64 * 1024 * 1024,
4011
- env: { ...process.env, PWD: meta.cwd, PITROOM_ACTIVE: "1" }
4038
+ env: { ...process.env, PATH: verifyPath(), PWD: meta.cwd, PITROOM_ACTIVE: "1" }
4012
4039
  });
4013
4040
  const output = `${r.stdout ?? ""}${r.stderr ?? ""}`;
4014
4041
  fs24.writeFileSync(runFile(meta.id, "verify.log"), output);
@@ -4059,17 +4086,128 @@ function cleanupWorktree(meta) {
4059
4086
  if (meta.worktree && fs24.existsSync(meta.worktree)) removeIsolatedCopy(meta.worktree, worktreesDir());
4060
4087
  }
4061
4088
 
4062
- // src/cli/mcp.ts
4063
- var PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"];
4089
+ // src/cli/mcp-extras.ts
4090
+ import fs25 from "node:fs";
4091
+ import path21 from "node:path";
4092
+
4093
+ // src/cli/mcp-support.ts
4094
+ import { spawn as spawn3 } from "node:child_process";
4095
+
4096
+ // src/core/group.ts
4097
+ function groupIds(group) {
4098
+ return listRunIds().filter((id) => {
4099
+ try {
4100
+ return readMeta(id).group === group;
4101
+ } catch {
4102
+ return false;
4103
+ }
4104
+ });
4105
+ }
4106
+ var oneLine5 = (s, max) => {
4107
+ const t = s.replace(/\s+/g, " ").trim();
4108
+ return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
4109
+ };
4110
+ function headline(meta, max = 160) {
4111
+ const text = readSummary(meta);
4112
+ const line = text.split("\n").find((l) => /^SUMMARY:/i.test(l.trim())) ?? text.split("\n").find((l) => l.trim()) ?? "";
4113
+ return oneLine5(line.replace(/^SUMMARY:\s*/i, ""), max);
4114
+ }
4115
+ function table(metas) {
4116
+ if (!metas.length) return "no runs";
4117
+ const rows = metas.map((m) => {
4118
+ const l = m.state === "running" ? live(m) : void 0;
4119
+ const steps = l ? String(l.steps) : m.usage ? String(m.usage.steps) : "-";
4120
+ const note = l?.last ? oneLine5(l.last, 48) : isActive(m.state) ? oneLine5(m.task, 48) : headline(m, 48) || oneLine5(m.error ?? m.task, 48);
4121
+ return [m.id, m.state, m.mode, duration(m), steps, describeTarget(m.ran ?? m.worker), note];
4122
+ });
4123
+ const head = ["RUN", "STATE", "MODE", "TIME", "STEPS", "WORKER", "NOW / RESULT"];
4124
+ const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
4125
+ const fmt = (r, header = false) => r.map((c, i) => {
4126
+ const cell2 = i === r.length - 1 ? c : c.padEnd(widths[i]);
4127
+ return header ? dim(cell2) : i === 1 ? stateColour(r[1], cell2) : cell2;
4128
+ }).join(" ");
4129
+ return [fmt(head, true), ...rows.map((r) => fmt(r))].join("\n");
4130
+ }
4131
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
4132
+ async function waitMany(ids, opts) {
4133
+ const deadline = Date.now() + opts.timeoutMs;
4134
+ for (; ; ) {
4135
+ const metas = ids.map((id) => freshMeta(id));
4136
+ const done = metas.filter((m) => TERMINAL.includes(m.state)).length;
4137
+ if (opts.any ? done > 0 : done === metas.length) return { metas, timedOut: false };
4138
+ if (Date.now() >= deadline) return { metas, timedOut: true };
4139
+ await sleep2(1500);
4140
+ }
4141
+ }
4142
+ async function watch(select, opts) {
4143
+ const seen = /* @__PURE__ */ new Map();
4144
+ const line = (s) => opts.write(`${s}
4145
+ `);
4146
+ const emit = (e, m) => {
4147
+ if (!opts.brief) return opts.write(`${JSON.stringify(e)}
4148
+ `);
4149
+ if (e.event === "started" && m) line(startedCard(m));
4150
+ else if (e.event === "fallback" && m) line(`\u{1F3C1} Pitroom \u21BB ${m.id} fell back from ${String(e.failed)}: ${String(e.reason)}`);
4151
+ else if (m && TERMINAL.includes(m.state)) line(endedCard(m));
4152
+ else if (e.event === "all-done") line(`\u{1F3C1} Pitroom: ${String(e.runs)} run${e.runs === 1 ? "" : "s"} finished (${String(e.ok)} ok${e.failed ? `, ${String(e.failed)} not ok` : ""})`);
4153
+ };
4154
+ const deadline = opts.timeoutMs > 0 ? Date.now() + opts.timeoutMs : Infinity;
4155
+ for (; ; ) {
4156
+ const metas = select().map((id) => freshMeta(id));
4157
+ if (opts.json || opts.brief) {
4158
+ for (const m of metas) {
4159
+ const prev = seen.get(m.id);
4160
+ const l = m.state === "running" ? live(m) : { steps: m.usage?.steps ?? 0, toolCalls: m.usage?.toolCalls ?? 0 };
4161
+ const attempts = m.attempts?.length ?? 0;
4162
+ const base2 = { run: m.id, mode: m.mode, task: oneLine5(m.task, 60) };
4163
+ if (!prev) emit({ event: m.state === "queued" ? "queued" : "started", ...base2 }, m);
4164
+ else if (prev.state === "queued" && m.state === "running") emit({ event: "started", ...base2 }, m);
4165
+ if (attempts > (prev?.attempts ?? 0)) {
4166
+ const a = m.attempts[attempts - 1];
4167
+ emit({ event: "fallback", run: m.id, failed: a.target, reason: oneLine5(a.error, 120) }, m);
4168
+ }
4169
+ if (m.state === "running" && l.steps > (prev?.steps ?? 0)) {
4170
+ emit({ event: "progress", run: m.id, steps: l.steps, tools: l.toolCalls, last: "last" in l && l.last ? oneLine5(String(l.last), 80) : void 0 });
4171
+ }
4172
+ if (TERMINAL.includes(m.state) && prev?.state !== m.state) {
4173
+ emit({
4174
+ event: m.state,
4175
+ run: m.id,
4176
+ time: duration(m),
4177
+ worker: describeTarget(m.ran ?? m.worker),
4178
+ summary: headline(m) || void 0,
4179
+ error: m.error ? oneLine5(m.error, 160) : void 0,
4180
+ refs: m.refs ? `${m.refs.valid}/${m.refs.total}` : void 0,
4181
+ changes: m.changes?.length || void 0
4182
+ }, m);
4183
+ }
4184
+ seen.set(m.id, { state: m.state, steps: l.steps, attempts });
4185
+ }
4186
+ } else {
4187
+ opts.write(`\x1B[H\x1B[2J${table(metas)}
4188
+
4189
+ ${(/* @__PURE__ */ new Date()).toLocaleTimeString()} \xB7 Ctrl-C to stop watching (workers keep running)
4190
+ `);
4191
+ }
4192
+ const allDone = metas.length > 0 && metas.every((m) => TERMINAL.includes(m.state));
4193
+ if (allDone || metas.length === 0 && seen.size === 0) {
4194
+ if (opts.json || opts.brief) {
4195
+ const ok = metas.filter((m) => m.state === "done").length;
4196
+ emit({ event: "all-done", runs: metas.length, ok, failed: metas.length - ok });
4197
+ }
4198
+ return { metas, timedOut: false };
4199
+ }
4200
+ if (Date.now() >= deadline) return { metas, timedOut: true };
4201
+ await sleep2(opts.intervalMs);
4202
+ }
4203
+ }
4204
+
4205
+ // src/cli/mcp-support.ts
4064
4206
  var MAX_OUTPUT = 12e4;
4065
4207
  var DEFAULT_WAIT = 50;
4066
4208
  var MAX_WAIT = 540;
4067
- var INSTRUCTIONS = [
4068
- "Pitroom hands bounded work to cheaper worker agents and returns a verified answer, the exact diff and a cost receipt. You decide, verify and answer.",
4069
- 'Use pitroom_run with mode "read" for research and locating code, mode "isolate" for code changes (the worker edits a copy; check it with pitroom_review, then pitroom_apply or pitroom_discard).',
4070
- 'A run can take minutes: pitroom_run waits up to waitSeconds, then returns the run id as "still running"; call pitroom_wait with it.',
4071
- "It is optional: for a small task you can do yourself, skip it."
4072
- ].join(" ");
4209
+ var MAX_TASKS = 20;
4210
+ var PROGRESS_EVERY_MS = 5e3;
4073
4211
  var ToolError = class extends Error {
4074
4212
  };
4075
4213
  var str = (a, key, required = false) => {
@@ -4105,6 +4243,12 @@ var oneOf = (a, key, allowed, fallback) => {
4105
4243
  if (!allowed.includes(v)) throw new ToolError(`"${key}" must be one of: ${allowed.join(", ")}`);
4106
4244
  return v;
4107
4245
  };
4246
+ var since = (a) => {
4247
+ const v = str(a, "since");
4248
+ if (v === void 0) return [];
4249
+ if (v !== "all" && !/^\d+d$/.test(v)) throw new ToolError('"since" takes 7d, 30d, \u2026 or all');
4250
+ return ["--since", v];
4251
+ };
4108
4252
  function workerFlags(a, only) {
4109
4253
  const out = [];
4110
4254
  const on = (key) => !only || only.includes(key);
@@ -4122,7 +4266,7 @@ function workerFlags(a, only) {
4122
4266
  var strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
4123
4267
  var clip6 = (s) => s.length > MAX_OUTPUT ? `${s.slice(0, MAX_OUTPUT)}
4124
4268
  \u2026 (${s.length - MAX_OUTPUT} more characters)` : s;
4125
- function pit(args, timeoutMs = 60 * 6e4) {
4269
+ function pit(args, timeoutMs = 60 * 6e4, signal) {
4126
4270
  return new Promise((resolve2) => {
4127
4271
  const script = process.argv[1];
4128
4272
  if (!script) return resolve2({ code: 1, out: "", err: "cannot locate the pitroom executable" });
@@ -4131,10 +4275,14 @@ function pit(args, timeoutMs = 60 * 6e4) {
4131
4275
  let err = "";
4132
4276
  child.stdout.on("data", (d) => out += d);
4133
4277
  child.stderr.on("data", (d) => err += d);
4134
- const timer = setTimeout(() => child.kill("SIGTERM"), timeoutMs);
4278
+ const stop = () => child.kill("SIGTERM");
4279
+ const timer2 = setTimeout(stop, timeoutMs);
4280
+ signal?.addEventListener("abort", stop, { once: true });
4281
+ if (signal?.aborted) stop();
4135
4282
  child.on("error", (e) => resolve2({ code: 1, out, err: err || String(e.message) }));
4136
4283
  child.on("close", (code) => {
4137
- clearTimeout(timer);
4284
+ clearTimeout(timer2);
4285
+ signal?.removeEventListener("abort", stop);
4138
4286
  resolve2({ code, out: strip(out).trim(), err: strip(err).trim() });
4139
4287
  });
4140
4288
  });
@@ -4144,117 +4292,365 @@ function asResult(r, still) {
4144
4292
  if (r.code === 75 && still) return { text: clip6(`${text}
4145
4293
 
4146
4294
  ${still}`) };
4147
- return { text: clip6(text), isError: r.code === 2 || r.code === 3 || r.code === 1 && !r.out };
4295
+ return { text: clip6(text), isError: r.code === 2 || r.code === 3 || r.code === 5 || r.code === 6 || r.code === 1 && !r.out };
4148
4296
  }
4149
- async function startAndWait(start, seconds, task) {
4297
+ var plain = async (args, ctx, timeoutMs = 6e4) => asResult(await pit(args, timeoutMs, ctx.signal));
4298
+ function trackProgress(ctx, ids) {
4299
+ const tick2 = () => {
4300
+ try {
4301
+ const lines = ids().map((id) => freshMeta(id)).filter((m) => isActive(m.state)).map((m) => progress(m).replace(/^pitroom\s+/, ""));
4302
+ if (lines.length) ctx.progress(lines.slice(0, 3).join("\n") + (lines.length > 3 ? `
4303
+ \u2026 and ${lines.length - 3} more` : ""));
4304
+ } catch {
4305
+ }
4306
+ };
4307
+ const first = setTimeout(tick2, 800);
4308
+ const timer2 = setInterval(tick2, PROGRESS_EVERY_MS);
4309
+ return () => {
4310
+ clearTimeout(first);
4311
+ clearInterval(timer2);
4312
+ };
4313
+ }
4314
+ async function waitFor(ids, seconds, ctx, group) {
4315
+ const args = group ? ["wait", "-g", group, "--timeout", String(seconds)] : ["wait", ...ids, "--timeout", String(seconds)];
4316
+ const done = trackProgress(ctx, () => group ? groupIds(group) : ids);
4317
+ let r;
4318
+ try {
4319
+ r = await pit(args, (seconds + 60) * 1e3, ctx.signal);
4320
+ } finally {
4321
+ done();
4322
+ }
4323
+ const again = group ? `{"group": "${group}"}` : `{"runs": ${JSON.stringify(ids)}}`;
4324
+ return asResult(r, `Not finished yet: call pitroom_wait with ${again} (waitSeconds up to ${MAX_WAIT}); pitroom_stop ends it.`);
4325
+ }
4326
+ var cancelled = (ctx) => ctx.signal.aborted && ctx.signal.reason === "cancelled";
4327
+ async function stopQuietly(args) {
4328
+ const r = await pit(args, 3e4);
4329
+ if (r.code !== 0) process.stderr.write(`pitroom mcp: ${args.join(" ")} failed: ${r.err || r.out}
4330
+ `);
4331
+ }
4332
+ async function startAndWait(start, seconds, ctx, task) {
4150
4333
  const started = await pit([...start, "--bg", "--json", ...task === void 0 ? [] : ["--", task]]);
4151
4334
  if (started.code !== 0) return asResult(started);
4152
4335
  let id;
4336
+ let cached3 = false;
4153
4337
  try {
4154
- id = JSON.parse(started.out).id;
4338
+ ({ id, cached: cached3 = false } = JSON.parse(started.out));
4155
4339
  } catch {
4156
4340
  return { text: `could not read the run id from: ${started.out.slice(0, 200)}`, isError: true };
4157
4341
  }
4158
- return waitFor([id], seconds);
4342
+ const result = await waitFor([id], seconds, ctx);
4343
+ if (cancelled(ctx) && !cached3) await stopQuietly(["stop", id]);
4344
+ if (cached3) result.text = `Cached answer: the same question on the same code as run ${id}; no worker ran (fresh: true asks one).
4345
+
4346
+ ${result.text}`;
4347
+ return result;
4348
+ }
4349
+ async function startCrew(flags, tasks, seconds, ctx) {
4350
+ const started = await pit(["crew", ...flags, "--json", "--", ...tasks]);
4351
+ if (started.code !== 0) return asResult(started);
4352
+ let group;
4353
+ try {
4354
+ group = JSON.parse(started.out).group;
4355
+ } catch {
4356
+ return { text: `could not read the group from: ${started.out.slice(0, 200)}`, isError: true };
4357
+ }
4358
+ const result = await waitFor([], seconds, ctx, group);
4359
+ if (cancelled(ctx)) await stopQuietly(["stop", "-g", group]);
4360
+ return result;
4159
4361
  }
4160
- async function waitFor(ids, seconds, group) {
4161
- const args = group ? ["wait", "-g", group, "--timeout", String(seconds)] : ["wait", ...ids, "--timeout", String(seconds)];
4162
- const r = await pit(args, (seconds + 60) * 1e3);
4163
- const again = group ? `{"group": "${group}"}` : `{"runs": ${JSON.stringify(ids)}}`;
4164
- return asResult(r, `Not finished yet: call pitroom_wait with ${again} (waitSeconds up to ${MAX_WAIT}); pitroom_stop ends it.`);
4362
+
4363
+ // src/cli/mcp-extras.ts
4364
+ var RpcError = class extends Error {
4365
+ constructor(code, message) {
4366
+ super(message);
4367
+ this.code = code;
4368
+ }
4369
+ };
4370
+ var RUN = /^pitroom:\/\/run\/(\d{8}-\d{6}-[0-9a-f]{4})(\/patch)?$/;
4371
+ var LISTED = 30;
4372
+ var runOfUri = (uri) => RUN.exec(uri)?.[1];
4373
+ var oneLine6 = (text, n) => {
4374
+ const flat = text.replace(/\s+/g, " ").trim();
4375
+ return flat.length > n ? `${flat.slice(0, n - 1)}\u2026` : flat;
4376
+ };
4377
+ function listResources() {
4378
+ const resources = [];
4379
+ for (const id of listRunIds().slice(-LISTED).reverse()) {
4380
+ try {
4381
+ const m = freshMeta(id);
4382
+ const what2 = `${m.state} \xB7 ${m.mode} \xB7 ${m.worker.backend}${m.worker.model ? `:${m.worker.model.split("/").pop()}` : ""}`;
4383
+ resources.push({ uri: `pitroom://run/${id}`, name: `run ${id}`, title: oneLine6(m.task, 70), description: `${what2}: the report`, mimeType: "text/plain" });
4384
+ if (m.changes?.length) resources.push({ uri: `pitroom://run/${id}/patch`, name: `patch ${id}`, title: `Patch of ${oneLine6(m.task, 60)}`, description: `${what2}: the exact diff, ${m.changes.length} file(s)`, mimeType: "text/x-diff" });
4385
+ } catch {
4386
+ }
4387
+ }
4388
+ return { resources };
4389
+ }
4390
+ var resourceTemplates = () => ({
4391
+ resourceTemplates: [
4392
+ { uriTemplate: "pitroom://run/{id}", name: "run-report", title: "A run's report", description: "The report of a run: answer, receipt, verified references.", mimeType: "text/plain" },
4393
+ { uriTemplate: "pitroom://run/{id}/patch", name: "run-patch", title: "A run's patch", description: "The exact diff an isolated or in-place run made.", mimeType: "text/x-diff" }
4394
+ ]
4395
+ });
4396
+ async function readResource(uri, ctx) {
4397
+ const m = RUN.exec(uri);
4398
+ if (!m) throw new RpcError(-32002, `unknown resource: ${uri}`);
4399
+ const patch = m[2] !== void 0;
4400
+ const r = await pit(["show", m[1], patch ? "--patch" : "--full"], 6e4, ctx.signal);
4401
+ if (r.code !== 0) throw new RpcError(r.code === 3 || r.code === 2 ? -32002 : -32603, r.err || r.out || `run ${m[1]} cannot be read`);
4402
+ return { contents: [{ uri, mimeType: patch ? "text/x-diff" : "text/plain", text: clip6(r.out) }] };
4403
+ }
4404
+ var PROMPTS = [
4405
+ {
4406
+ name: "research",
4407
+ title: "Research with a worker",
4408
+ description: "Find, map or explain code through a read-only worker, and verify what comes back.",
4409
+ arguments: [{ name: "question", description: "What to find out about the project.", required: true }],
4410
+ text: (a) => `Use the pitroom_run tool (mode "read") to find out: ${a.question}
4411
+
4412
+ Give the worker the full question and any file it should start from. When it answers, check one or two of the cited file:line references yourself before relying on the answer, and say plainly what is verified and what is not. If it comes back "still running", call pitroom_wait with the run id.`
4413
+ },
4414
+ {
4415
+ name: "implement",
4416
+ title: "Get a change made",
4417
+ description: "A worker makes a change in an isolated copy; you review the exact diff and apply it only when it is right.",
4418
+ arguments: [{ name: "task", description: "The change to make, with the context a worker needs.", required: true }],
4419
+ text: (a) => `Get this change made by a worker: ${a.task}
4420
+
4421
+ Call pitroom_run with mode "isolate" (add "verify" with the project's test command if there is one). Read the diff with pitroom_show (patch: true), then have it judged with pitroom_review. If the diff does what was asked and the review has no critical findings, call pitroom_apply; otherwise pitroom_run with "continue" to ask for the fix, or pitroom_discard. Do not apply a change you have not read.`
4422
+ },
4423
+ {
4424
+ name: "review",
4425
+ title: "Review changes with another model",
4426
+ description: "A read-only reviewer from another model judges a commit range or the latest change.",
4427
+ arguments: [{ name: "range", description: "A commit range such as main..HEAD. Default: the latest run's change." }],
4428
+ text: (a) => (a.range ? `Have the commits ${a.range} reviewed: call pitroom_review with range "${a.range}".` : 'Have the latest change reviewed: call pitroom_review with run "last".') + "\n\nWeigh each finding against the code before acting on it: a reviewer can be wrong. Fix what is real, and say which findings you dropped and why."
4429
+ },
4430
+ {
4431
+ name: "crew",
4432
+ title: "Split work across workers",
4433
+ description: "Independent tasks run in parallel as one group of workers.",
4434
+ arguments: [{ name: "tasks", description: "The tasks, one per line.", required: true }],
4435
+ text: (a) => `Run these independent tasks in parallel: call pitroom_run with "tasks" (one worker each):
4436
+
4437
+ ${a.tasks}
4438
+
4439
+ Make each task self-contained. Use mode "isolate" if they change files, then read every patch (pitroom_show, patch: true) and apply them with pitroom_apply (group). Only split work that does not depend on each other.`
4440
+ }
4441
+ ];
4442
+ var TOOL_MAP = 'Through this MCP server the `pitroom` commands below are tools: `pitroom run` is pitroom_run (`-i` is mode "isolate", `-w` mode "write", `--continue` is continue, `--verify` is verify), `pitroom crew` is pitroom_run with tasks, `pitroom wait` pitroom_wait, `pitroom show`/`status` pitroom_show, `pitroom review` pitroom_review, `pitroom audit` pitroom_audit, `pitroom apply`/`discard`/`revert`/`stop` the tools of those names, `pitroom history`/`ls`/`models`/`doctor` pitroom_info. With a shell the commands work as well.';
4443
+ var skills;
4444
+ function skillPrompts() {
4445
+ if (skills) return skills;
4446
+ const found = [];
4447
+ const root = packageRoot();
4448
+ for (const name of skillNames(root)) {
4449
+ try {
4450
+ const text = fs25.readFileSync(path21.join(root, "skills", name, "SKILL.md"), "utf8");
4451
+ const front = /^---\n([\s\S]*?)\n---\n/.exec(text);
4452
+ const body = (front ? text.slice(front[0].length) : text).trim();
4453
+ const description = /^description:\s*(.+)$/m.exec(front?.[1] ?? "")?.[1]?.trim() ?? `The ${name} skill.`;
4454
+ const title = /^#\s+(.+)$/m.exec(body)?.[1]?.trim() ?? name;
4455
+ found.push({
4456
+ name,
4457
+ title,
4458
+ // the first sentence: prompts/list is read whole by some clients, so it stays short
4459
+ description: `Skill: ${description.split(/(?<=\.)\s/)[0]}`,
4460
+ arguments: [{ name: "task", description: "What you are about to do (optional)." }],
4461
+ text: (a) => `${TOOL_MAP}
4462
+
4463
+ ${body}${a.task ? `
4464
+
4465
+ ---
4466
+
4467
+ The task: ${a.task}` : ""}`
4468
+ });
4469
+ } catch {
4470
+ }
4471
+ }
4472
+ skills = found.filter((p) => !PROMPTS.some((b) => b.name === p.name));
4473
+ return skills;
4474
+ }
4475
+ var allPrompts = () => [...PROMPTS, ...skillPrompts()];
4476
+ var listPrompts = () => ({ prompts: allPrompts().map(({ name, title, description, arguments: args }) => ({ name, title, description, arguments: args })) });
4477
+ function getPrompt(name, given) {
4478
+ const p = allPrompts().find((x) => x.name === name);
4479
+ if (!p) throw new RpcError(-32602, `unknown prompt: ${String(name)}`);
4480
+ const args = {};
4481
+ const raw = given && typeof given === "object" && !Array.isArray(given) ? given : {};
4482
+ for (const a of p.arguments) {
4483
+ const v = raw[a.name];
4484
+ if (typeof v === "string" && v.trim()) args[a.name] = v.trim();
4485
+ else if (a.required) throw new RpcError(-32602, `missing argument: ${a.name}`);
4486
+ }
4487
+ return { description: p.description, messages: [{ role: "user", content: { type: "text", text: p.text(args) } }] };
4165
4488
  }
4489
+
4490
+ // src/cli/mcp-tools.ts
4166
4491
  var WORKER_PROPS = {
4167
- worker: { type: "string", description: 'Worker target "backend[:model]", e.g. "opencode", "codex", "claude:haiku", "gemini:gemini-3.8-flash". Default: the configured worker.' },
4168
- model: { type: "string", description: "Model for the preferred worker." },
4169
- tier: { type: "string", description: "A worker tier from the config: cheap, standard, capable (an explicit worker wins)." },
4170
- effort: { type: "string", description: "Reasoning effort for the worker: low, medium, high, xhigh." },
4171
- dir: { type: "string", description: "Project directory (default: the directory the server was started in)." },
4172
- files: { type: "array", items: { type: "string" }, description: "Files to attach (paths)." },
4173
- verify: { type: "string", description: 'A command run after the worker finishes (e.g. "npm test"); a failing one is reported.' },
4174
- link: { type: "array", items: { type: "string" }, description: 'Ignored directories to link into an isolated copy so tests can run (e.g. ["node_modules"]).' },
4175
- web: { type: "boolean", description: "Let the worker use web tools." },
4176
- group: { type: "string", description: "A name to group related runs under." }
4492
+ worker: { type: "string", description: 'Worker "backend[:model]", e.g. "opencode", "claude:haiku". Default: configured.' },
4493
+ model: { type: "string", description: "Model for that worker." },
4494
+ tier: { type: "string", description: "cheap, standard or capable (from the config)." },
4495
+ effort: { type: "string", description: "low, medium, high, xhigh." },
4496
+ dir: { type: "string", description: "Project directory (default: the server's)." },
4497
+ files: { type: "array", items: { type: "string" }, description: "Files to attach." },
4498
+ verify: { type: "string", description: 'Command run afterwards, e.g. "npm test".' },
4499
+ link: { type: "array", items: { type: "string" }, description: 'Ignored dirs to link into an isolated copy, e.g. ["node_modules"].' },
4500
+ web: { type: "boolean", description: "Allow web tools." },
4501
+ group: { type: "string", description: "Group name for related runs." }
4177
4502
  };
4178
- var WAIT_PROP = { waitSeconds: { type: "number", description: `How long to wait for the result before returning "still running" (default ${DEFAULT_WAIT}, at most ${MAX_WAIT}).` } };
4179
- var RUN_ID2 = { type: "string", description: 'A run id as printed by a run, or "last".' };
4503
+ var WAIT_PROP = { waitSeconds: { type: "number", description: `Wait this long before returning "still running" (default ${DEFAULT_WAIT}, max ${MAX_WAIT}).` } };
4504
+ var RUN_ID2 = { type: "string", description: 'Run id, or "last".' };
4505
+ var READ_ONLY = { readOnlyHint: true, openWorldHint: false };
4506
+ var TOPICS = {
4507
+ runs: ["running", "group"],
4508
+ history: ["text", "state", "model", "since", "limit"],
4509
+ stats: ["since"],
4510
+ savings: ["since", "perModel"],
4511
+ models: ["worker", "all"],
4512
+ cooldown: [],
4513
+ config: [],
4514
+ doctor: []
4515
+ };
4516
+ function infoArgs(topic, a) {
4517
+ const allowed = TOPICS[topic];
4518
+ const stray = Object.keys(a).filter((k) => k !== "topic" && a[k] !== void 0 && a[k] !== null && !allowed.includes(k));
4519
+ if (stray.length) throw new ToolError(`${stray.map((k) => `"${k}"`).join(", ")} ${stray.length > 1 ? "do" : "does"} not go with topic "${topic}" (${allowed.length ? `it takes ${allowed.join(", ")}` : "it takes no options"})`);
4520
+ const group = str(a, "group");
4521
+ const worker = str(a, "worker");
4522
+ switch (topic) {
4523
+ case "runs":
4524
+ return { args: group ? ["status", "-g", group] : ["ls", ...bool(a, "running") ? ["--running"] : []] };
4525
+ case "history": {
4526
+ const limit = a.limit;
4527
+ if (limit !== void 0 && (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > 200)) throw new ToolError('"limit" must be a whole number from 1 to 200');
4528
+ const text = str(a, "text");
4529
+ const state = str(a, "state");
4530
+ const model = str(a, "model");
4531
+ return { args: ["history", ...since(a), ...state ? ["--state", state] : [], ...model ? ["--model", model] : [], ...limit ? ["--limit", String(limit)] : [], ...text ? ["--", text] : []] };
4532
+ }
4533
+ case "stats":
4534
+ return { args: ["history", "stats", ...since(a)] };
4535
+ case "savings":
4536
+ return { args: ["savings", ...since(a), ...bool(a, "perModel") ? ["--models"] : []] };
4537
+ case "models":
4538
+ return { args: ["models", ...worker ? [worker] : [], ...bool(a, "all") ? ["--all"] : []] };
4539
+ case "doctor":
4540
+ return { args: ["doctor"], timeoutMs: 12e4 };
4541
+ case "cooldown":
4542
+ case "config":
4543
+ return { args: [topic] };
4544
+ default:
4545
+ throw new Error(`topic "${topic}" has no command`);
4546
+ }
4547
+ }
4180
4548
  var TOOLS2 = [
4181
4549
  {
4182
4550
  name: "pitroom_run",
4183
4551
  title: "Run a worker",
4184
- description: 'Hand a bounded task to a cheaper worker agent. mode "read" (default) is read-only research that returns an answer whose file:line references are verified; "isolate" lets the worker edit a private copy and returns the exact diff (review it, then pitroom_apply or pitroom_discard); "write" edits the working tree in place and is undoable with the CLI (`pitroom revert`). Returns the worker\'s answer and a receipt, or "still running" with the run id.',
4552
+ description: 'Hand a bounded task to a cheaper worker agent. mode "read" (default): read-only research, an answer with verified file:line references. "isolate": the worker edits a private copy and you get the exact diff (then pitroom_review, pitroom_apply or pitroom_discard). "write": edits the working tree (undo: pitroom_revert). Give "tasks" instead of "task" to run independent tasks in parallel (read or isolate). Returns the report(s) and a receipt (a read question asked before on the same code comes back cached), or "still running" for pitroom_wait. The using-pitroom skill (or prompt) says when and how to delegate. Cancelling the call stops the run.',
4185
4553
  inputSchema: {
4186
4554
  type: "object",
4187
4555
  properties: {
4188
- task: { type: "string", description: "What the worker should do, with the context it needs." },
4189
- mode: { type: "string", enum: ["read", "isolate", "write"], description: "read (default), isolate, or write." },
4190
- inPlace: { type: "boolean", description: "Read mode only: read the directory itself instead of a clean snapshot without secret-looking files." },
4191
- audit: { type: "boolean", description: "Read mode only: have another worker re-check the answer afterwards." },
4556
+ task: { type: "string", description: "What to do, with the context the worker needs." },
4557
+ tasks: { type: "array", items: { type: "string" }, minItems: 1, maxItems: MAX_TASKS, description: "Or several independent tasks, one worker each." },
4558
+ mode: { type: "string", enum: ["read", "isolate", "write"] },
4559
+ continue: { type: "string", description: "A finished run to follow up in the same worker session." },
4560
+ inPlace: { type: "boolean", description: "Read: read the directory itself, not a snapshot without secret-looking files." },
4561
+ audit: { type: "boolean", description: "Read: have another worker re-check the answer (each one, with tasks)." },
4562
+ fresh: { type: "boolean", description: "Read: ask a worker even if this question was answered on the same code." },
4192
4563
  ...WORKER_PROPS,
4193
4564
  ...WAIT_PROP
4194
- },
4195
- required: ["task"]
4565
+ }
4196
4566
  },
4197
4567
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
4198
- async call(a) {
4199
- const task = str(a, "task", true);
4568
+ async call(a, ctx) {
4569
+ const task = str(a, "task");
4570
+ const tasks = strs(a, "tasks").filter((t) => t.trim());
4571
+ if (!task && !tasks.length) throw new ToolError('give "task" (or "tasks" for parallel work)');
4572
+ if (task && tasks.length) throw new ToolError('give "task" or "tasks", not both');
4200
4573
  const mode = oneOf(a, "mode", ["read", "isolate", "write"], "read");
4201
4574
  const flags = mode === "isolate" ? ["-i"] : mode === "write" ? ["-w"] : [];
4202
4575
  if (mode === "read") {
4203
4576
  if (bool(a, "inPlace")) flags.push("--in-place");
4204
4577
  if (bool(a, "audit")) flags.push("--audit");
4578
+ if (bool(a, "fresh")) flags.push("--fresh");
4579
+ }
4580
+ const follow = str(a, "continue");
4581
+ if (tasks.length) {
4582
+ if (tasks.length > MAX_TASKS) throw new ToolError(`at most ${MAX_TASKS} tasks at once: start the rest when these are done`);
4583
+ if (mode === "write") throw new ToolError('parallel workers never write in place: use mode "isolate" (each gets its own copy)');
4584
+ if (follow) throw new ToolError('"continue" follows up one run: give "task"');
4585
+ return startCrew([...flags, ...workerFlags(a)], tasks, waitSeconds(a), ctx);
4205
4586
  }
4206
- return startAndWait(["run", ...flags, ...workerFlags(a)], waitSeconds(a), task);
4587
+ if (follow) flags.push("--continue", follow);
4588
+ return startAndWait(["run", ...flags, ...workerFlags(a)], waitSeconds(a), ctx, task);
4207
4589
  }
4208
4590
  },
4209
4591
  {
4210
4592
  name: "pitroom_wait",
4211
4593
  title: "Wait for runs",
4212
- description: 'Wait for runs started earlier (a run that came back "still running") and return their reports.',
4213
- inputSchema: {
4214
- type: "object",
4215
- properties: { runs: { type: "array", items: { type: "string" }, description: "Run ids." }, group: { type: "string", description: "Or a group name." }, ...WAIT_PROP }
4216
- },
4217
- annotations: { readOnlyHint: true, openWorldHint: false },
4218
- async call(a) {
4594
+ description: 'Wait for runs that came back "still running" and return their reports.',
4595
+ inputSchema: { type: "object", properties: { runs: { type: "array", items: { type: "string" } }, group: { type: "string" }, ...WAIT_PROP } },
4596
+ annotations: READ_ONLY,
4597
+ async call(a, ctx) {
4219
4598
  const runs = strs(a, "runs");
4220
4599
  const group = str(a, "group");
4221
4600
  if (!runs.length && !group) throw new ToolError('give "runs" or "group"');
4222
- return waitFor(runs, waitSeconds(a), runs.length ? void 0 : group);
4601
+ return waitFor(runs, waitSeconds(a), ctx, runs.length ? void 0 : group);
4223
4602
  }
4224
4603
  },
4225
4604
  {
4226
- name: "pitroom_status",
4227
- title: "Run status",
4228
- description: "The state and live progress of a run (default: the latest), or of a group.",
4229
- inputSchema: { type: "object", properties: { run: RUN_ID2, group: { type: "string", description: "A group name." } } },
4230
- annotations: { readOnlyHint: true, openWorldHint: false },
4231
- async call(a) {
4232
- const group = str(a, "group");
4233
- const run2 = str(a, "run");
4234
- return asResult(await pit(["status", ...group ? ["-g", group] : run2 ? [run2] : []], 6e4));
4605
+ name: "pitroom_show",
4606
+ title: "Show a run",
4607
+ description: "A run's report (live progress while it runs); patch: the exact diff; full: the untruncated answer.",
4608
+ inputSchema: { type: "object", properties: { run: RUN_ID2, patch: { type: "boolean" }, full: { type: "boolean" } } },
4609
+ annotations: READ_ONLY,
4610
+ async call(a, ctx) {
4611
+ const run2 = str(a, "run") ?? "last";
4612
+ return plain(["show", run2, ...bool(a, "patch") ? ["--patch"] : bool(a, "full") ? ["--full"] : []], ctx);
4235
4613
  }
4236
4614
  },
4237
4615
  {
4238
- name: "pitroom_show",
4239
- title: "Show a run",
4240
- description: "A finished run's report again; with patch the exact diff of an isolated change, with full the untruncated answer.",
4241
- inputSchema: { type: "object", properties: { run: RUN_ID2, patch: { type: "boolean" }, full: { type: "boolean" } }, required: ["run"] },
4242
- annotations: { readOnlyHint: true, openWorldHint: false },
4243
- async call(a) {
4244
- const run2 = str(a, "run", true);
4245
- return asResult(await pit(["show", run2, ...bool(a, "patch") ? ["--patch"] : bool(a, "full") ? ["--full"] : []], 6e4));
4616
+ name: "pitroom_info",
4617
+ title: "Pitroom information",
4618
+ description: "Reports, changing nothing. topic: runs (latest runs; running, group), history (search earlier answers before asking again; text, state, model, since, limit), stats (success rate, time, tokens, audits per worker; since), savings (since, perModel), models (a backend's models and costs; worker, all), cooldown (rate-limited models being skipped), config, doctor (setup check).",
4619
+ inputSchema: {
4620
+ type: "object",
4621
+ properties: {
4622
+ topic: { type: "string", enum: Object.keys(TOPICS) },
4623
+ text: { type: "string" },
4624
+ state: { type: "string" },
4625
+ model: { type: "string" },
4626
+ since: { type: "string", description: "7d, 30d, \u2026 or all." },
4627
+ limit: { type: "number" },
4628
+ running: { type: "boolean" },
4629
+ group: { type: "string" },
4630
+ worker: { type: "string" },
4631
+ all: { type: "boolean" },
4632
+ perModel: { type: "boolean" }
4633
+ },
4634
+ required: ["topic"]
4635
+ },
4636
+ annotations: READ_ONLY,
4637
+ async call(a, ctx) {
4638
+ if (str(a, "topic") === void 0) throw new ToolError(`"topic" is required: one of ${Object.keys(TOPICS).join(", ")}`);
4639
+ const topic = oneOf(a, "topic", Object.keys(TOPICS), "runs");
4640
+ const { args, timeoutMs } = infoArgs(topic, a);
4641
+ return plain(args, ctx, timeoutMs);
4246
4642
  }
4247
4643
  },
4248
4644
  {
4249
4645
  name: "pitroom_review",
4250
4646
  title: "Review a change",
4251
- description: `A read-only reviewer (by default another worker than the implementer) judges one run's change, or a range of commits ("main..HEAD"). Returns findings with severities and a SPEC / QUALITY verdict.`,
4647
+ description: `A read-only reviewer (another worker by default) judges a run's change or a commit range ("main..HEAD"): findings with severities and a SPEC / QUALITY verdict.`,
4252
4648
  inputSchema: {
4253
4649
  type: "object",
4254
4650
  properties: {
4255
- run: { ...RUN_ID2, description: "The run whose change to review (an isolated or in-place change)." },
4651
+ run: RUN_ID2,
4256
4652
  range: { type: "string", description: 'Or a commit range "A..B".' },
4257
- plan: { type: "string", description: "With range: the plan file the commits implement." },
4653
+ plan: { type: "string", description: "With range: the plan file it implements." },
4258
4654
  worker: WORKER_PROPS.worker,
4259
4655
  tier: WORKER_PROPS.tier,
4260
4656
  dir: WORKER_PROPS.dir,
@@ -4263,95 +4659,211 @@ var TOOLS2 = [
4263
4659
  }
4264
4660
  },
4265
4661
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
4266
- async call(a) {
4662
+ async call(a, ctx) {
4267
4663
  const run2 = str(a, "run");
4268
4664
  const range = str(a, "range");
4269
4665
  if (!run2 === !range) throw new ToolError('give exactly one of "run" or "range"');
4270
4666
  const plan = str(a, "plan");
4271
4667
  const flags = workerFlags(a, ["worker", "tier", "dir", "group"]);
4272
- return startAndWait(["review", ...range ? ["--range", range, ...plan ? ["--plan", plan] : []] : [run2], ...flags], waitSeconds(a));
4668
+ return startAndWait(["review", ...range ? ["--range", range, ...plan ? ["--plan", plan] : []] : [run2], ...flags], waitSeconds(a), ctx);
4273
4669
  }
4274
4670
  },
4275
4671
  {
4276
4672
  name: "pitroom_audit",
4277
4673
  title: "Audit an answer",
4278
- description: "Another worker re-checks a finished read run's answer against the project and says AGREE, PARTIAL or DISAGREE with the claims it disputes.",
4674
+ description: "Another worker re-checks a read run's answer: AGREE, PARTIAL or DISAGREE, with the disputed claims.",
4279
4675
  inputSchema: { type: "object", properties: { run: RUN_ID2, worker: WORKER_PROPS.worker, ...WAIT_PROP }, required: ["run"] },
4280
4676
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
4281
- async call(a) {
4677
+ async call(a, ctx) {
4282
4678
  const run2 = str(a, "run", true);
4283
4679
  const worker = str(a, "worker");
4284
- return startAndWait(["audit", run2, ...worker ? ["-W", worker] : []], waitSeconds(a));
4680
+ return startAndWait(["audit", run2, ...worker ? ["-W", worker] : []], waitSeconds(a), ctx);
4285
4681
  }
4286
4682
  },
4287
4683
  {
4288
4684
  name: "pitroom_apply",
4289
4685
  title: "Apply an isolated change",
4290
- description: "Land an isolated run's patch on the user's working tree (checked first; refused if it no longer applies cleanly). A patch that deletes files is refused unless allowDelete is true: only pass it after checking the deletions are what the user asked for.",
4686
+ description: "Land an isolated run's patch (or a group's, in order) on the working tree; checked first. A patch deleting files needs allowDelete: pass it only after checking the deletions were asked for.",
4291
4687
  inputSchema: { type: "object", properties: { run: RUN_ID2, group: { type: "string" }, allowDelete: { type: "boolean" } } },
4292
4688
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
4293
- async call(a) {
4689
+ async call(a, ctx) {
4294
4690
  const group = str(a, "group");
4295
4691
  const run2 = str(a, "run");
4296
4692
  if (!run2 && !group) throw new ToolError('give "run" or "group"');
4297
- return asResult(await pit(["apply", ...group && !run2 ? ["-g", group] : [run2], ...bool(a, "allowDelete") ? ["--allow-delete"] : []], 12e4));
4693
+ if (run2 && group) throw new ToolError('give "run" or "group", not both');
4694
+ return plain(["apply", ...group ? ["-g", group] : [run2], ...bool(a, "allowDelete") ? ["--allow-delete"] : []], ctx, 12e4);
4298
4695
  }
4299
4696
  },
4300
4697
  {
4301
4698
  name: "pitroom_discard",
4302
4699
  title: "Discard an isolated change",
4303
- description: "Drop an isolated run's private copy (its patch is kept).",
4700
+ description: "Drop an isolated run's private copy (the patch is kept).",
4304
4701
  inputSchema: { type: "object", properties: { run: RUN_ID2 }, required: ["run"] },
4305
4702
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
4306
- async call(a) {
4307
- return asResult(await pit(["discard", str(a, "run", true)], 6e4));
4703
+ async call(a, ctx) {
4704
+ return plain(["discard", str(a, "run", true)], ctx);
4705
+ }
4706
+ },
4707
+ {
4708
+ name: "pitroom_revert",
4709
+ title: "Undo an in-place change",
4710
+ description: `Undo a mode "write" run's changes (refused if the files changed since).`,
4711
+ inputSchema: { type: "object", properties: { run: RUN_ID2 }, required: ["run"] },
4712
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
4713
+ async call(a, ctx) {
4714
+ return plain(["revert", str(a, "run", true)], ctx, 12e4);
4308
4715
  }
4309
4716
  },
4310
4717
  {
4311
4718
  name: "pitroom_stop",
4312
4719
  title: "Stop runs",
4313
- description: "Stop a running or queued run, or every run of a group.",
4314
- inputSchema: { type: "object", properties: { run: RUN_ID2, group: { type: "string" } } },
4720
+ description: "Stop a running or queued run, or a group; cooldowns: try rate-limited models again now.",
4721
+ inputSchema: { type: "object", properties: { run: RUN_ID2, group: { type: "string" }, cooldowns: { type: "boolean" } } },
4315
4722
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
4316
- async call(a) {
4723
+ async call(a, ctx) {
4317
4724
  const run2 = str(a, "run");
4318
4725
  const group = str(a, "group");
4319
- if (!run2 && !group) throw new ToolError('give "run" or "group"');
4320
- return asResult(await pit(["stop", ...run2 ? [run2] : ["-g", group]], 6e4));
4726
+ if (bool(a, "cooldowns")) {
4727
+ if (run2 || group) throw new ToolError('"cooldowns" goes alone');
4728
+ return plain(["cooldown", "--clear"], ctx);
4729
+ }
4730
+ if (!run2 && !group) throw new ToolError('give "run" or "group" (or "cooldowns": true)');
4731
+ if (run2 && group) throw new ToolError('give "run" or "group", not both');
4732
+ return plain(["stop", ...run2 ? [run2] : ["-g", group]], ctx);
4321
4733
  }
4322
4734
  }
4323
4735
  ];
4324
- var log = (msg) => void process.stderr.write(`pitroom mcp: ${msg}
4325
- `);
4326
- var send = (msg) => void process.stdout.write(`${JSON.stringify(msg)}
4327
- `);
4328
- var RpcError = class extends Error {
4329
- constructor(code, message) {
4330
- super(message);
4331
- this.code = code;
4736
+
4737
+ // src/cli/mcp-watch.ts
4738
+ var EVERY_MS = 2e3;
4739
+ var MAX_SUBSCRIPTIONS = 100;
4740
+ var watched = /* @__PURE__ */ new Set();
4741
+ var timer;
4742
+ var signature = (uri) => {
4743
+ const id = runOfUri(uri);
4744
+ if (!id) return void 0;
4745
+ try {
4746
+ const m = freshMeta(id);
4747
+ return uri.endsWith("/patch") ? `${m.state}:${m.changes?.length ?? 0}` : m.state;
4748
+ } catch {
4749
+ return void 0;
4332
4750
  }
4333
4751
  };
4334
- async function dispatch(method, params) {
4335
- switch (method) {
4336
- case "initialize": {
4337
- const asked = typeof params.protocolVersion === "string" ? params.protocolVersion : "";
4338
- return {
4339
- protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0],
4340
- capabilities: { tools: { listChanged: false } },
4341
- serverInfo: { name: "pitroom", title: "Pitroom", version: VERSION2 },
4342
- instructions: INSTRUCTIONS
4343
- };
4752
+ var newestRun = () => {
4753
+ try {
4754
+ return listRunIds().at(-1);
4755
+ } catch {
4756
+ return void 0;
4757
+ }
4758
+ };
4759
+ function subscribe(session, uri) {
4760
+ if (typeof uri !== "string") throw new RpcError(-32602, '"uri" is required');
4761
+ if (!runOfUri(uri)) throw new RpcError(-32602, `only runs can be subscribed to (pitroom://run/<id>): ${uri}`);
4762
+ if (!session.subscriptions.has(uri) && session.subscriptions.size >= MAX_SUBSCRIPTIONS) throw new RpcError(-32602, `at most ${MAX_SUBSCRIPTIONS} subscriptions: unsubscribe from finished runs first`);
4763
+ const sig = signature(uri);
4764
+ if (sig === void 0) throw new RpcError(-32002, `unknown resource: ${uri}`);
4765
+ session.subscriptions.set(uri, sig);
4766
+ }
4767
+ function unsubscribe(session, uri) {
4768
+ if (typeof uri !== "string") throw new RpcError(-32602, '"uri" is required');
4769
+ session.subscriptions.delete(uri);
4770
+ }
4771
+ function tick() {
4772
+ const listening = [...watched].filter((s) => s.push);
4773
+ if (!listening.length) return;
4774
+ const newest = newestRun();
4775
+ const seen = /* @__PURE__ */ new Map();
4776
+ for (const s of listening) {
4777
+ const push = s.push;
4778
+ if (newest !== s.newestRun) {
4779
+ s.newestRun = newest;
4780
+ push({ jsonrpc: "2.0", method: "notifications/resources/list_changed" });
4344
4781
  }
4345
- case "ping":
4346
- return {};
4347
- case "tools/list":
4348
- return { tools: TOOLS2.map(({ name, title, description, inputSchema, annotations }) => ({ name, title, description, inputSchema, annotations })) };
4349
- case "tools/call": {
4350
- const tool = TOOLS2.find((t) => t.name === params.name);
4351
- if (!tool) throw new RpcError(-32602, `unknown tool: ${String(params.name)}`);
4352
- const args = params.arguments && typeof params.arguments === "object" && !Array.isArray(params.arguments) ? params.arguments : {};
4782
+ for (const [uri, last] of s.subscriptions) {
4783
+ if (!seen.has(uri)) seen.set(uri, signature(uri));
4784
+ const now = seen.get(uri);
4785
+ if (now === last) continue;
4786
+ if (now === void 0) s.subscriptions.delete(uri);
4787
+ else s.subscriptions.set(uri, now);
4788
+ push({ jsonrpc: "2.0", method: "notifications/resources/updated", params: { uri } });
4789
+ }
4790
+ }
4791
+ }
4792
+ function watch2(session) {
4793
+ session.newestRun = newestRun();
4794
+ watched.add(session);
4795
+ if (!timer) {
4796
+ timer = setInterval(tick, EVERY_MS);
4797
+ timer.unref();
4798
+ }
4799
+ }
4800
+ function unwatch(session) {
4801
+ watched.delete(session);
4802
+ session.subscriptions.clear();
4803
+ if (!watched.size && timer) {
4804
+ clearInterval(timer);
4805
+ timer = void 0;
4806
+ }
4807
+ }
4808
+
4809
+ // src/cli/mcp.ts
4810
+ var PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"];
4811
+ var INSTRUCTIONS = [
4812
+ "Pitroom hands bounded work to cheaper worker agents and returns a verified answer, the exact diff and a cost receipt. You decide, verify and answer.",
4813
+ 'Use pitroom_run with mode "read" for research and locating code, mode "isolate" for code changes (the worker edits a copy; check it with pitroom_review, then pitroom_apply or pitroom_discard); pitroom_run with "tasks" for independent tasks in parallel; pitroom_info (topic history) finds an earlier answer before you ask again.',
4814
+ 'A run can take minutes: pitroom_run waits up to waitSeconds, then returns the run id as "still running"; call pitroom_wait with it.',
4815
+ "Pitroom's skills (using-pitroom, pitroom-research, pitroom-implement, pitroom-review, pitroom-crew, pitroom-debugging, pitroom-tdd and more) say when to delegate and how to check what comes back: if you have them, use the matching one alongside these tools; if not, the prompts of the same names hold them.",
4816
+ "It is optional: for a small task you can do yourself, skip it."
4817
+ ].join(" ");
4818
+ var log = (msg) => void process.stderr.write(`pitroom mcp: ${msg}
4819
+ `);
4820
+ function newSession() {
4821
+ const session = { inflight: /* @__PURE__ */ new Map(), subscriptions: /* @__PURE__ */ new Map() };
4822
+ watch2(session);
4823
+ return session;
4824
+ }
4825
+ function endSession(session) {
4826
+ for (const { abort } of session.inflight.values()) abort.abort("closed");
4827
+ unwatch(session);
4828
+ session.push = void 0;
4829
+ }
4830
+ function progressToken(params) {
4831
+ const meta = params && typeof params === "object" ? params._meta : void 0;
4832
+ const token = meta && typeof meta === "object" ? meta.progressToken : void 0;
4833
+ return typeof token === "string" || typeof token === "number" ? token : void 0;
4834
+ }
4835
+ function contextFor(params, abort, notify) {
4836
+ const token = progressToken(params);
4837
+ let n = 0;
4838
+ return {
4839
+ signal: abort.signal,
4840
+ progress: (message) => {
4841
+ if (token === void 0 || abort.signal.aborted) return;
4842
+ notify({ jsonrpc: "2.0", method: "notifications/progress", params: { progressToken: token, progress: ++n, message } });
4843
+ }
4844
+ };
4845
+ }
4846
+ async function dispatch(method, params, ctx, session) {
4847
+ switch (method) {
4848
+ case "initialize": {
4849
+ const asked = typeof params.protocolVersion === "string" ? params.protocolVersion : "";
4850
+ return {
4851
+ protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0],
4852
+ capabilities: { tools: { listChanged: false }, resources: { listChanged: true, subscribe: true }, prompts: { listChanged: false } },
4853
+ serverInfo: { name: "pitroom", title: "Pitroom", version: VERSION2 },
4854
+ instructions: INSTRUCTIONS
4855
+ };
4856
+ }
4857
+ case "ping":
4858
+ return {};
4859
+ case "tools/list":
4860
+ return { tools: TOOLS2.map(({ name, title, description, inputSchema, annotations }) => ({ name, title, description, inputSchema, annotations })) };
4861
+ case "tools/call": {
4862
+ const tool = TOOLS2.find((t) => t.name === params.name);
4863
+ if (!tool) throw new RpcError(-32602, `unknown tool: ${String(params.name)}`);
4864
+ const args = params.arguments && typeof params.arguments === "object" && !Array.isArray(params.arguments) ? params.arguments : {};
4353
4865
  try {
4354
- const r = await tool.call(args);
4866
+ const r = await tool.call(args, ctx);
4355
4867
  return { content: [{ type: "text", text: r.text }], isError: r.isError === true };
4356
4868
  } catch (e) {
4357
4869
  if (e instanceof ToolError) return { content: [{ type: "text", text: e.message }], isError: true };
@@ -4359,1194 +4871,1427 @@ async function dispatch(method, params) {
4359
4871
  return { content: [{ type: "text", text: `pitroom: ${e.message}` }], isError: true };
4360
4872
  }
4361
4873
  }
4874
+ case "resources/list":
4875
+ return listResources();
4876
+ case "resources/templates/list":
4877
+ return resourceTemplates();
4878
+ case "resources/read":
4879
+ if (typeof params.uri !== "string") throw new RpcError(-32602, '"uri" is required');
4880
+ return readResource(params.uri, ctx);
4881
+ case "resources/subscribe":
4882
+ subscribe(session, params.uri);
4883
+ return {};
4884
+ case "resources/unsubscribe":
4885
+ unsubscribe(session, params.uri);
4886
+ return {};
4887
+ case "prompts/list":
4888
+ return listPrompts();
4889
+ case "prompts/get":
4890
+ return getPrompt(params.name, params.arguments);
4362
4891
  default:
4363
4892
  throw new RpcError(-32601, `method not found: ${method}`);
4364
4893
  }
4365
4894
  }
4366
- async function handle(msg) {
4895
+ function cancel(session, params) {
4896
+ const id = params.requestId;
4897
+ if (typeof id !== "string" && typeof id !== "number") return;
4898
+ session.inflight.get(id)?.abort.abort("cancelled");
4899
+ }
4900
+ async function handle(session, msg, notify) {
4367
4901
  if (!msg || typeof msg !== "object" || Array.isArray(msg)) return { jsonrpc: "2.0", id: null, error: { code: -32600, message: "invalid request" } };
4368
4902
  const m = msg;
4369
4903
  if (typeof m.method !== "string") return void 0;
4370
- const isRequest = m.id !== void 0 && m.id !== null;
4904
+ const params = m.params && typeof m.params === "object" && !Array.isArray(m.params) ? m.params : {};
4905
+ if (m.method === "notifications/cancelled") {
4906
+ cancel(session, params);
4907
+ return void 0;
4908
+ }
4909
+ const isRequest = typeof m.id === "string" || typeof m.id === "number";
4910
+ const abort = new AbortController();
4911
+ const entry = { abort, ctx: contextFor(params, abort, notify) };
4912
+ if (isRequest) session.inflight.set(m.id, entry);
4371
4913
  try {
4372
- const result = await dispatch(m.method, m.params && typeof m.params === "object" ? m.params : {});
4914
+ const result = await dispatch(m.method, params, entry.ctx, session);
4915
+ if (abort.signal.aborted && abort.signal.reason === "cancelled") return void 0;
4373
4916
  return isRequest ? { jsonrpc: "2.0", id: m.id, result } : void 0;
4374
4917
  } catch (e) {
4375
- if (!isRequest) return void 0;
4918
+ if (!isRequest || abort.signal.aborted && abort.signal.reason === "cancelled") return void 0;
4376
4919
  const code = e instanceof RpcError ? e.code : -32603;
4377
4920
  return { jsonrpc: "2.0", id: m.id, error: { code, message: e.message } };
4921
+ } finally {
4922
+ if (isRequest && session.inflight.get(m.id) === entry) session.inflight.delete(m.id);
4378
4923
  }
4379
4924
  }
4380
- async function handleLine(line) {
4381
- let parsed;
4382
- try {
4383
- parsed = JSON.parse(line);
4384
- } catch {
4385
- send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
4386
- return;
4387
- }
4925
+ async function handlePayload(session, parsed, notify) {
4388
4926
  if (Array.isArray(parsed)) {
4389
- const replies = (await Promise.all(parsed.map(handle))).filter((r) => !!r);
4390
- if (replies.length) send(replies);
4391
- return;
4927
+ const replies = (await Promise.all(parsed.map((m) => handle(session, m, notify)))).filter((r) => !!r);
4928
+ return replies.length ? replies : void 0;
4392
4929
  }
4393
- const reply = await handle(parsed);
4394
- if (reply) send(reply);
4930
+ return handle(session, parsed, notify);
4395
4931
  }
4932
+ var parseError = () => ({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
4396
4933
  async function serveMcp() {
4934
+ process.stdout.on("error", () => {
4935
+ });
4936
+ const send = (msg) => void process.stdout.write(`${JSON.stringify(msg)}
4937
+ `);
4938
+ const session = newSession();
4939
+ session.push = send;
4397
4940
  const rl = readline.createInterface({ input: process.stdin });
4398
- const inflight = /* @__PURE__ */ new Set();
4941
+ const pending = /* @__PURE__ */ new Set();
4942
+ const handleLine = async (line) => {
4943
+ let parsed;
4944
+ try {
4945
+ parsed = JSON.parse(line);
4946
+ } catch {
4947
+ send(parseError());
4948
+ return;
4949
+ }
4950
+ const reply2 = await handlePayload(session, parsed, send);
4951
+ if (reply2) send(reply2);
4952
+ };
4399
4953
  rl.on("line", (line) => {
4400
4954
  if (!line.trim()) return;
4401
4955
  const p = handleLine(line).catch((e) => log(String(e.stack ?? e)));
4402
- inflight.add(p);
4403
- void p.finally(() => inflight.delete(p));
4956
+ pending.add(p);
4957
+ void p.finally(() => pending.delete(p));
4404
4958
  });
4405
4959
  await new Promise((resolve2) => rl.once("close", resolve2));
4406
- await Promise.allSettled([...inflight]);
4960
+ unwatch(session);
4961
+ await Promise.allSettled([...pending]);
4407
4962
  return 0;
4408
4963
  }
4409
4964
 
4410
- // src/cli/commands.ts
4411
- import { spawnSync as spawnSync8 } from "node:child_process";
4412
- import fs30 from "node:fs";
4413
- import path26 from "node:path";
4414
- import { fileURLToPath as fileURLToPath4 } from "node:url";
4965
+ // src/cli/mcp-http.ts
4966
+ import crypto7 from "node:crypto";
4967
+ import fs27 from "node:fs";
4968
+ import http2 from "node:http";
4969
+ import path23 from "node:path";
4415
4970
 
4416
- // src/core/init.ts
4417
- import fs25 from "node:fs";
4418
- import path21 from "node:path";
4419
- var FREE = /-free$/;
4420
- var TIER_ORDER = [["cheap", "opencode"], ["standard", "codex"], ["capable", "claude"]];
4421
- var isFound = (binary5) => path21.isAbsolute(binary5) && fs25.existsSync(binary5);
4422
- function planInit(opts = {}) {
4423
- const file2 = configPath();
4424
- const workers = backendIds().map((id) => {
4425
- const b = getBackend(id);
4426
- const binary5 = b.binary();
4427
- return { id, name: b.name, found: isFound(binary5), binary: binary5 };
4428
- });
4429
- const notes = [];
4430
- const config = {};
4431
- let blocked;
4432
- let opencode2;
4433
- if (workers.find((w) => w.id === "opencode")?.found) {
4434
- const b = getBackend("opencode");
4435
- let models = [];
4436
- try {
4437
- models = b.catalog ? b.catalog().models.map((m) => m.id) : b.listModels?.() ?? [];
4438
- } catch {
4439
- notes.push("could not list OpenCode models (is it logged in?)");
4440
- }
4441
- const defaultModel4 = b.defaultModel?.();
4442
- const chosen = opts.model ?? defaultModel4;
4443
- const free = models.filter((m) => FREE.test(m) && m !== chosen);
4444
- opencode2 = { defaultModel: defaultModel4, models: models.length, free: free.slice(0, 6) };
4445
- if (opts.model) {
4446
- if (models.length && !models.includes(opts.model)) throw new UserError(`"${opts.model}" is not in \`opencode models\``);
4447
- if (!models.length) notes.push(`could not check "${opts.model}": \`opencode models\` listed nothing`);
4448
- config.models = { opencode: opts.model };
4449
- } else if (!defaultModel4) {
4450
- blocked = `OpenCode has no default model: choose one with --model <id>${free.length ? ` (free models you have: ${free.slice(0, 4).join(", ")})` : ""}`;
4451
- }
4452
- const fallback = opts.fallback ?? free.slice(0, 2);
4453
- for (const m of fallback) {
4454
- if (models.length && !models.includes(m)) throw new UserError(`"${m}" is not in \`opencode models\``);
4455
- }
4456
- if (fallback.length) {
4457
- config.fallback = fallback.map((m) => `opencode:${m}`);
4458
- if (!opts.fallback) notes.push(`fallback suggested from the free models in your catalogue: ${fallback.join(", ")} (change it with --fallback a,b)`);
4459
- } else {
4460
- notes.push("no fallback: no free OpenCode model found to suggest (pass --fallback a,b to name some)");
4971
+ // src/core/dash.ts
4972
+ import { spawn as spawn4 } from "node:child_process";
4973
+ import crypto6 from "node:crypto";
4974
+ import fs26 from "node:fs";
4975
+ import http from "node:http";
4976
+ import path22 from "node:path";
4977
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
4978
+
4979
+ // src/core/file-diff.ts
4980
+ var PREVIEW_LINES = 300;
4981
+ function gitPath(value, prefix = true) {
4982
+ let path31 = value;
4983
+ if (value.startsWith('"') && value.endsWith('"')) {
4984
+ const bytes = [];
4985
+ const escapes = { t: " ", n: "\n", r: "\r", b: "\b", f: "\f", v: "\v" };
4986
+ for (const match of value.slice(1, -1).matchAll(/\\([0-7]{1,3}|.)|([^\\]+)/g)) {
4987
+ if (match[1] && /^[0-7]+$/.test(match[1])) bytes.push(parseInt(match[1], 8));
4988
+ else bytes.push(...new TextEncoder().encode(match[2] ?? escapes[match[1]] ?? match[1]));
4461
4989
  }
4990
+ path31 = new TextDecoder().decode(new Uint8Array(bytes));
4462
4991
  }
4463
- const tiers = Object.fromEntries(TIER_ORDER.filter(([, id]) => workers.find((w) => w.id === id)?.found));
4464
- if (Object.keys(tiers).length > 1) config.tiers = tiers;
4465
- if (!workers.some((w) => w.found)) blocked = "no worker CLI found: install OpenCode (https://opencode.ai), Codex CLI, Claude Code or Gemini CLI first";
4466
- else if (!blocked && !Object.keys(config).length) notes.push("nothing to propose: the default model and the single worker need no config");
4467
- return { path: file2, exists: fs25.existsSync(file2), workers, opencode: opencode2, config, blocked, notes };
4468
- }
4469
- function writeInit(plan, force) {
4470
- if (plan.blocked) throw new UserError(plan.blocked);
4471
- if (!Object.keys(plan.config).length) throw new UserError("nothing to write: the proposal is empty");
4472
- if (plan.exists && !force) throw new UserError(`${plan.path} already exists: pass --force to replace it (the old file is kept as ${path21.basename(plan.path)}.bak)`);
4473
- fs25.mkdirSync(path21.dirname(plan.path), { recursive: true });
4474
- if (plan.exists) fs25.copyFileSync(plan.path, `${plan.path}.bak`);
4475
- fs25.writeFileSync(plan.path, `${JSON.stringify(plan.config, null, 2)}
4476
- `);
4477
- return plan.path;
4992
+ return prefix ? path31.replace(/^[ab]\//, "") : path31;
4478
4993
  }
4479
- function formatInit(plan, written) {
4480
- const out = [bold("pitroom init") + dim(": worker CLIs on this machine")];
4481
- 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")}`);
4482
- if (plan.opencode) {
4483
- const o = plan.opencode;
4484
- 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" : ""}` : ""}`);
4485
- }
4486
- out.push("", `config file: ${plan.path}${plan.exists ? " (exists)" : " (not present)"}`, JSON.stringify(plan.config, null, 2));
4487
- for (const n of plan.notes) out.push(` note: ${n}`);
4488
- if (written) out.push("", green(`\u2714 written to ${written}`), dim(" next: pitroom doctor, then pitroom install"));
4489
- else if (plan.blocked) out.push("", yellow(`! ${plan.blocked}`));
4490
- else out.push("", `nothing written yet: pass --yes to write it${plan.exists ? " (with --force, since the file exists)" : ""}`);
4491
- return out.join("\n");
4994
+ function headerPath(header) {
4995
+ const quoted = header.match(/^diff --git ("(?:[^"\\]|\\.)*"|\S+) ("(?:[^"\\]|\\.)*"|\S+)$/);
4996
+ if (quoted) return gitPath(quoted[2]);
4997
+ return gitPath(header.slice(header.lastIndexOf(" b/") + 1));
4492
4998
  }
4493
-
4494
- // src/core/group.ts
4495
- function groupIds(group) {
4496
- return listRunIds().filter((id) => {
4497
- try {
4498
- return readMeta(id).group === group;
4499
- } catch {
4500
- return false;
4999
+ function fileDiffs(patch, changes, sourceTruncated = false) {
5000
+ const files = [];
5001
+ let file2;
5002
+ let oldLine = 0;
5003
+ let newLine = 0;
5004
+ let oldLeft = 0;
5005
+ let newLeft = 0;
5006
+ let inHunk = false;
5007
+ let row = 0;
5008
+ const finishHunk = () => {
5009
+ if (file2 && (oldLeft > 0 || newLeft > 0)) file2.incomplete = true;
5010
+ oldLeft = newLeft = 0;
5011
+ inHunk = false;
5012
+ };
5013
+ const add = (line) => {
5014
+ if (!file2) return;
5015
+ if (file2.lines.length < PREVIEW_LINES) file2.lines.push({ ...line, id: String(row) });
5016
+ else file2.omittedLines++;
5017
+ row++;
5018
+ };
5019
+ for (const line of patch.split("\n")) {
5020
+ if (line.startsWith("diff --git ")) {
5021
+ finishHunk();
5022
+ file2 = { path: headerPath(line), status: "M", lines: [], additions: 0, deletions: 0, omittedLines: 0, binary: false, incomplete: false };
5023
+ files.push(file2);
5024
+ row = 0;
5025
+ continue;
5026
+ }
5027
+ if (!file2) continue;
5028
+ const hunk = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
5029
+ if (hunk) {
5030
+ finishHunk();
5031
+ oldLine = Number(hunk[1]);
5032
+ newLine = Number(hunk[3]);
5033
+ oldLeft = Number(hunk[2] ?? 1);
5034
+ newLeft = Number(hunk[4] ?? 1);
5035
+ inHunk = true;
5036
+ add({ type: "hunk", content: line });
5037
+ } else if (inHunk && line.startsWith("\")) {
5038
+ add({ type: "meta", content: line });
5039
+ } else if (inHunk && (oldLeft > 0 || newLeft > 0)) {
5040
+ if (line.startsWith("+") && newLeft > 0) {
5041
+ file2.additions++;
5042
+ newLeft--;
5043
+ add({ type: "added", newLine: newLine++, content: line.slice(1) });
5044
+ } else if (line.startsWith("-") && oldLeft > 0) {
5045
+ file2.deletions++;
5046
+ oldLeft--;
5047
+ add({ type: "removed", oldLine: oldLine++, content: line.slice(1) });
5048
+ } else if (line.startsWith(" ") && oldLeft > 0 && newLeft > 0) {
5049
+ oldLeft--;
5050
+ newLeft--;
5051
+ add({ type: "context", oldLine: oldLine++, newLine: newLine++, content: line.slice(1) });
5052
+ } else {
5053
+ file2.incomplete = true;
5054
+ }
5055
+ } else if (!inHunk) {
5056
+ if (line.startsWith("+++ ") && line !== "+++ /dev/null") file2.path = gitPath(line.slice(4).replace(/\t$/, ""));
5057
+ else if (line.startsWith("--- ") && line !== "--- /dev/null") file2.path = gitPath(line.slice(4).replace(/\t$/, ""));
5058
+ else if (/^(GIT binary patch|Binary files .* differ)$/.test(line)) file2.binary = true;
5059
+ else if (/^(new file mode|deleted file mode|old mode|new mode|rename from|rename to|similarity index) /.test(line)) {
5060
+ if (line.startsWith("new file mode ")) file2.status = "A";
5061
+ if (line.startsWith("deleted file mode ")) file2.status = "D";
5062
+ if (line.startsWith("rename to ")) {
5063
+ file2.path = gitPath(line.slice(10), false);
5064
+ file2.status = "R";
5065
+ }
5066
+ add({ type: "meta", content: line });
5067
+ }
4501
5068
  }
5069
+ }
5070
+ finishHunk();
5071
+ if (sourceTruncated && file2) file2.incomplete = true;
5072
+ for (const f of files) {
5073
+ if (f.incomplete || f.binary) f.additions = f.deletions = void 0;
5074
+ }
5075
+ const byPath = new Map(files.map((f) => [f.path, f]));
5076
+ const result = changes.map((change) => {
5077
+ const diff = byPath.get(change.path);
5078
+ byPath.delete(change.path);
5079
+ return diff ? { ...diff, status: change.status } : {
5080
+ ...change,
5081
+ lines: [],
5082
+ omittedLines: 0,
5083
+ binary: false,
5084
+ incomplete: sourceTruncated,
5085
+ unavailable: true
5086
+ };
4502
5087
  });
5088
+ return [...result, ...byPath.values()];
4503
5089
  }
4504
- var oneLine5 = (s, max) => {
4505
- const t = s.replace(/\s+/g, " ").trim();
4506
- return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
4507
- };
4508
- function headline(meta, max = 160) {
4509
- const text = readSummary(meta);
4510
- const line = text.split("\n").find((l) => /^SUMMARY:/i.test(l.trim())) ?? text.split("\n").find((l) => l.trim()) ?? "";
4511
- return oneLine5(line.replace(/^SUMMARY:\s*/i, ""), max);
4512
- }
4513
- function table(metas) {
4514
- if (!metas.length) return "no runs";
4515
- const rows = metas.map((m) => {
4516
- const l = m.state === "running" ? live(m) : void 0;
4517
- const steps = l ? String(l.steps) : m.usage ? String(m.usage.steps) : "-";
4518
- const note = l?.last ? oneLine5(l.last, 48) : isActive(m.state) ? oneLine5(m.task, 48) : headline(m, 48) || oneLine5(m.error ?? m.task, 48);
4519
- return [m.id, m.state, m.mode, duration(m), steps, describeTarget(m.ran ?? m.worker), note];
4520
- });
4521
- const head = ["RUN", "STATE", "MODE", "TIME", "STEPS", "WORKER", "NOW / RESULT"];
4522
- const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
4523
- const fmt = (r, header = false) => r.map((c, i) => {
4524
- const cell2 = i === r.length - 1 ? c : c.padEnd(widths[i]);
4525
- return header ? dim(cell2) : i === 1 ? stateColour(r[1], cell2) : cell2;
4526
- }).join(" ");
4527
- return [fmt(head, true), ...rows.map((r) => fmt(r))].join("\n");
4528
- }
4529
- var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
4530
- async function waitMany(ids, opts) {
4531
- const deadline = Date.now() + opts.timeoutMs;
4532
- for (; ; ) {
4533
- const metas = ids.map((id) => freshMeta(id));
4534
- const done = metas.filter((m) => TERMINAL.includes(m.state)).length;
4535
- if (opts.any ? done > 0 : done === metas.length) return { metas, timedOut: false };
4536
- if (Date.now() >= deadline) return { metas, timedOut: true };
4537
- await sleep2(1500);
4538
- }
4539
- }
4540
- async function watch(select, opts) {
4541
- const seen = /* @__PURE__ */ new Map();
4542
- const line = (s) => opts.write(`${s}
4543
- `);
4544
- const emit = (e, m) => {
4545
- if (!opts.brief) return opts.write(`${JSON.stringify(e)}
4546
- `);
4547
- if (e.event === "started" && m) line(startedCard(m));
4548
- else if (e.event === "fallback" && m) line(`\u{1F3C1} Pitroom \u21BB ${m.id} fell back from ${String(e.failed)}: ${String(e.reason)}`);
4549
- else if (m && TERMINAL.includes(m.state)) line(endedCard(m));
4550
- else if (e.event === "all-done") line(`\u{1F3C1} Pitroom: ${String(e.runs)} run${e.runs === 1 ? "" : "s"} finished (${String(e.ok)} ok${e.failed ? `, ${String(e.failed)} not ok` : ""})`);
4551
- };
4552
- const deadline = opts.timeoutMs > 0 ? Date.now() + opts.timeoutMs : Infinity;
4553
- for (; ; ) {
4554
- const metas = select().map((id) => freshMeta(id));
4555
- if (opts.json || opts.brief) {
4556
- for (const m of metas) {
4557
- const prev = seen.get(m.id);
4558
- const l = m.state === "running" ? live(m) : { steps: m.usage?.steps ?? 0, toolCalls: m.usage?.toolCalls ?? 0 };
4559
- const attempts = m.attempts?.length ?? 0;
4560
- const base2 = { run: m.id, mode: m.mode, task: oneLine5(m.task, 60) };
4561
- if (!prev) emit({ event: m.state === "queued" ? "queued" : "started", ...base2 }, m);
4562
- else if (prev.state === "queued" && m.state === "running") emit({ event: "started", ...base2 }, m);
4563
- if (attempts > (prev?.attempts ?? 0)) {
4564
- const a = m.attempts[attempts - 1];
4565
- emit({ event: "fallback", run: m.id, failed: a.target, reason: oneLine5(a.error, 120) }, m);
4566
- }
4567
- if (m.state === "running" && l.steps > (prev?.steps ?? 0)) {
4568
- emit({ event: "progress", run: m.id, steps: l.steps, tools: l.toolCalls, last: "last" in l && l.last ? oneLine5(String(l.last), 80) : void 0 });
4569
- }
4570
- if (TERMINAL.includes(m.state) && prev?.state !== m.state) {
4571
- emit({
4572
- event: m.state,
4573
- run: m.id,
4574
- time: duration(m),
4575
- worker: describeTarget(m.ran ?? m.worker),
4576
- summary: headline(m) || void 0,
4577
- error: m.error ? oneLine5(m.error, 160) : void 0,
4578
- refs: m.refs ? `${m.refs.valid}/${m.refs.total}` : void 0,
4579
- changes: m.changes?.length || void 0
4580
- }, m);
4581
- }
4582
- seen.set(m.id, { state: m.state, steps: l.steps, attempts });
4583
- }
4584
- } else {
4585
- opts.write(`\x1B[H\x1B[2J${table(metas)}
4586
5090
 
4587
- ${(/* @__PURE__ */ new Date()).toLocaleTimeString()} \xB7 Ctrl-C to stop watching (workers keep running)
4588
- `);
4589
- }
4590
- const allDone = metas.length > 0 && metas.every((m) => TERMINAL.includes(m.state));
4591
- if (allDone || metas.length === 0 && seen.size === 0) {
4592
- if (opts.json || opts.brief) {
4593
- const ok = metas.filter((m) => m.state === "done").length;
4594
- emit({ event: "all-done", runs: metas.length, ok, failed: metas.length - ok });
4595
- }
4596
- return { metas, timedOut: false };
4597
- }
4598
- if (Date.now() >= deadline) return { metas, timedOut: true };
4599
- await sleep2(opts.intervalMs);
4600
- }
4601
- }
5091
+ // src/core/dash-page.ts
5092
+ 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){}})()";
5093
+ 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";
5094
+ var PAGE = `<!doctype html>
5095
+ <html lang="en" class="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
5096
+ <meta name="color-scheme" content="dark light"><title>Pitroom</title><link rel="icon" href="${ICON3}">
5097
+ <script>${THEME_SCRIPT}</script><link rel="stylesheet" href="/assets/app.css"></head>
5098
+ <body><div id="root"></div><script type="module" src="/assets/app.js"></script></body></html>`;
5099
+ var MISSING = `<!doctype html><meta charset="utf-8"><title>Pitroom</title><body style="font:15px system-ui;padding:3rem;max-width:40rem;margin:auto"><h1>Pitroom dash</h1><p>The dashboard files are missing (dist/ui). Run <code>npm run build</code> in the Pitroom repository, or reinstall the package.</p></body>`;
4602
5100
 
4603
- // src/core/mcp-install.ts
4604
- import { spawnSync as spawnSync7 } from "node:child_process";
4605
- import fs26 from "node:fs";
4606
- import os10 from "node:os";
4607
- import path22 from "node:path";
4608
- var CLIENT_IDS = ["claude-code", "codex", "gemini", "cursor", "claude-desktop"];
4609
- var NAME = "pitroom";
4610
- function mcpCommand() {
4611
- const launcher = launcherPath();
4612
- if (isPitroomLauncher(launcher)) return { command: launcher, args: ["mcp"] };
4613
- for (const dir of (process.env.PATH ?? "").split(path22.delimiter).filter(Boolean)) {
4614
- const p = path22.join(dir, process.platform === "win32" ? "pitroom.cmd" : "pitroom");
4615
- if (fs26.existsSync(p)) return { command: path22.resolve(p), args: ["mcp"] };
4616
- }
4617
- return { command: process.execPath, args: [process.argv[1] ?? "pitroom", "mcp"] };
4618
- }
4619
- var same = (a, b) => {
4620
- const o = b;
4621
- return !!o && o.command === a.command && Array.isArray(o.args) && o.args.join("\0") === a.args.join("\0");
5101
+ // src/core/dash.ts
5102
+ var DEFAULT_PORT = 7878;
5103
+ var WEEK_MS2 = 7 * 24 * 3600 * 1e3;
5104
+ var SCAN = 400;
5105
+ var RUN_ID3 = /^\d{8}-\d{6}-[0-9a-f]{4}$/;
5106
+ var LOCAL_HOST = /^(127\.0\.0\.1|localhost|\[::1\]):\d+$/i;
5107
+ var oneLine7 = (s, max) => {
5108
+ const t = s.replace(/\s+/g, " ").trim();
5109
+ return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
4622
5110
  };
4623
- function run(backendId, args) {
4624
- const { command, prefix } = resolveCommand(getBackend(backendId).binary());
4625
- const r = spawnSync7(command, [...prefix, ...args], { encoding: "utf8", timeout: 6e4, stdio: ["ignore", "pipe", "pipe"] });
4626
- return { ok: r.status === 0 && !r.error, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
5111
+ function findings(m) {
5112
+ const v = m.verdict;
5113
+ if (!v) return "";
5114
+ const parts = [v.critical && `${v.critical} critical`, v.important && `${v.important} important`, v.minor && `${v.minor} minor`].filter(Boolean);
5115
+ return parts.length ? parts.join(" \xB7 ") : "no findings";
4627
5116
  }
4628
- var runnable = (backendId) => run(backendId, ["--version"]).ok;
4629
- var readJson = (file2) => {
4630
- try {
4631
- return JSON.parse(fs26.readFileSync(file2, "utf8"));
4632
- } catch {
4633
- return void 0;
4634
- }
4635
- };
4636
- var jsonState = (file2, c) => {
4637
- const entry = readJson(file2)?.mcpServers?.[NAME];
4638
- return entry === void 0 ? "absent" : same(c, entry) ? "same" : "different";
4639
- };
4640
- var home2 = () => os10.homedir();
4641
- var geminiDir = () => path22.join(process.env.GEMINI_CLI_HOME ?? home2(), ".gemini");
4642
- function cliClient(id, backend, name, where, o) {
5117
+ function toRun(m) {
5118
+ const l = m.state === "running" ? live(m) : void 0;
4643
5119
  return {
4644
- id,
4645
- name,
4646
- where,
4647
- found: () => runnable(backend),
4648
- state: o.state,
4649
- add: (c) => {
4650
- const r = run(backend, o.add(c));
4651
- return { ok: r.ok, message: r.ok ? where : r.out.split("\n")[0]?.trim() || `${name}'s command failed` };
4652
- },
4653
- remove: () => {
4654
- const r = run(backend, o.remove());
4655
- return { ok: r.ok, message: r.ok ? where : r.out.split("\n")[0]?.trim() || `${name}'s command failed` };
4656
- }
5120
+ id: m.id,
5121
+ state: m.state,
5122
+ verifyFailed: m.verifyResult && !m.verifyResult.ok ? true : void 0,
5123
+ kind: kind(m),
5124
+ // the model that really ran, once the CLI has said which; until then just the worker, never a guess
5125
+ worker: workerName(ranTarget(m)),
5126
+ task: what(m),
5127
+ group: m.group,
5128
+ startedAt: m.startedAt,
5129
+ time: elapsed(m),
5130
+ steps: l ? l.steps : m.usage?.steps ?? 0,
5131
+ tokens: m.usage?.total,
5132
+ saved: m.savedUsd || void 0,
5133
+ verdict: m.verdict ? `SPEC ${m.verdict.spec.toUpperCase()} \xB7 QUALITY ${m.verdict.quality.toUpperCase()}` : m.auditVerdict ? `AUDIT ${m.auditVerdict.toUpperCase()}` : void 0,
5134
+ audit: auditBadge(m),
5135
+ changes: m.changes?.length || void 0,
5136
+ applied: m.applied || void 0,
5137
+ note: l?.last ? oneLine7(String(l.last), 140) : isActive(m.state) ? "" : m.verifyResult && !m.verifyResult.ok ? `verify failed: ${m.verify ?? ""}` : m.verdict ? findings(m) : m.auditOf && m.state === "done" ? m.auditDisputed?.length ? `${m.auditDisputed.length} disputed` : "nothing disputed" : headline(m, 200) || oneLine7(m.error ?? "", 200)
4657
5138
  };
4658
5139
  }
4659
- function jsonClient(id, name, file2, dir) {
4660
- const rel = () => file2().replace(home2(), "~");
4661
- return {
4662
- id,
4663
- name,
4664
- found: () => fs26.existsSync(dir()),
4665
- state: (c) => jsonState(file2(), c),
4666
- add: (c) => {
4667
- let config = {};
4668
- if (fs26.existsSync(file2())) {
4669
- config = readJson(file2());
4670
- if (!config || typeof config !== "object" || Array.isArray(config)) return { ok: false, message: `${rel()} is not valid JSON: left alone` };
4671
- const backup = `${file2()}.bak-pitroom`;
4672
- if (!fs26.existsSync(backup)) fs26.copyFileSync(file2(), backup);
4673
- }
4674
- config.mcpServers = { ...config.mcpServers ?? {}, [NAME]: { command: c.command, args: c.args } };
4675
- fs26.mkdirSync(path22.dirname(file2()), { recursive: true });
4676
- const tmp = `${file2()}.${process.pid}.tmp`;
4677
- fs26.writeFileSync(tmp, `${JSON.stringify(config, null, 2)}
4678
- `);
4679
- fs26.renameSync(tmp, file2());
4680
- return { ok: true, message: rel() };
4681
- },
4682
- remove: () => {
4683
- const config = readJson(file2());
4684
- if (!config?.mcpServers?.[NAME]) return { ok: true, message: rel() };
4685
- delete config.mcpServers[NAME];
4686
- fs26.writeFileSync(file2(), `${JSON.stringify(config, null, 2)}
4687
- `);
4688
- return { ok: true, message: rel() };
5140
+ function dashState(opts = {}) {
5141
+ const limit = Math.min(Math.max(opts.limit ?? 40, 1), 200);
5142
+ const runs = [];
5143
+ const groups = /* @__PURE__ */ new Set();
5144
+ for (const id of listRunIds().slice(-SCAN).reverse()) {
5145
+ let m;
5146
+ try {
5147
+ m = freshMeta(id);
5148
+ } catch {
5149
+ continue;
4689
5150
  }
5151
+ if (m.group) groups.add(m.group);
5152
+ if (opts.group && m.group !== opts.group) continue;
5153
+ if (runs.length < limit) runs.push(toRun(m));
5154
+ }
5155
+ return {
5156
+ price: primaryPrice().name,
5157
+ running: runs.filter((r) => r.state === "running" || r.state === "queued").length,
5158
+ saved: totals(readLedger(Date.now() - WEEK_MS2)).saved,
5159
+ groups: [...groups].sort(),
5160
+ runs
4690
5161
  };
4691
5162
  }
4692
- function desktopFile() {
4693
- if (process.platform === "darwin") return path22.join(home2(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
4694
- if (process.platform === "win32") return path22.join(process.env.APPDATA ?? path22.join(home2(), "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
4695
- return path22.join(process.env.XDG_CONFIG_HOME ?? path22.join(home2(), ".config"), "Claude", "claude_desktop_config.json");
4696
- }
4697
- function codexState(c) {
4698
- let text;
5163
+ var TASK_MAX = 6e3;
5164
+ var PATCH_LINES = 300;
5165
+ function runDetail(id) {
5166
+ if (!RUN_ID3.test(id)) return void 0;
5167
+ let m;
4699
5168
  try {
4700
- text = fs26.readFileSync(path22.join(process.env.CODEX_HOME ?? path22.join(home2(), ".codex"), "config.toml"), "utf8");
5169
+ m = freshMeta(id);
4701
5170
  } catch {
4702
- return "absent";
5171
+ return void 0;
4703
5172
  }
4704
- const at = text.search(/^\[mcp_servers\.pitroom\]\s*$/m);
4705
- if (at < 0) return "absent";
4706
- const table2 = text.slice(at).split(/\n(?=\[)/)[0] ?? "";
4707
- try {
4708
- const command = /^command\s*=\s*("(?:[^"\\]|\\.)*")/m.exec(table2)?.[1];
4709
- const args = /^args\s*=\s*(\[[^\n]*\])/m.exec(table2)?.[1];
4710
- return command && args && same(c, { command: JSON.parse(command), args: JSON.parse(args) }) ? "same" : "different";
5173
+ const start = Date.parse(m.startedAt);
5174
+ const kept = isActive(m.state) ? void 0 : archivedRun(id);
5175
+ let steps = kept?.steps ?? [];
5176
+ if (!steps.length) try {
5177
+ const events = readRunFile(id, "events.jsonl");
5178
+ steps = (events ? getBackend((m.ran ?? m.worker).backend).parse(events).timeline ?? [] : []).map((s) => ({
5179
+ ...s,
5180
+ t: s.at && Number.isFinite(start) ? Math.max(0, Math.round((s.at - start) / 1e3)) : void 0
5181
+ }));
4711
5182
  } catch {
4712
- return "different";
4713
5183
  }
5184
+ const rel = (t) => [m.cwd, m.dir].filter(Boolean).reduce((x, base2) => x.split(`${base2}/`).join(""), t);
5185
+ steps = steps.map((x) => ({ ...x, text: rel(x.text) }));
5186
+ const answer = isActive(m.state) ? "" : readSummary(m).replace(/^SUMMARY:\s*SPEC[^\n]*\n+/i, "").replace(/^DETAILS:[ \t]*\n+/i, "").trim();
5187
+ const lastSay = steps.at(-1);
5188
+ if (lastSay?.kind === "say" && answer && (answer.includes(lastSay.text.slice(0, 80)) || lastSay.text.includes(answer.slice(0, 80)))) steps.pop();
5189
+ const fullPatch = readRunFile(id, "changes.patch");
5190
+ const patch = fullPatch ?? kept?.patch ?? "";
5191
+ const patchLines = patch.split("\n");
5192
+ const u = m.usage;
5193
+ return {
5194
+ id,
5195
+ state: m.state,
5196
+ card: toRun(m),
5197
+ task: (m.auditOf ? `Audit of ${m.auditOf}` : 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),
5198
+ steps,
5199
+ answer,
5200
+ changes: m.changes ?? [],
5201
+ fileDiffs: fileDiffs(patch, m.changes ?? [], fullPatch === void 0 && kept?.patchTruncated),
5202
+ patch: patch ? `${patchLines.slice(0, PATCH_LINES).join("\n")}${patchLines.length > PATCH_LINES ? `
5203
+ \u2026 ${patchLines.length - PATCH_LINES} more lines (pitroom show ${id} --patch)` : ""}` : void 0,
5204
+ info: {
5205
+ worker: (m.ran ?? m.worker).backend,
5206
+ // the model has its own line
5207
+ model: m.resolvedModel,
5208
+ mode: m.mode,
5209
+ started: m.startedAt,
5210
+ time: elapsed(m),
5211
+ steps: u?.steps,
5212
+ toolCalls: u?.toolCalls,
5213
+ tokens: u?.total,
5214
+ returnedTokens: m.returnedTokens,
5215
+ cost: u?.cost,
5216
+ saved: m.savedUsd,
5217
+ group: m.group,
5218
+ directory: m.dir
5219
+ },
5220
+ attempts: m.attempts ?? [],
5221
+ warnings: m.warnings ?? [],
5222
+ refs: m.refs ? { valid: m.refs.valid, total: m.refs.total, invalid: m.refs.invalid.map((r) => `${r.ref} (${r.reason})`) } : void 0,
5223
+ verify: m.verifyResult && m.verify ? { command: m.verify, ok: m.verifyResult.ok, tail: m.verifyResult.tail } : void 0,
5224
+ error: m.error,
5225
+ audit: m.auditOf ? m.state === "done" ? { state: m.state, verdict: m.auditVerdict, disputed: m.auditDisputed ?? [] } : void 0 : m.audit && { id: m.audit.id, state: m.audit.state, verdict: m.audit.verdict, disputed: m.audit.disputed ?? [] },
5226
+ report: isActive(m.state) ? progress(m) : formatReport(m, void 0, 120)
5227
+ };
4714
5228
  }
4715
- function clients() {
4716
- return [
4717
- cliClient("claude-code", "claude", "Claude Code", "user settings (~/.claude.json)", {
4718
- state: (c) => jsonState(path22.join(home2(), ".claude.json"), c),
4719
- add: (c) => ["mcp", "add", "--scope", "user", NAME, "--", c.command, ...c.args],
4720
- remove: () => ["mcp", "remove", NAME, "--scope", "user"]
4721
- }),
4722
- cliClient("codex", "codex", "Codex", "~/.codex/config.toml", {
4723
- state: (c) => codexState(c),
4724
- add: (c) => ["mcp", "add", NAME, "--", c.command, ...c.args],
4725
- remove: () => ["mcp", "remove", NAME]
4726
- }),
4727
- cliClient("gemini", "gemini", "Gemini CLI", "user settings (~/.gemini/settings.json)", {
4728
- state: (c) => jsonState(path22.join(geminiDir(), "settings.json"), c),
4729
- add: (c) => ["mcp", "add", "--scope", "user", NAME, c.command, ...c.args],
4730
- remove: () => ["mcp", "remove", "--scope", "user", NAME]
4731
- }),
4732
- jsonClient("cursor", "Cursor", () => path22.join(home2(), ".cursor", "mcp.json"), () => path22.join(home2(), ".cursor")),
4733
- jsonClient("claude-desktop", "Claude Desktop", desktopFile, () => path22.dirname(desktopFile()))
4734
- ];
4735
- }
4736
- function attempt2(f) {
5229
+ var HEADERS = {
5230
+ "cache-control": "no-store",
5231
+ "x-content-type-options": "nosniff",
5232
+ "referrer-policy": "no-referrer",
5233
+ // Own files only, plus the hash of the one inline theme script; styles may be inline (the components position popups with them).
5234
+ "content-security-policy": `default-src 'none'; script-src 'self' 'sha256-${crypto6.createHash("sha256").update(THEME_SCRIPT).digest("base64")}'; style-src 'self' 'unsafe-inline'; img-src data:; connect-src 'self'; base-uri 'none'; form-action 'none'`
5235
+ };
5236
+ var ASSETS = { "/assets/app.js": "text/javascript; charset=utf-8", "/assets/app.css": "text/css; charset=utf-8" };
5237
+ var assetsDir = () => fileURLToPath3(new URL("./ui/", import.meta.url));
5238
+ var assetCache = /* @__PURE__ */ new Map();
5239
+ function readAsset(route) {
4737
5240
  try {
4738
- return f();
4739
- } catch (e) {
4740
- return { ok: false, message: e.message.split("\n")[0] ?? "failed" };
5241
+ const file2 = path22.join(assetsDir(), path22.basename(route));
5242
+ const { mtimeMs } = fs26.statSync(file2);
5243
+ const hit = assetCache.get(route);
5244
+ if (hit && hit.mtimeMs === mtimeMs) return hit.data;
5245
+ const data = fs26.readFileSync(file2);
5246
+ assetCache.set(route, { mtimeMs, data });
5247
+ return data;
5248
+ } catch {
5249
+ return void 0;
4741
5250
  }
4742
5251
  }
4743
- function installMcp(opts = {}) {
4744
- const c = mcpCommand();
4745
- const out = [];
4746
- for (const client of clients()) {
4747
- if (opts.only && !opts.only.includes(client.id)) continue;
4748
- const r = (state, message) => out.push({ id: client.id, name: client.name, state, message });
4749
- if (!client.found()) {
4750
- r("not-found", client.id === "cursor" || client.id === "claude-desktop" ? "not found on this machine" : "not installed (its command does not run)");
4751
- continue;
5252
+ function handler(touch2) {
5253
+ const send = (res, code, type, body) => {
5254
+ res.writeHead(code, { ...HEADERS, "content-type": type });
5255
+ res.end(body);
5256
+ };
5257
+ const json = (res, code, value) => send(res, code, "application/json; charset=utf-8", JSON.stringify(value));
5258
+ return (req, res) => {
5259
+ touch2();
5260
+ if (!LOCAL_HOST.test(req.headers.host ?? "")) return json(res, 403, { error: "forbidden host" });
5261
+ if (req.method !== "GET" && req.method !== "HEAD") return json(res, 405, { error: "read-only" });
5262
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
5263
+ if (url.pathname === "/") return send(res, 200, "text/html; charset=utf-8", readAsset("/assets/app.js") ? PAGE : MISSING);
5264
+ const type = ASSETS[url.pathname];
5265
+ if (type) {
5266
+ const data = readAsset(url.pathname);
5267
+ if (!data) return json(res, 404, { error: "dashboard files missing" });
5268
+ res.writeHead(200, { ...HEADERS, "content-type": type });
5269
+ return void res.end(req.method === "HEAD" ? void 0 : data);
4752
5270
  }
4753
- const now = client.state(c);
4754
- if (now === "same" && !opts.force) {
4755
- r("already", "already registered");
4756
- continue;
5271
+ if (url.pathname === "/api/state") {
5272
+ const group = url.searchParams.get("group") ?? void 0;
5273
+ const limit = Number(url.searchParams.get("limit") ?? 40);
5274
+ return json(res, 200, dashState({ group: group && group.length <= 100 ? group : void 0, limit: Number.isFinite(limit) ? limit : 40 }));
4757
5275
  }
4758
- if (opts.dryRun) {
4759
- r("would-add", `would register ${[c.command, ...c.args].join(" ")}`);
4760
- continue;
5276
+ if (url.pathname === "/api/history") {
5277
+ const q = url.searchParams;
5278
+ const days2 = Number(q.get("days") ?? 0);
5279
+ return json(res, 200, listHistory({
5280
+ text: (q.get("q") ?? "").slice(0, 200),
5281
+ model: q.get("model") || void 0,
5282
+ backend: q.get("backend") || void 0,
5283
+ state: q.get("state") || void 0,
5284
+ group: q.get("group") || void 0,
5285
+ sinceMs: days2 > 0 ? Date.now() - days2 * 864e5 : void 0,
5286
+ beforeId: RUN_ID3.test(q.get("before") ?? "") ? q.get("before") : void 0,
5287
+ limit: Number(q.get("limit") ?? 30) || 30
5288
+ }));
4761
5289
  }
4762
- if (now !== "absent") attempt2(() => client.remove());
4763
- const done = attempt2(() => client.add(c));
4764
- r(done.ok ? now === "absent" ? "added" : "updated" : "failed", done.ok ? `${now === "absent" ? "registered" : "updated"} in ${done.message}` : done.message);
4765
- }
4766
- return out;
4767
- }
4768
- function uninstallMcp() {
4769
- const c = mcpCommand();
4770
- const out = [];
4771
- for (const client of clients()) {
4772
- let has2 = false;
4773
- try {
4774
- has2 = client.state(c) !== "absent";
4775
- } catch {
4776
- has2 = false;
5290
+ if (url.pathname === "/api/stats") {
5291
+ const days2 = Number(url.searchParams.get("days") ?? 30);
5292
+ return json(res, 200, { ...historyStats(days2 > 0 ? Date.now() - days2 * 864e5 : void 0), price: primaryPrice().name });
4777
5293
  }
4778
- if (!has2) continue;
4779
- const done = client.found() ? attempt2(() => client.remove()) : { ok: false, message: `its command does not run: remove the pitroom entry from its config by hand` };
4780
- out.push({ id: client.id, name: client.name, state: done.ok ? "removed" : "failed", message: done.ok ? `removed from ${done.message}` : done.message });
4781
- }
4782
- return out;
4783
- }
4784
- function mcpStatus() {
4785
- const c = mcpCommand();
4786
- return clients().map((client) => {
4787
- try {
4788
- return { id: client.id, name: client.name, state: client.state(c) };
4789
- } catch {
4790
- return { id: client.id, name: client.name, state: "absent" };
5294
+ const m = /^\/api\/run\/([^/]+)$/.exec(url.pathname);
5295
+ if (m) {
5296
+ const d = runDetail(m[1]);
5297
+ return d ? json(res, 200, d) : json(res, 404, { error: "no such run" });
4791
5298
  }
5299
+ return json(res, 404, { error: "not found" });
5300
+ };
5301
+ }
5302
+ function startDash(opts) {
5303
+ let lastRequest = Date.now();
5304
+ const server = http.createServer(handler(() => lastRequest = Date.now()));
5305
+ return new Promise((resolve2, reject) => {
5306
+ server.once("error", reject);
5307
+ server.listen(opts.port, "127.0.0.1", () => {
5308
+ const port = server.address().port;
5309
+ let done;
5310
+ const closed = new Promise((r) => done = r);
5311
+ let timer2;
5312
+ const close = () => {
5313
+ if (timer2) clearInterval(timer2);
5314
+ server.closeAllConnections?.();
5315
+ return new Promise((r) => server.close(() => (done(), r())));
5316
+ };
5317
+ if (opts.idleMs && opts.idleMs > 0) {
5318
+ timer2 = setInterval(() => Date.now() - lastRequest > opts.idleMs && void close(), Math.min(6e4, opts.idleMs));
5319
+ timer2.unref();
5320
+ }
5321
+ resolve2({ port, url: `http://127.0.0.1:${port}/`, closed, close });
5322
+ });
4792
5323
  });
4793
5324
  }
4794
- var parseClients = (value) => {
4795
- if (!value) return void 0;
4796
- const wanted = value.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
4797
- const bad = wanted.filter((w) => !CLIENT_IDS.includes(w));
4798
- if (bad.length) throw new Error(`unknown client ${bad.join(", ")} (one of: ${CLIENT_IDS.join(", ")})`);
4799
- return wanted;
4800
- };
4801
-
4802
- // src/core/plan-status.ts
4803
- import crypto6 from "node:crypto";
4804
- import fs27 from "node:fs";
4805
- import path23 from "node:path";
4806
- function notesFile(plan) {
4807
- const id = crypto6.createHash("sha1").update(plan.file).digest("hex").slice(0, 12);
4808
- return path23.join(home(), "plans", id, "notes.md");
5325
+ var registryFile = () => path22.join(home(), "dash.json");
5326
+ async function ping(port) {
5327
+ try {
5328
+ const r = await fetch(`http://127.0.0.1:${port}/api/state?limit=1`, { signal: AbortSignal.timeout(1500) });
5329
+ return r.ok;
5330
+ } catch {
5331
+ return false;
5332
+ }
4809
5333
  }
4810
- function readNotes(plan) {
4811
- const f = notesFile(plan);
4812
- if (!fs27.existsSync(f)) return [];
4813
- return fs27.readFileSync(f, "utf8").split("\n").slice(1).filter(Boolean).map((l) => {
4814
- const m = /^(\d{4}-\d\d-\d\d \d\d:\d\d) (.*)$/.exec(l);
4815
- return m ? { at: m[1], text: m[2] } : { at: "", text: l };
4816
- });
5334
+ async function runningDash() {
5335
+ try {
5336
+ const r = JSON.parse(fs26.readFileSync(registryFile(), "utf8"));
5337
+ if (isAlive(r.pid) && await ping(r.port)) return { ...r, url: `http://127.0.0.1:${r.port}/` };
5338
+ } catch {
5339
+ }
5340
+ fs26.rmSync(registryFile(), { force: true });
5341
+ return void 0;
4817
5342
  }
4818
- function addNote(planFile, text) {
4819
- const plan = loadPlan(planFile);
4820
- const f = notesFile(plan);
4821
- fs27.mkdirSync(path23.dirname(f), { recursive: true });
4822
- if (!fs27.existsSync(f)) fs27.writeFileSync(f, `# plan: ${plan.file}
4823
- `);
4824
- const at = (/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace("T", " ");
4825
- fs27.appendFileSync(f, `${at} ${text.replace(/\s+/g, " ").trim()}
4826
- `);
4827
- return f;
5343
+ function openBrowser(url) {
5344
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
5345
+ try {
5346
+ spawn4(cmd, args, { stdio: "ignore", detached: true }).on("error", () => void 0).unref();
5347
+ } catch {
5348
+ }
4828
5349
  }
4829
- function planStatus(planFile) {
4830
- const plan = loadPlan(planFile);
4831
- const runs = [];
4832
- for (const id of listRunIds()) {
4833
- try {
4834
- const m = freshMeta(id);
4835
- if (m.plan?.file === plan.file) runs.push(m);
4836
- } catch {
5350
+ async function dashCommand(o) {
5351
+ const existing = await runningDash();
5352
+ if (o.stop) {
5353
+ if (!existing) throw new UserError("pitroom dash is not running");
5354
+ process.kill(existing.pid, "SIGTERM");
5355
+ fs26.rmSync(registryFile(), { force: true });
5356
+ o.log(`stopped pitroom dash (${existing.url})`);
5357
+ return 0;
5358
+ }
5359
+ if (existing && !o.serve) {
5360
+ o.log(existing.url);
5361
+ if (o.open) openBrowser(existing.url);
5362
+ return 0;
5363
+ }
5364
+ if (o.detach) {
5365
+ const child = spawn4(process.execPath, [process.argv[1], "dash", "--serve", ...o.port !== void 0 ? ["--port", String(o.port)] : [], "--idle", "1h"], {
5366
+ detached: true,
5367
+ stdio: "ignore",
5368
+ env: process.env
5369
+ });
5370
+ child.on("error", () => void 0);
5371
+ child.unref();
5372
+ for (let i = 0; i < 60; i++) {
5373
+ await new Promise((r2) => setTimeout(r2, 100));
5374
+ const r = await runningDash();
5375
+ if (r && r.pid === child.pid) {
5376
+ o.log(r.url);
5377
+ if (o.open) openBrowser(r.url);
5378
+ return 0;
5379
+ }
4837
5380
  }
5381
+ throw new UserError("pitroom dash did not start (is the port taken? try --port 0)");
4838
5382
  }
4839
- const notes = readNotes(plan);
4840
- const tasks = plan.tasks.map((t) => {
4841
- const impl = runs.filter((m) => m.plan.step === t.step && !m.reviewOf);
4842
- const rev = runs.filter((m) => m.plan.step === t.step && m.reviewOf).at(-1);
4843
- const last = impl.at(-1);
4844
- return {
4845
- step: t.step,
4846
- title: t.title,
4847
- runs: impl.length,
4848
- last: last && { id: last.id, state: last.state, status: last.taskStatus },
4849
- review: rev && { id: rev.id, state: rev.state, kind: rev.reviewKind ?? "task", verdict: rev.verdict },
4850
- applied: impl.some((m) => m.applied || m.mode === "write" && m.state === "done" && !m.reverted && !!m.changes?.length),
4851
- note: [...notes].reverse().find((n) => n.text.startsWith(`Task ${t.step}:`))?.text
4852
- };
4853
- });
4854
- return { plan, tasks, rulings: notes.filter((n) => /\bRuling:/.test(n.text)), notesFile: notesFile(plan) };
4855
- }
4856
- var cell = (s, max) => {
4857
- const t = s.replace(/\s+/g, " ").trim();
4858
- return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
4859
- };
4860
- function formatPlanStatus(s) {
4861
- const head = ["TASK", "STATE", "STATUS", "REVIEW", "ROUNDS", "APPLIED", "TITLE", "NOTE"];
4862
- const rows = s.tasks.map((t) => {
4863
- const v = t.review?.verdict;
4864
- const review = v ? `${v.spec}/${v.quality} (c${v.critical} i${v.important} m${v.minor})` : t.review?.state ?? "-";
4865
- return [
4866
- String(t.step),
4867
- t.last?.state ?? "-",
4868
- t.last?.status ?? "-",
4869
- review,
4870
- t.runs ? String(t.runs - 1) : "-",
4871
- t.runs ? t.applied ? "yes" : "no" : "-",
4872
- cell(t.title, 32),
4873
- cell(t.note ?? "", 60)
4874
- ];
4875
- });
4876
- const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
4877
- const fmt = (r) => r.map((c, i) => i === r.length - 1 ? c : c.padEnd(widths[i])).join(" ").trimEnd();
4878
- const rulings = s.rulings.length ? s.rulings.map((n) => ` ${n.at} ${n.text}`).join("\n") : " none";
4879
- return [
4880
- `plan ${s.plan.file} \xB7 ${s.plan.title || planName(s.plan.file)} \xB7 ${s.tasks.length} tasks`,
4881
- "",
4882
- fmt(head),
4883
- ...rows.map(fmt),
4884
- "",
4885
- `Rulings:
4886
- ${rulings}`,
4887
- `notes: ${s.notesFile}`
4888
- ].join("\n");
5383
+ importRuns();
5384
+ const explicit = o.port !== void 0;
5385
+ let dash;
5386
+ try {
5387
+ dash = await startDash({ port: o.port ?? DEFAULT_PORT, idleMs: o.idleMs });
5388
+ } catch (e) {
5389
+ if (explicit || e.code !== "EADDRINUSE") throw new UserError(`pitroom dash: ${e.message}`);
5390
+ dash = await startDash({ port: 0, idleMs: o.idleMs });
5391
+ }
5392
+ fs26.mkdirSync(home(), { recursive: true });
5393
+ fs26.writeFileSync(registryFile(), JSON.stringify({ pid: process.pid, port: dash.port }));
5394
+ const stop = () => void dash.close();
5395
+ process.once("SIGINT", stop);
5396
+ process.once("SIGTERM", stop);
5397
+ if (!o.serve) o.log(`${dash.url}
5398
+ (read-only, this machine only; Ctrl-C stops it)`);
5399
+ if (o.open) openBrowser(dash.url);
5400
+ await dash.closed;
5401
+ try {
5402
+ if (JSON.parse(fs26.readFileSync(registryFile(), "utf8")).pid === process.pid) fs26.rmSync(registryFile(), { force: true });
5403
+ } catch {
5404
+ }
5405
+ return 0;
4889
5406
  }
4890
5407
 
4891
- // src/core/review.ts
4892
- import crypto7 from "node:crypto";
4893
- import fs28 from "node:fs";
4894
- import path24 from "node:path";
4895
- var TEMPLATE2 = { task: "task-reviewer", fix: "re-review", range: "code-reviewer" };
4896
- var section = (title, body) => `## ${title}
4897
-
4898
- ${body.trim() || "(empty)"}
4899
- `;
4900
- function ancestors(m) {
4901
- const out = [];
4902
- for (let id = m.parent; id; ) {
4903
- const a = readMeta(id);
4904
- out.push(a);
4905
- id = a.parent;
5408
+ // src/cli/mcp-http.ts
5409
+ var DEFAULT_PORT2 = 7117;
5410
+ var MAX_BODY = 4e6;
5411
+ var MAX_SESSIONS = 64;
5412
+ var IDLE_MS = 60 * 6e4;
5413
+ var PATH = "/mcp";
5414
+ var tokenFile = () => path23.join(home(), "mcp-token");
5415
+ var readToken = (file2) => {
5416
+ try {
5417
+ const kept = fs27.readFileSync(file2, "utf8").trim();
5418
+ return kept.length >= 16 ? kept : void 0;
5419
+ } catch {
5420
+ return void 0;
4906
5421
  }
4907
- return out;
4908
- }
4909
- function briefOf(m) {
4910
- const root = [m, ...ancestors(m)].at(-1);
4911
- const f = runFile(root.id, "brief.md");
4912
- return fs28.existsSync(f) ? fs28.readFileSync(f, "utf8") : root.task;
4913
- }
4914
- function latestReview(ids) {
4915
- for (const id of listRunIds().reverse()) {
5422
+ };
5423
+ function mcpToken() {
5424
+ const env = process.env.PITROOM_MCP_TOKEN?.trim();
5425
+ if (env) {
5426
+ if (env.length < 16) throw new UserError("PITROOM_MCP_TOKEN is too short to be a secret (16 characters at least)", 3);
5427
+ return { token: env, from: "PITROOM_MCP_TOKEN" };
5428
+ }
5429
+ const file2 = tokenFile();
5430
+ let token = readToken(file2);
5431
+ if (!token) {
5432
+ fs27.mkdirSync(path23.dirname(file2), { recursive: true });
5433
+ token = crypto7.randomBytes(32).toString("hex");
4916
5434
  try {
4917
- const m = readMeta(id);
4918
- if (m.reviewOf && ids.includes(m.reviewOf) && m.state === "done") return m;
4919
- } catch {
5435
+ fs27.writeFileSync(file2, `${token}
5436
+ `, { mode: 384, flag: "wx" });
5437
+ } catch (e) {
5438
+ if (e.code !== "EEXIST") throw e;
5439
+ token = readToken(file2);
5440
+ if (!token) throw new UserError(`${file2} holds no usable token: delete it and start again`, 3);
4920
5441
  }
4921
5442
  }
4922
- return void 0;
4923
- }
4924
- function diffOf(m, from = m.baseTree) {
4925
- const where = m.mode === "isolate" ? m.worktree : m.repoRoot;
4926
- if (where && fs28.existsSync(where) && from && m.afterTree) return reviewDiff(where, from, m.afterTree);
4927
- if (from !== m.baseTree) {
4928
- 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);
5443
+ try {
5444
+ fs27.chmodSync(file2, 384);
5445
+ } catch {
4929
5446
  }
4930
- return readRunFile(m.id, "changes.patch") ?? archivedRun(m.id)?.patch ?? "";
5447
+ return { token, from: file2 };
4931
5448
  }
4932
- function runReview(id) {
4933
- const m = freshMeta(id);
4934
- if (isActive(m.state)) throw new UserError(`run ${m.id} is still ${m.state}; pitroom wait ${m.id} first`, 3);
4935
- if (m.reviewOf) throw new UserError(`run ${m.id} is itself a review`);
4936
- if (m.mode === "read") throw new UserError(`run ${m.id} was read-only; there is no change to review`);
4937
- if (!m.changes?.length) throw new UserError(`run ${m.id} made no changes; nothing to review`);
4938
- const chain = ancestors(m);
4939
- const previous = latestReview(chain.map((a) => a.id));
4940
- const dir = m.mode === "isolate" && m.worktree && fs28.existsSync(m.worktree) ? m.cwd : m.dir;
4941
- const job = { of: m.id, dir, implementer: m.ran ?? m.worker, group: m.group, plan: m.plan };
4942
- const title = m.plan ? `Task ${m.plan.step}: ${m.plan.title}` : `run ${m.id}`;
4943
- if (!previous) {
4944
- return {
4945
- ...job,
4946
- kind: "task",
4947
- package: [`# Review package \xB7 ${title}
4948
- `, section("BRIEF", briefOf(m)), section("REPORT", readSummary(m)), section("DIFF", diffOf(m))].join("\n")
4949
- };
4950
- }
4951
- const reviewed = chain.find((a) => a.id === previous.reviewOf);
4952
- const from = m.mode === "isolate" ? reviewed.afterTree : m.baseTree;
4953
- return {
4954
- ...job,
4955
- kind: "fix",
4956
- package: [
4957
- `# Re-review package \xB7 ${title} \xB7 fix round after review ${previous.id}
4958
- `,
4959
- section("BRIEF", briefOf(m)),
4960
- section("PREVIOUS FINDINGS", readSummary(previous)),
4961
- section("FIX REPORT", readSummary(m)),
4962
- section("FIX DIFF", diffOf(m, from))
4963
- ].join("\n")
4964
- };
5449
+ var LOCAL_ORIGIN = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i;
5450
+ var sameSecret = (given, token) => {
5451
+ const a = crypto7.createHash("sha256").update(given).digest();
5452
+ const b = crypto7.createHash("sha256").update(token).digest();
5453
+ return crypto7.timingSafeEqual(a, b);
5454
+ };
5455
+ var idleSince = (k) => Math.max(k.lastSeen, k.closeStream ? k.streamSeen ?? 0 : 0);
5456
+ function drop(sessions, id) {
5457
+ const k = sessions.get(id);
5458
+ if (!k) return;
5459
+ k.closeStream?.();
5460
+ endSession(k.session);
5461
+ sessions.delete(id);
5462
+ }
5463
+ var PING_MS = 25e3;
5464
+ function reply(res, status, body, headers = {}) {
5465
+ if (res.headersSent) return void res.end();
5466
+ const text = body === void 0 ? "" : JSON.stringify(body);
5467
+ res.writeHead(status, { ...text ? { "Content-Type": "application/json" } : {}, "Content-Length": Buffer.byteLength(text), ...headers });
5468
+ res.end(text);
5469
+ }
5470
+ var failure5 = (code, message) => ({ jsonrpc: "2.0", id: null, error: { code, message } });
5471
+ function readBody(req) {
5472
+ return new Promise((resolve2, reject) => {
5473
+ const chunks = [];
5474
+ let size = 0;
5475
+ req.on("data", (c) => {
5476
+ size += c.length;
5477
+ if (size <= MAX_BODY) chunks.push(c);
5478
+ });
5479
+ req.on("end", () => resolve2(size > MAX_BODY ? "too-big" : Buffer.concat(chunks).toString("utf8")));
5480
+ req.on("error", reject);
5481
+ });
4965
5482
  }
4966
- function rangeReview(range, dir, planFile) {
4967
- const root = repoRoot(dir);
4968
- if (!root) throw new UserError("--range needs a git repository");
4969
- const i = range.indexOf("..");
4970
- const a = i > 0 ? range.slice(0, i) : "";
4971
- const b = i > 0 ? range.slice(i + 2) || "HEAD" : "";
4972
- if (!a || b.startsWith(".")) throw new UserError(`--range takes A..B (e.g. main..HEAD), not "${range}"`);
4973
- for (const ref of [a, b]) if (!commitOf(root, ref)) throw new UserError(`not a commit: ${ref}`);
4974
- const plan = planFile ? loadPlan(planFile) : void 0;
4975
- const notes = plan ? readNotes(plan) : [];
4976
- const requirements = plan ? [
4977
- `Plan: ${plan.file}`,
4978
- "### Global Constraints",
4979
- plan.constraints || "(none stated in the plan)",
4980
- "### Tasks",
4981
- plan.tasks.map((t) => `- Task ${t.step}: ${t.title}`).join("\n")
4982
- ].join("\n\n") : "(none given; judge the change on its own terms)";
4983
- return {
4984
- kind: "range",
4985
- of: `${a}..${b}`,
4986
- dir: root,
4987
- group: plan ? planName(plan.file) : void 0,
4988
- package: [
4989
- `# Review package \xB7 ${a}..${b}
4990
- `,
4991
- section("WHAT WAS IMPLEMENTED", plan ? `${plan.title}
5483
+ var wantsStream = (parsed) => {
5484
+ const messages = Array.isArray(parsed) ? parsed : [parsed];
5485
+ return messages.some((m) => m && typeof m === "object" && m.id !== void 0 && m.id !== null && progressToken(m.params) !== void 0);
5486
+ };
5487
+ function evictIdle(sessions) {
5488
+ let oldest;
5489
+ for (const entry of sessions) if (!entry[1].session.inflight.size && (!oldest || idleSince(entry[1]) < idleSince(oldest[1]))) oldest = entry;
5490
+ if (!oldest) return false;
5491
+ drop(sessions, oldest[0]);
5492
+ return true;
5493
+ }
5494
+ function makeHandler(token, sessions) {
5495
+ return async (req, res) => {
5496
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
5497
+ if (url.pathname !== PATH) return reply(res, 404, failure5(-32600, `not found: the endpoint is ${PATH}`));
5498
+ if (!LOCAL_HOST.test(req.headers.host ?? "")) return reply(res, 403, failure5(-32600, "forbidden host"));
5499
+ const origin = req.headers.origin;
5500
+ if (origin !== void 0 && !LOCAL_ORIGIN.test(origin)) return reply(res, 403, failure5(-32600, "forbidden origin"));
5501
+ const auth = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? "")?.[1]?.trim();
5502
+ if (!auth || !sameSecret(auth, token)) return reply(res, 401, failure5(-32001, "a bearer token is needed"), { "WWW-Authenticate": "Bearer" });
5503
+ const sid = typeof req.headers["mcp-session-id"] === "string" ? req.headers["mcp-session-id"] : void 0;
5504
+ if (req.method === "DELETE") {
5505
+ if (!sid || !sessions.has(sid)) return reply(res, 404, failure5(-32600, "no such session"));
5506
+ drop(sessions, sid);
5507
+ return reply(res, 204);
5508
+ }
5509
+ if (req.method === "GET") {
5510
+ if (!/text\/event-stream|\*\/\*/.test(req.headers.accept ?? "")) return reply(res, 406, failure5(-32600, "a GET opens the event stream: send Accept: text/event-stream"));
5511
+ if (!sid) return reply(res, 400, failure5(-32600, "Mcp-Session-Id is missing: initialize first"));
5512
+ const kept2 = sessions.get(sid);
5513
+ if (!kept2) return reply(res, 404, failure5(-32600, "no such session: initialize again"));
5514
+ kept2.closeStream?.();
5515
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
5516
+ res.write(": open\n\n");
5517
+ const open = () => !res.writableEnded && !res.destroyed;
5518
+ const push = (msg) => {
5519
+ if (!open()) return;
5520
+ kept2.streamSeen = Date.now();
5521
+ res.write(`event: message
5522
+ data: ${JSON.stringify(msg)}
4992
5523
 
4993
- ${plan.header}` : "The commits below."),
4994
- section("REQUIREMENTS", requirements),
4995
- ...notes.length ? [section("NOTES FROM EXECUTION", notes.map((n) => `- ${n.text}`).join("\n"))] : [],
4996
- rangeDiff(root, a, b)
4997
- ].join("\n")
5524
+ `);
5525
+ };
5526
+ const ping2 = setInterval(() => open() && res.write(": ping\n\n"), PING_MS);
5527
+ ping2.unref();
5528
+ res.on("error", () => {
5529
+ });
5530
+ kept2.streamSeen = Date.now();
5531
+ const close = () => {
5532
+ clearInterval(ping2);
5533
+ if (kept2.session.push === push) kept2.session.push = void 0;
5534
+ if (kept2.closeStream === close) kept2.closeStream = void 0;
5535
+ if (!res.writableEnded) res.end();
5536
+ };
5537
+ kept2.session.push = push;
5538
+ kept2.closeStream = close;
5539
+ res.on("close", close);
5540
+ return;
5541
+ }
5542
+ if (req.method !== "POST") return reply(res, 405, failure5(-32600, "GET, POST or DELETE"), { Allow: "GET, POST, DELETE" });
5543
+ const body = await readBody(req);
5544
+ if (body === "too-big") return reply(res, 413, failure5(-32600, `a request body is at most ${MAX_BODY} bytes`));
5545
+ let parsed;
5546
+ try {
5547
+ parsed = JSON.parse(body);
5548
+ } catch {
5549
+ return reply(res, 400, parseError());
5550
+ }
5551
+ const headers = {};
5552
+ let kept;
5553
+ const single = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
5554
+ if (single?.method === "initialize") {
5555
+ if (sid) drop(sessions, sid);
5556
+ if (sessions.size >= MAX_SESSIONS && !evictIdle(sessions)) return reply(res, 503, failure5(-32e3, `${MAX_SESSIONS} sessions are busy with requests: try again when one ends`));
5557
+ const id = crypto7.randomUUID();
5558
+ kept = { session: newSession(), lastSeen: Date.now() };
5559
+ sessions.set(id, kept);
5560
+ headers["Mcp-Session-Id"] = id;
5561
+ } else {
5562
+ if (!sid) return reply(res, 400, failure5(-32600, "Mcp-Session-Id is missing: initialize first"));
5563
+ kept = sessions.get(sid);
5564
+ if (!kept) return reply(res, 404, failure5(-32600, "no such session: initialize again"));
5565
+ }
5566
+ kept.lastSeen = Date.now();
5567
+ if (!wantsStream(parsed)) {
5568
+ const out2 = await handlePayload(kept.session, parsed, () => {
5569
+ });
5570
+ kept.lastSeen = Date.now();
5571
+ return out2 === void 0 ? reply(res, 202, void 0, headers) : reply(res, 200, out2, headers);
5572
+ }
5573
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", ...headers });
5574
+ const event = (msg) => {
5575
+ if (!res.writableEnded && !res.destroyed) res.write(`event: message
5576
+ data: ${JSON.stringify(msg)}
5577
+
5578
+ `);
5579
+ };
5580
+ const out = await handlePayload(kept.session, parsed, event);
5581
+ if (out !== void 0) event(out);
5582
+ kept.lastSeen = Date.now();
5583
+ res.end();
4998
5584
  };
4999
5585
  }
5000
- function pickReviewer(job) {
5001
- const eff = effective();
5002
- const tiers = eff.tiers.value;
5003
- if (job.kind === "range") return tiers.capable;
5004
- if (tiers.review) return tiers.review;
5005
- const implementer = job.implementer;
5006
- if (!implementer) return void 0;
5007
- const def = parseTarget(eff.worker.value, DEFAULT_BACKEND).backend;
5008
- const candidates = [tiers.standard, tiers.capable, ...eff.fallback.value, eff.worker.value].filter((s) => !!s);
5009
- return candidates.find((c) => parseTarget(c, def).backend !== implementer.backend);
5010
- }
5011
- function writePackage(job) {
5012
- const g = gitDir(job.dir);
5013
- if (!g) throw new UserError(`not a git repository: ${job.dir}`);
5014
- const file2 = path24.join(g, "pitroom", `review-${crypto7.randomBytes(4).toString("hex")}.md`);
5015
- fs28.mkdirSync(path24.dirname(file2), { recursive: true });
5016
- fs28.writeFileSync(file2, job.package);
5017
- return file2;
5586
+ async function serveMcpHttp(opts) {
5587
+ const { token, from } = mcpToken();
5588
+ const sessions = /* @__PURE__ */ new Map();
5589
+ const handler2 = makeHandler(token, sessions);
5590
+ const server = http2.createServer((req, res) => {
5591
+ handler2(req, res).catch((e) => {
5592
+ process.stderr.write(`pitroom mcp: ${String(e.stack ?? e)}
5593
+ `);
5594
+ reply(res, 500, failure5(-32603, "internal error"));
5595
+ });
5596
+ });
5597
+ const sweep = setInterval(() => {
5598
+ for (const [id, k] of [...sessions]) if (Date.now() - idleSince(k) > IDLE_MS && !k.session.inflight.size) drop(sessions, id);
5599
+ }, 6e4);
5600
+ sweep.unref();
5601
+ const port = await new Promise((resolve2, reject) => {
5602
+ server.once("error", (e) => reject(e.code === "EADDRINUSE" ? new UserError(`port ${opts.port} is in use: pick another with --port N (0 picks a free one)`, 3) : e));
5603
+ server.listen(opts.port, "127.0.0.1", () => resolve2(server.address().port));
5604
+ });
5605
+ const url = `http://127.0.0.1:${port}${PATH}`;
5606
+ const secret = from === "PITROOM_MCP_TOKEN" ? "$PITROOM_MCP_TOKEN" : `$(cat "${from}")`;
5607
+ console.log(`pitroom mcp: listening on ${url} (127.0.0.1 only; token from ${from})`);
5608
+ console.log(` working in ${process.cwd()} (-d DIR starts it in another project; the tools' "dir" argument picks one per call)`);
5609
+ console.log(` Claude Code: claude mcp add --transport http pitroom ${url} --header "Authorization: Bearer ${secret}"`);
5610
+ console.log(' other clients: URL as above, header "Authorization: Bearer <the token>"');
5611
+ console.log(" anyone with the token can run workers as you: keep it private");
5612
+ await new Promise((resolve2) => {
5613
+ const stop = () => resolve2();
5614
+ process.once("SIGINT", stop);
5615
+ process.once("SIGTERM", stop);
5616
+ });
5617
+ clearInterval(sweep);
5618
+ for (const id of [...sessions.keys()]) drop(sessions, id);
5619
+ server.closeAllConnections();
5620
+ await new Promise((resolve2) => server.close(() => resolve2()));
5621
+ return 0;
5018
5622
  }
5019
5623
 
5020
- // src/core/models.ts
5021
- var base = (model) => (model ?? "").split("#")[0];
5022
- function usage() {
5023
- const eff = effective();
5024
- const out = /* @__PURE__ */ new Map();
5025
- const add = (spec, label) => {
5026
- let t;
5624
+ // src/cli/commands.ts
5625
+ import { spawnSync as spawnSync8 } from "node:child_process";
5626
+ import fs33 from "node:fs";
5627
+ import path29 from "node:path";
5628
+ import { fileURLToPath as fileURLToPath4 } from "node:url";
5629
+
5630
+ // src/core/init.ts
5631
+ import fs28 from "node:fs";
5632
+ import path24 from "node:path";
5633
+ var FREE = /-free$/;
5634
+ var TIER_ORDER = [["cheap", "opencode"], ["standard", "codex"], ["capable", "claude"]];
5635
+ var isFound = (binary5) => path24.isAbsolute(binary5) && fs28.existsSync(binary5);
5636
+ function planInit(opts = {}) {
5637
+ const file2 = configPath();
5638
+ const workers = backendIds().map((id) => {
5639
+ const b = getBackend(id);
5640
+ const binary5 = b.binary();
5641
+ return { id, name: b.name, found: isFound(binary5), binary: binary5 };
5642
+ });
5643
+ const notes = [];
5644
+ const config = {};
5645
+ let blocked;
5646
+ let opencode2;
5647
+ if (workers.find((w) => w.id === "opencode")?.found) {
5648
+ const b = getBackend("opencode");
5649
+ let models = [];
5027
5650
  try {
5028
- t = parseTarget(spec, "opencode");
5651
+ models = b.catalog ? b.catalog().models.map((m) => m.id) : b.listModels?.() ?? [];
5029
5652
  } catch {
5030
- return;
5653
+ notes.push("could not list OpenCode models (is it logged in?)");
5031
5654
  }
5032
- const model = base(t.model) || base(eff.models.value[t.backend]);
5033
- if (!model) return;
5034
- const key = `${t.backend}:${model}`;
5035
- out.set(key, [...out.get(key) ?? [], label]);
5036
- };
5037
- add(eff.worker.value, "default worker");
5038
- for (const [name, spec] of Object.entries(eff.tiers.value)) add(spec, `tier ${name}`);
5039
- eff.fallback.value.forEach((spec) => add(spec, "fallback"));
5040
- for (const [backend, model] of Object.entries(eff.models.value)) add(`${backend}:${model}`, "models");
5041
- return out;
5042
- }
5043
- function modelTable(opts = {}) {
5044
- const costs = effective().costs.value;
5045
- const used = usage();
5046
- const seen = /* @__PURE__ */ new Map();
5047
- for (const e of readLedger()) {
5048
- if (!e.backend || !e.model) continue;
5049
- const key = `${e.backend}:${base(e.model)}`;
5050
- const a = seen.get(key) ?? { runs: 0, tokens: 0, usd: 0 };
5051
- seen.set(key, { runs: a.runs + 1, tokens: a.tokens + e.tokens, usd: a.usd + e.workerCost });
5052
- }
5053
- const rows = [];
5054
- const sources = [];
5055
- let hiddenOpenCode = 0;
5056
- const known = /* @__PURE__ */ new Set();
5057
- for (const b of allBackends()) {
5058
- if (opts.backend && b.id !== opts.backend) continue;
5059
- const catalog5 = b.catalog?.() ?? { models: [], source: "no model list for this worker" };
5060
- sources.push(`${b.id}: ${catalog5.source}`);
5061
- for (const m of catalog5.models) {
5062
- const key = `${b.id}:${m.id}`;
5063
- known.add(key);
5064
- const s = seen.get(key);
5065
- const row = {
5066
- worker: b.id,
5067
- model: m.id,
5068
- efforts: m.efforts?.length ? m.efforts : void 0,
5069
- defaultEffort: m.defaultEffort,
5070
- cost: costs[key],
5071
- runs: s?.runs ?? 0,
5072
- avgTokens: s ? Math.round(s.tokens / s.runs) : void 0,
5073
- reportedUsdPerRun: s && s.usd > 0 ? s.usd / s.runs : void 0,
5074
- inUse: used.get(key) ?? []
5075
- };
5076
- const relevant = row.inUse.length || row.runs || row.cost !== void 0;
5077
- if (b.id === "opencode" && !opts.all && !relevant) hiddenOpenCode++;
5078
- else rows.push(row);
5655
+ const defaultModel4 = b.defaultModel?.();
5656
+ const chosen = opts.model ?? defaultModel4;
5657
+ const free = models.filter((m) => FREE.test(m) && m !== chosen);
5658
+ opencode2 = { defaultModel: defaultModel4, models: models.length, free: free.slice(0, 6) };
5659
+ if (opts.model) {
5660
+ if (models.length && !models.includes(opts.model)) throw new UserError(`"${opts.model}" is not in \`opencode models\``);
5661
+ if (!models.length) notes.push(`could not check "${opts.model}": \`opencode models\` listed nothing`);
5662
+ config.models = { opencode: opts.model };
5663
+ } else if (!defaultModel4) {
5664
+ blocked = `OpenCode has no default model: choose one with --model <id>${free.length ? ` (free models you have: ${free.slice(0, 4).join(", ")})` : ""}`;
5665
+ }
5666
+ const fallback = opts.fallback ?? free.slice(0, 2);
5667
+ for (const m of fallback) {
5668
+ if (models.length && !models.includes(m)) throw new UserError(`"${m}" is not in \`opencode models\``);
5669
+ }
5670
+ if (fallback.length) {
5671
+ config.fallback = fallback.map((m) => `opencode:${m}`);
5672
+ if (!opts.fallback) notes.push(`fallback suggested from the free models in your catalogue: ${fallback.join(", ")} (change it with --fallback a,b)`);
5673
+ } else {
5674
+ notes.push("no fallback: no free OpenCode model found to suggest (pass --fallback a,b to name some)");
5079
5675
  }
5080
5676
  }
5081
- for (const [key, labels] of used) {
5082
- const [worker, ...rest] = key.split(":");
5083
- if (known.has(key) || opts.backend && worker !== opts.backend) continue;
5084
- const s = seen.get(key);
5085
- rows.push({ worker, model: rest.join(":"), cost: costs[key], runs: s?.runs ?? 0, avgTokens: s ? Math.round(s.tokens / s.runs) : void 0, inUse: labels });
5086
- }
5087
- rows.sort(
5088
- (a, b) => a.worker.localeCompare(b.worker) || Number(b.inUse.length > 0) - Number(a.inUse.length > 0) || (a.cost ?? Infinity) - (b.cost ?? Infinity) || b.runs - a.runs || a.model.localeCompare(b.model)
5089
- );
5090
- return { rows, sources, hiddenOpenCode };
5677
+ const tiers = Object.fromEntries(TIER_ORDER.filter(([, id]) => workers.find((w) => w.id === id)?.found));
5678
+ if (Object.keys(tiers).length > 1) config.tiers = tiers;
5679
+ if (!workers.some((w) => w.found)) blocked = "no worker CLI found: install OpenCode (https://opencode.ai), Codex CLI, Claude Code or Gemini CLI first";
5680
+ else if (!blocked && !Object.keys(config).length) notes.push("nothing to propose: the default model and the single worker need no config");
5681
+ return { path: file2, exists: fs28.existsSync(file2), workers, opencode: opencode2, config, blocked, notes };
5091
5682
  }
5092
- var compact2 = (n) => n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n);
5093
- function formatModels(t) {
5094
- const efforts = (r) => r.efforts ? r.efforts.map((e) => e === r.defaultEffort ? `${e}*` : e).join("/") : "-";
5095
- const cells = t.rows.map((r) => [
5096
- r.worker,
5097
- r.model,
5098
- efforts(r),
5099
- r.cost !== void 0 ? String(r.cost) : "?",
5100
- r.runs ? String(r.runs) : "-",
5101
- r.avgTokens !== void 0 ? compact2(r.avgTokens) : "-",
5102
- r.reportedUsdPerRun !== void 0 ? `$${r.reportedUsdPerRun.toFixed(3)}` : "-",
5103
- r.inUse.join(", ") || "-"
5104
- ]);
5105
- const head = ["worker", "model", "effort (* default)", "cost", "runs", "avg tokens", "$/run", "in use"];
5106
- const widths = head.map((h, i) => Math.max(h.length, ...cells.map((c) => c[i].length)));
5107
- const line = (c) => c.map((x, i) => x.padEnd(widths[i])).join(" ").trimEnd();
5108
- const out = [line(head), ...cells.map(line), ""];
5109
- out.push('cost: your relative cost from the config ("costs": {"codex:gpt-6-sol": 1, \u2026}); ? = not set. Pitroom cannot know vendor prices.');
5110
- out.push("runs, avg tokens, $/run: from your own runs (only Claude Code and OpenCode report dollars). Choose one with -W worker:model --effort LEVEL.");
5111
- if (t.hiddenOpenCode) out.push(`${t.hiddenOpenCode} more OpenCode models not shown (use --all).`);
5112
- out.push(...t.sources.map((s) => `source ${s}`));
5683
+ function writeInit(plan, force) {
5684
+ if (plan.blocked) throw new UserError(plan.blocked);
5685
+ if (!Object.keys(plan.config).length) throw new UserError("nothing to write: the proposal is empty");
5686
+ if (plan.exists && !force) throw new UserError(`${plan.path} already exists: pass --force to replace it (the old file is kept as ${path24.basename(plan.path)}.bak)`);
5687
+ fs28.mkdirSync(path24.dirname(plan.path), { recursive: true });
5688
+ if (plan.exists) fs28.copyFileSync(plan.path, `${plan.path}.bak`);
5689
+ fs28.writeFileSync(plan.path, `${JSON.stringify(plan.config, null, 2)}
5690
+ `);
5691
+ return plan.path;
5692
+ }
5693
+ function formatInit(plan, written) {
5694
+ const out = [bold("pitroom init") + dim(": worker CLIs on this machine")];
5695
+ 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")}`);
5696
+ if (plan.opencode) {
5697
+ const o = plan.opencode;
5698
+ 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" : ""}` : ""}`);
5699
+ }
5700
+ out.push("", `config file: ${plan.path}${plan.exists ? " (exists)" : " (not present)"}`, JSON.stringify(plan.config, null, 2));
5701
+ for (const n of plan.notes) out.push(` note: ${n}`);
5702
+ if (written) out.push("", green(`\u2714 written to ${written}`), dim(" next: pitroom doctor, then pitroom install"));
5703
+ else if (plan.blocked) out.push("", yellow(`! ${plan.blocked}`));
5704
+ else out.push("", `nothing written yet: pass --yes to write it${plan.exists ? " (with --force, since the file exists)" : ""}`);
5113
5705
  return out.join("\n");
5114
5706
  }
5115
5707
 
5116
- // src/core/dash.ts
5117
- import { spawn as spawn4 } from "node:child_process";
5118
- import crypto8 from "node:crypto";
5708
+ // src/core/mcp-install.ts
5709
+ import { spawnSync as spawnSync7 } from "node:child_process";
5119
5710
  import fs29 from "node:fs";
5120
- import http from "node:http";
5711
+ import os10 from "node:os";
5121
5712
  import path25 from "node:path";
5122
- import { fileURLToPath as fileURLToPath3 } from "node:url";
5123
-
5124
- // src/core/file-diff.ts
5125
- var PREVIEW_LINES = 300;
5126
- function gitPath(value, prefix = true) {
5127
- let path28 = value;
5128
- if (value.startsWith('"') && value.endsWith('"')) {
5129
- const bytes = [];
5130
- const escapes = { t: " ", n: "\n", r: "\r", b: "\b", f: "\f", v: "\v" };
5131
- for (const match of value.slice(1, -1).matchAll(/\\([0-7]{1,3}|.)|([^\\]+)/g)) {
5132
- if (match[1] && /^[0-7]+$/.test(match[1])) bytes.push(parseInt(match[1], 8));
5133
- else bytes.push(...new TextEncoder().encode(match[2] ?? escapes[match[1]] ?? match[1]));
5134
- }
5135
- path28 = new TextDecoder().decode(new Uint8Array(bytes));
5713
+ var CLIENT_IDS = ["claude-code", "codex", "gemini", "cursor", "claude-desktop"];
5714
+ var NAME = "pitroom";
5715
+ function mcpCommand() {
5716
+ const launcher = launcherPath();
5717
+ if (isPitroomLauncher(launcher)) return { command: launcher, args: ["mcp"] };
5718
+ for (const dir of (process.env.PATH ?? "").split(path25.delimiter).filter(Boolean)) {
5719
+ const p = path25.join(dir, process.platform === "win32" ? "pitroom.cmd" : "pitroom");
5720
+ if (fs29.existsSync(p)) return { command: path25.resolve(p), args: ["mcp"] };
5136
5721
  }
5137
- return prefix ? path28.replace(/^[ab]\//, "") : path28;
5722
+ return { command: process.execPath, args: [process.argv[1] ?? "pitroom", "mcp"] };
5138
5723
  }
5139
- function headerPath(header) {
5140
- const quoted = header.match(/^diff --git ("(?:[^"\\]|\\.)*"|\S+) ("(?:[^"\\]|\\.)*"|\S+)$/);
5141
- if (quoted) return gitPath(quoted[2]);
5142
- return gitPath(header.slice(header.lastIndexOf(" b/") + 1));
5724
+ var same = (a, b) => {
5725
+ const o = b;
5726
+ return !!o && o.command === a.command && Array.isArray(o.args) && o.args.join("\0") === a.args.join("\0");
5727
+ };
5728
+ function run(backendId, args) {
5729
+ const { command, prefix } = resolveCommand(getBackend(backendId).binary());
5730
+ const r = spawnSync7(command, [...prefix, ...args], { encoding: "utf8", timeout: 6e4, stdio: ["ignore", "pipe", "pipe"] });
5731
+ return { ok: r.status === 0 && !r.error, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
5143
5732
  }
5144
- function fileDiffs(patch, changes, sourceTruncated = false) {
5145
- const files = [];
5146
- let file2;
5147
- let oldLine = 0;
5148
- let newLine = 0;
5149
- let oldLeft = 0;
5150
- let newLeft = 0;
5151
- let inHunk = false;
5152
- let row = 0;
5153
- const finishHunk = () => {
5154
- if (file2 && (oldLeft > 0 || newLeft > 0)) file2.incomplete = true;
5155
- oldLeft = newLeft = 0;
5156
- inHunk = false;
5733
+ var runnable = (backendId) => run(backendId, ["--version"]).ok;
5734
+ var readJson = (file2) => {
5735
+ try {
5736
+ return JSON.parse(fs29.readFileSync(file2, "utf8"));
5737
+ } catch {
5738
+ return void 0;
5739
+ }
5740
+ };
5741
+ var jsonState = (file2, c) => {
5742
+ const entry = readJson(file2)?.mcpServers?.[NAME];
5743
+ return entry === void 0 ? "absent" : same(c, entry) ? "same" : "different";
5744
+ };
5745
+ var home2 = () => os10.homedir();
5746
+ var geminiDir = () => path25.join(process.env.GEMINI_CLI_HOME ?? home2(), ".gemini");
5747
+ function cliClient(id, backend, name, where, o) {
5748
+ return {
5749
+ id,
5750
+ name,
5751
+ where,
5752
+ found: () => runnable(backend),
5753
+ state: o.state,
5754
+ add: (c) => {
5755
+ const r = run(backend, o.add(c));
5756
+ return { ok: r.ok, message: r.ok ? where : r.out.split("\n")[0]?.trim() || `${name}'s command failed` };
5757
+ },
5758
+ remove: () => {
5759
+ const r = run(backend, o.remove());
5760
+ return { ok: r.ok, message: r.ok ? where : r.out.split("\n")[0]?.trim() || `${name}'s command failed` };
5761
+ }
5157
5762
  };
5158
- const add = (line) => {
5159
- if (!file2) return;
5160
- if (file2.lines.length < PREVIEW_LINES) file2.lines.push({ ...line, id: String(row) });
5161
- else file2.omittedLines++;
5162
- row++;
5763
+ }
5764
+ function jsonClient(id, name, file2, dir) {
5765
+ const rel = () => file2().replace(home2(), "~");
5766
+ return {
5767
+ id,
5768
+ name,
5769
+ found: () => fs29.existsSync(dir()),
5770
+ state: (c) => jsonState(file2(), c),
5771
+ add: (c) => {
5772
+ let config = {};
5773
+ if (fs29.existsSync(file2())) {
5774
+ config = readJson(file2());
5775
+ if (!config || typeof config !== "object" || Array.isArray(config)) return { ok: false, message: `${rel()} is not valid JSON: left alone` };
5776
+ const backup = `${file2()}.bak-pitroom`;
5777
+ if (!fs29.existsSync(backup)) fs29.copyFileSync(file2(), backup);
5778
+ }
5779
+ config.mcpServers = { ...config.mcpServers ?? {}, [NAME]: { command: c.command, args: c.args } };
5780
+ fs29.mkdirSync(path25.dirname(file2()), { recursive: true });
5781
+ const tmp = `${file2()}.${process.pid}.tmp`;
5782
+ fs29.writeFileSync(tmp, `${JSON.stringify(config, null, 2)}
5783
+ `);
5784
+ fs29.renameSync(tmp, file2());
5785
+ return { ok: true, message: rel() };
5786
+ },
5787
+ remove: () => {
5788
+ const config = readJson(file2());
5789
+ if (!config?.mcpServers?.[NAME]) return { ok: true, message: rel() };
5790
+ delete config.mcpServers[NAME];
5791
+ fs29.writeFileSync(file2(), `${JSON.stringify(config, null, 2)}
5792
+ `);
5793
+ return { ok: true, message: rel() };
5794
+ }
5163
5795
  };
5164
- for (const line of patch.split("\n")) {
5165
- if (line.startsWith("diff --git ")) {
5166
- finishHunk();
5167
- file2 = { path: headerPath(line), status: "M", lines: [], additions: 0, deletions: 0, omittedLines: 0, binary: false, incomplete: false };
5168
- files.push(file2);
5169
- row = 0;
5796
+ }
5797
+ function desktopFile() {
5798
+ if (process.platform === "darwin") return path25.join(home2(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
5799
+ if (process.platform === "win32") return path25.join(process.env.APPDATA ?? path25.join(home2(), "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
5800
+ return path25.join(process.env.XDG_CONFIG_HOME ?? path25.join(home2(), ".config"), "Claude", "claude_desktop_config.json");
5801
+ }
5802
+ function codexState(c) {
5803
+ let text;
5804
+ try {
5805
+ text = fs29.readFileSync(path25.join(process.env.CODEX_HOME ?? path25.join(home2(), ".codex"), "config.toml"), "utf8");
5806
+ } catch {
5807
+ return "absent";
5808
+ }
5809
+ const at = text.search(/^\[mcp_servers\.pitroom\]\s*$/m);
5810
+ if (at < 0) return "absent";
5811
+ const table2 = text.slice(at).split(/\n(?=\[)/)[0] ?? "";
5812
+ try {
5813
+ const command = /^command\s*=\s*("(?:[^"\\]|\\.)*")/m.exec(table2)?.[1];
5814
+ const args = /^args\s*=\s*(\[[^\n]*\])/m.exec(table2)?.[1];
5815
+ return command && args && same(c, { command: JSON.parse(command), args: JSON.parse(args) }) ? "same" : "different";
5816
+ } catch {
5817
+ return "different";
5818
+ }
5819
+ }
5820
+ function clients() {
5821
+ return [
5822
+ cliClient("claude-code", "claude", "Claude Code", "user settings (~/.claude.json)", {
5823
+ state: (c) => jsonState(path25.join(home2(), ".claude.json"), c),
5824
+ add: (c) => ["mcp", "add", "--scope", "user", NAME, "--", c.command, ...c.args],
5825
+ remove: () => ["mcp", "remove", NAME, "--scope", "user"]
5826
+ }),
5827
+ cliClient("codex", "codex", "Codex", "~/.codex/config.toml", {
5828
+ state: (c) => codexState(c),
5829
+ add: (c) => ["mcp", "add", NAME, "--", c.command, ...c.args],
5830
+ remove: () => ["mcp", "remove", NAME]
5831
+ }),
5832
+ cliClient("gemini", "gemini", "Gemini CLI", "user settings (~/.gemini/settings.json)", {
5833
+ state: (c) => jsonState(path25.join(geminiDir(), "settings.json"), c),
5834
+ add: (c) => ["mcp", "add", "--scope", "user", NAME, c.command, ...c.args],
5835
+ remove: () => ["mcp", "remove", "--scope", "user", NAME]
5836
+ }),
5837
+ jsonClient("cursor", "Cursor", () => path25.join(home2(), ".cursor", "mcp.json"), () => path25.join(home2(), ".cursor")),
5838
+ jsonClient("claude-desktop", "Claude Desktop", desktopFile, () => path25.dirname(desktopFile()))
5839
+ ];
5840
+ }
5841
+ function attempt2(f) {
5842
+ try {
5843
+ return f();
5844
+ } catch (e) {
5845
+ return { ok: false, message: e.message.split("\n")[0] ?? "failed" };
5846
+ }
5847
+ }
5848
+ function installMcp(opts = {}) {
5849
+ const c = mcpCommand();
5850
+ const out = [];
5851
+ for (const client of clients()) {
5852
+ if (opts.only && !opts.only.includes(client.id)) continue;
5853
+ const r = (state, message) => out.push({ id: client.id, name: client.name, state, message });
5854
+ if (!client.found()) {
5855
+ r("not-found", client.id === "cursor" || client.id === "claude-desktop" ? "not found on this machine" : "not installed (its command does not run)");
5170
5856
  continue;
5171
5857
  }
5172
- if (!file2) continue;
5173
- const hunk = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
5174
- if (hunk) {
5175
- finishHunk();
5176
- oldLine = Number(hunk[1]);
5177
- newLine = Number(hunk[3]);
5178
- oldLeft = Number(hunk[2] ?? 1);
5179
- newLeft = Number(hunk[4] ?? 1);
5180
- inHunk = true;
5181
- add({ type: "hunk", content: line });
5182
- } else if (inHunk && line.startsWith("\")) {
5183
- add({ type: "meta", content: line });
5184
- } else if (inHunk && (oldLeft > 0 || newLeft > 0)) {
5185
- if (line.startsWith("+") && newLeft > 0) {
5186
- file2.additions++;
5187
- newLeft--;
5188
- add({ type: "added", newLine: newLine++, content: line.slice(1) });
5189
- } else if (line.startsWith("-") && oldLeft > 0) {
5190
- file2.deletions++;
5191
- oldLeft--;
5192
- add({ type: "removed", oldLine: oldLine++, content: line.slice(1) });
5193
- } else if (line.startsWith(" ") && oldLeft > 0 && newLeft > 0) {
5194
- oldLeft--;
5195
- newLeft--;
5196
- add({ type: "context", oldLine: oldLine++, newLine: newLine++, content: line.slice(1) });
5197
- } else {
5198
- file2.incomplete = true;
5199
- }
5200
- } else if (!inHunk) {
5201
- if (line.startsWith("+++ ") && line !== "+++ /dev/null") file2.path = gitPath(line.slice(4).replace(/\t$/, ""));
5202
- else if (line.startsWith("--- ") && line !== "--- /dev/null") file2.path = gitPath(line.slice(4).replace(/\t$/, ""));
5203
- else if (/^(GIT binary patch|Binary files .* differ)$/.test(line)) file2.binary = true;
5204
- else if (/^(new file mode|deleted file mode|old mode|new mode|rename from|rename to|similarity index) /.test(line)) {
5205
- if (line.startsWith("new file mode ")) file2.status = "A";
5206
- if (line.startsWith("deleted file mode ")) file2.status = "D";
5207
- if (line.startsWith("rename to ")) {
5208
- file2.path = gitPath(line.slice(10), false);
5209
- file2.status = "R";
5210
- }
5211
- add({ type: "meta", content: line });
5212
- }
5858
+ const now = client.state(c);
5859
+ if (now === "same" && !opts.force) {
5860
+ r("already", "already registered");
5861
+ continue;
5862
+ }
5863
+ if (opts.dryRun) {
5864
+ r("would-add", `would register ${[c.command, ...c.args].join(" ")}`);
5865
+ continue;
5213
5866
  }
5867
+ if (now !== "absent") attempt2(() => client.remove());
5868
+ const done = attempt2(() => client.add(c));
5869
+ r(done.ok ? now === "absent" ? "added" : "updated" : "failed", done.ok ? `${now === "absent" ? "registered" : "updated"} in ${done.message}` : done.message);
5214
5870
  }
5215
- finishHunk();
5216
- if (sourceTruncated && file2) file2.incomplete = true;
5217
- for (const f of files) {
5218
- if (f.incomplete || f.binary) f.additions = f.deletions = void 0;
5871
+ return out;
5872
+ }
5873
+ function uninstallMcp() {
5874
+ const c = mcpCommand();
5875
+ const out = [];
5876
+ for (const client of clients()) {
5877
+ let has2 = false;
5878
+ try {
5879
+ has2 = client.state(c) !== "absent";
5880
+ } catch {
5881
+ has2 = false;
5882
+ }
5883
+ if (!has2) continue;
5884
+ const done = client.found() ? attempt2(() => client.remove()) : { ok: false, message: `its command does not run: remove the pitroom entry from its config by hand` };
5885
+ out.push({ id: client.id, name: client.name, state: done.ok ? "removed" : "failed", message: done.ok ? `removed from ${done.message}` : done.message });
5219
5886
  }
5220
- const byPath = new Map(files.map((f) => [f.path, f]));
5221
- const result = changes.map((change) => {
5222
- const diff = byPath.get(change.path);
5223
- byPath.delete(change.path);
5224
- return diff ? { ...diff, status: change.status } : {
5225
- ...change,
5226
- lines: [],
5227
- omittedLines: 0,
5228
- binary: false,
5229
- incomplete: sourceTruncated,
5230
- unavailable: true
5231
- };
5232
- });
5233
- return [...result, ...byPath.values()];
5887
+ return out;
5234
5888
  }
5235
-
5236
- // src/core/dash-page.ts
5237
- 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){}})()";
5238
- 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";
5239
- var PAGE = `<!doctype html>
5240
- <html lang="en" class="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
5241
- <meta name="color-scheme" content="dark light"><title>Pitroom</title><link rel="icon" href="${ICON3}">
5242
- <script>${THEME_SCRIPT}</script><link rel="stylesheet" href="/assets/app.css"></head>
5243
- <body><div id="root"></div><script type="module" src="/assets/app.js"></script></body></html>`;
5244
- var MISSING = `<!doctype html><meta charset="utf-8"><title>Pitroom</title><body style="font:15px system-ui;padding:3rem;max-width:40rem;margin:auto"><h1>Pitroom dash</h1><p>The dashboard files are missing (dist/ui). Run <code>npm run build</code> in the Pitroom repository, or reinstall the package.</p></body>`;
5245
-
5246
- // src/core/dash.ts
5247
- var DEFAULT_PORT = 7878;
5248
- var WEEK_MS2 = 7 * 24 * 3600 * 1e3;
5249
- var SCAN = 400;
5250
- var RUN_ID3 = /^\d{8}-\d{6}-[0-9a-f]{4}$/;
5251
- var LOCAL_HOST = /^(127\.0\.0\.1|localhost|\[::1\]):\d+$/i;
5252
- var oneLine6 = (s, max) => {
5253
- const t = s.replace(/\s+/g, " ").trim();
5254
- return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
5255
- };
5256
- function findings(m) {
5257
- const v = m.verdict;
5258
- if (!v) return "";
5259
- const parts = [v.critical && `${v.critical} critical`, v.important && `${v.important} important`, v.minor && `${v.minor} minor`].filter(Boolean);
5260
- return parts.length ? parts.join(" \xB7 ") : "no findings";
5889
+ function mcpStatus() {
5890
+ const c = mcpCommand();
5891
+ return clients().map((client) => {
5892
+ try {
5893
+ return { id: client.id, name: client.name, state: client.state(c) };
5894
+ } catch {
5895
+ return { id: client.id, name: client.name, state: "absent" };
5896
+ }
5897
+ });
5261
5898
  }
5262
- function toRun(m) {
5263
- const l = m.state === "running" ? live(m) : void 0;
5264
- return {
5265
- id: m.id,
5266
- state: m.state,
5267
- kind: kind(m),
5268
- // the model that really ran, once the CLI has said which; until then just the worker, never a guess
5269
- worker: workerName(ranTarget(m)),
5270
- task: what(m),
5271
- group: m.group,
5272
- startedAt: m.startedAt,
5273
- time: elapsed(m),
5274
- steps: l ? l.steps : m.usage?.steps ?? 0,
5275
- tokens: m.usage?.total,
5276
- saved: m.savedUsd || void 0,
5277
- verdict: m.verdict ? `SPEC ${m.verdict.spec.toUpperCase()} \xB7 QUALITY ${m.verdict.quality.toUpperCase()}` : m.auditVerdict ? `AUDIT ${m.auditVerdict.toUpperCase()}` : void 0,
5278
- audit: auditBadge(m),
5279
- changes: m.changes?.length || void 0,
5280
- applied: m.applied || void 0,
5281
- note: l?.last ? oneLine6(String(l.last), 140) : isActive(m.state) ? "" : m.verdict ? findings(m) : m.auditOf && m.state === "done" ? m.auditDisputed?.length ? `${m.auditDisputed.length} disputed` : "nothing disputed" : headline(m, 200) || oneLine6(m.error ?? "", 200)
5282
- };
5899
+ var parseClients = (value) => {
5900
+ if (!value) return void 0;
5901
+ const wanted = value.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
5902
+ const bad = wanted.filter((w) => !CLIENT_IDS.includes(w));
5903
+ if (bad.length) throw new Error(`unknown client ${bad.join(", ")} (one of: ${CLIENT_IDS.join(", ")})`);
5904
+ return wanted;
5905
+ };
5906
+
5907
+ // src/core/plan-status.ts
5908
+ import crypto8 from "node:crypto";
5909
+ import fs30 from "node:fs";
5910
+ import path26 from "node:path";
5911
+ function notesFile(plan) {
5912
+ const id = crypto8.createHash("sha1").update(plan.file).digest("hex").slice(0, 12);
5913
+ return path26.join(home(), "plans", id, "notes.md");
5283
5914
  }
5284
- function dashState(opts = {}) {
5285
- const limit = Math.min(Math.max(opts.limit ?? 40, 1), 200);
5915
+ function readNotes(plan) {
5916
+ const f = notesFile(plan);
5917
+ if (!fs30.existsSync(f)) return [];
5918
+ return fs30.readFileSync(f, "utf8").split("\n").slice(1).filter(Boolean).map((l) => {
5919
+ const m = /^(\d{4}-\d\d-\d\d \d\d:\d\d) (.*)$/.exec(l);
5920
+ return m ? { at: m[1], text: m[2] } : { at: "", text: l };
5921
+ });
5922
+ }
5923
+ function addNote(planFile, text) {
5924
+ const plan = loadPlan(planFile);
5925
+ const f = notesFile(plan);
5926
+ fs30.mkdirSync(path26.dirname(f), { recursive: true });
5927
+ if (!fs30.existsSync(f)) fs30.writeFileSync(f, `# plan: ${plan.file}
5928
+ `);
5929
+ const at = (/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace("T", " ");
5930
+ fs30.appendFileSync(f, `${at} ${text.replace(/\s+/g, " ").trim()}
5931
+ `);
5932
+ return f;
5933
+ }
5934
+ function planStatus(planFile) {
5935
+ const plan = loadPlan(planFile);
5286
5936
  const runs = [];
5287
- const groups = /* @__PURE__ */ new Set();
5288
- for (const id of listRunIds().slice(-SCAN).reverse()) {
5289
- let m;
5937
+ for (const id of listRunIds()) {
5290
5938
  try {
5291
- m = freshMeta(id);
5939
+ const m = freshMeta(id);
5940
+ if (m.plan?.file === plan.file) runs.push(m);
5292
5941
  } catch {
5293
- continue;
5294
5942
  }
5295
- if (m.group) groups.add(m.group);
5296
- if (opts.group && m.group !== opts.group) continue;
5297
- if (runs.length < limit) runs.push(toRun(m));
5298
5943
  }
5299
- return {
5300
- price: primaryPrice().name,
5301
- running: runs.filter((r) => r.state === "running" || r.state === "queued").length,
5302
- saved: totals(readLedger(Date.now() - WEEK_MS2)).saved,
5303
- groups: [...groups].sort(),
5304
- runs
5305
- };
5944
+ const notes = readNotes(plan);
5945
+ const tasks = plan.tasks.map((t) => {
5946
+ const impl = runs.filter((m) => m.plan.step === t.step && !m.reviewOf);
5947
+ const rev = runs.filter((m) => m.plan.step === t.step && m.reviewOf).at(-1);
5948
+ const last = impl.at(-1);
5949
+ return {
5950
+ step: t.step,
5951
+ title: t.title,
5952
+ runs: impl.length,
5953
+ last: last && { id: last.id, state: last.state, status: last.taskStatus },
5954
+ review: rev && { id: rev.id, state: rev.state, kind: rev.reviewKind ?? "task", verdict: rev.verdict },
5955
+ applied: impl.some((m) => m.applied || m.mode === "write" && m.state === "done" && !m.reverted && !!m.changes?.length),
5956
+ note: [...notes].reverse().find((n) => n.text.startsWith(`Task ${t.step}:`))?.text
5957
+ };
5958
+ });
5959
+ return { plan, tasks, rulings: notes.filter((n) => /\bRuling:/.test(n.text)), notesFile: notesFile(plan) };
5306
5960
  }
5307
- var TASK_MAX = 6e3;
5308
- var PATCH_LINES = 300;
5309
- function runDetail(id) {
5310
- if (!RUN_ID3.test(id)) return void 0;
5311
- let m;
5961
+ var cell = (s, max) => {
5962
+ const t = s.replace(/\s+/g, " ").trim();
5963
+ return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
5964
+ };
5965
+ function formatPlanStatus(s) {
5966
+ const head = ["TASK", "STATE", "STATUS", "REVIEW", "ROUNDS", "APPLIED", "TITLE", "NOTE"];
5967
+ const rows = s.tasks.map((t) => {
5968
+ const v = t.review?.verdict;
5969
+ const review = v ? `${v.spec}/${v.quality} (c${v.critical} i${v.important} m${v.minor})` : t.review?.state ?? "-";
5970
+ return [
5971
+ String(t.step),
5972
+ t.last?.state ?? "-",
5973
+ t.last?.status ?? "-",
5974
+ review,
5975
+ t.runs ? String(t.runs - 1) : "-",
5976
+ t.runs ? t.applied ? "yes" : "no" : "-",
5977
+ cell(t.title, 32),
5978
+ cell(t.note ?? "", 60)
5979
+ ];
5980
+ });
5981
+ const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
5982
+ const fmt = (r) => r.map((c, i) => i === r.length - 1 ? c : c.padEnd(widths[i])).join(" ").trimEnd();
5983
+ const rulings = s.rulings.length ? s.rulings.map((n) => ` ${n.at} ${n.text}`).join("\n") : " none";
5984
+ return [
5985
+ `plan ${s.plan.file} \xB7 ${s.plan.title || planName(s.plan.file)} \xB7 ${s.tasks.length} tasks`,
5986
+ "",
5987
+ fmt(head),
5988
+ ...rows.map(fmt),
5989
+ "",
5990
+ `Rulings:
5991
+ ${rulings}`,
5992
+ `notes: ${s.notesFile}`
5993
+ ].join("\n");
5994
+ }
5995
+
5996
+ // src/core/cache.ts
5997
+ import crypto9 from "node:crypto";
5998
+ import fs31 from "node:fs";
5999
+ import path27 from "node:path";
6000
+ var SCAN2 = 500;
6001
+ function cacheKey(o) {
6002
+ if (o.verify || o.mode !== "read" || o.continueFrom || o.web || o.plan || o.review || o.audit || o.auditRate === 1) return void 0;
6003
+ if (!(effective().cacheDays.value > 0)) return void 0;
6004
+ const dir = path27.resolve(o.dir);
6005
+ const root = repoRoot(dir);
6006
+ if (!root) return void 0;
5312
6007
  try {
5313
- m = freshMeta(id);
6008
+ const tree = snapshotTree(root);
6009
+ const head = commitOf(root, "HEAD") ?? "";
6010
+ const files = o.files.map((f) => {
6011
+ const abs = path27.resolve(f);
6012
+ return [abs, crypto9.createHash("sha256").update(fs31.readFileSync(abs)).digest("hex")];
6013
+ });
6014
+ const question = o.task.replace(/\s+/g, " ").trim();
6015
+ const readIn2 = READ_IN.includes(effective().readIn.value) ? effective().readIn.value : "auto";
6016
+ const where = !o.inPlace && wantSnapshot(readIn2, root, dir) ? "snapshot" : "project";
6017
+ const key = crypto9.createHash("sha256").update(JSON.stringify([question, path27.relative(root, dir), files, where])).digest("hex");
6018
+ return { key, state: `${head}:${tree}` };
5314
6019
  } catch {
5315
6020
  return void 0;
5316
6021
  }
5317
- const start = Date.parse(m.startedAt);
5318
- const kept = isActive(m.state) ? void 0 : archivedRun(id);
5319
- let steps = kept?.steps ?? [];
5320
- if (!steps.length) try {
5321
- const events = readRunFile(id, "events.jsonl");
5322
- steps = (events ? getBackend((m.ran ?? m.worker).backend).parse(events).timeline ?? [] : []).map((s) => ({
5323
- ...s,
5324
- t: s.at && Number.isFinite(start) ? Math.max(0, Math.round((s.at - start) / 1e3)) : void 0
5325
- }));
5326
- } catch {
5327
- }
5328
- const rel = (t) => [m.cwd, m.dir].filter(Boolean).reduce((x, base2) => x.split(`${base2}/`).join(""), t);
5329
- steps = steps.map((x) => ({ ...x, text: rel(x.text) }));
5330
- const answer = isActive(m.state) ? "" : readSummary(m).replace(/^SUMMARY:\s*SPEC[^\n]*\n+/i, "").replace(/^DETAILS:[ \t]*\n+/i, "").trim();
5331
- const lastSay = steps.at(-1);
5332
- if (lastSay?.kind === "say" && answer && (answer.includes(lastSay.text.slice(0, 80)) || lastSay.text.includes(answer.slice(0, 80)))) steps.pop();
5333
- const fullPatch = readRunFile(id, "changes.patch");
5334
- const patch = fullPatch ?? kept?.patch ?? "";
5335
- const patchLines = patch.split("\n");
5336
- const u = m.usage;
5337
- return {
5338
- id,
5339
- state: m.state,
5340
- card: toRun(m),
5341
- task: (m.auditOf ? `Audit of ${m.auditOf}` : 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),
5342
- steps,
5343
- answer,
5344
- changes: m.changes ?? [],
5345
- fileDiffs: fileDiffs(patch, m.changes ?? [], fullPatch === void 0 && kept?.patchTruncated),
5346
- patch: patch ? `${patchLines.slice(0, PATCH_LINES).join("\n")}${patchLines.length > PATCH_LINES ? `
5347
- \u2026 ${patchLines.length - PATCH_LINES} more lines (pitroom show ${id} --patch)` : ""}` : void 0,
5348
- info: {
5349
- worker: (m.ran ?? m.worker).backend,
5350
- // the model has its own line
5351
- model: m.resolvedModel,
5352
- mode: m.mode,
5353
- started: m.startedAt,
5354
- time: elapsed(m),
5355
- steps: u?.steps,
5356
- toolCalls: u?.toolCalls,
5357
- tokens: u?.total,
5358
- returnedTokens: m.returnedTokens,
5359
- cost: u?.cost,
5360
- saved: m.savedUsd,
5361
- group: m.group,
5362
- directory: m.dir
5363
- },
5364
- attempts: m.attempts ?? [],
5365
- warnings: m.warnings ?? [],
5366
- refs: m.refs ? { valid: m.refs.valid, total: m.refs.total, invalid: m.refs.invalid.map((r) => `${r.ref} (${r.reason})`) } : void 0,
5367
- verify: m.verifyResult && m.verify ? { command: m.verify, ok: m.verifyResult.ok, tail: m.verifyResult.tail } : void 0,
5368
- error: m.error,
5369
- audit: m.auditOf ? m.state === "done" ? { state: m.state, verdict: m.auditVerdict, disputed: m.auditDisputed ?? [] } : void 0 : m.audit && { id: m.audit.id, state: m.audit.state, verdict: m.audit.verdict, disputed: m.audit.disputed ?? [] },
5370
- report: isActive(m.state) ? progress(m) : formatReport(m, void 0, 120)
5371
- };
5372
6022
  }
5373
- var HEADERS = {
5374
- "cache-control": "no-store",
5375
- "x-content-type-options": "nosniff",
5376
- "referrer-policy": "no-referrer",
5377
- // Own files only, plus the hash of the one inline theme script; styles may be inline (the components position popups with them).
5378
- "content-security-policy": `default-src 'none'; script-src 'self' 'sha256-${crypto8.createHash("sha256").update(THEME_SCRIPT).digest("base64")}'; style-src 'self' 'unsafe-inline'; img-src data:; connect-src 'self'; base-uri 'none'; form-action 'none'`
5379
- };
5380
- var ASSETS = { "/assets/app.js": "text/javascript; charset=utf-8", "/assets/app.css": "text/css; charset=utf-8" };
5381
- var assetsDir = () => fileURLToPath3(new URL("./ui/", import.meta.url));
5382
- var assetCache = /* @__PURE__ */ new Map();
5383
- function readAsset(route) {
6023
+ function heldUp(m) {
6024
+ if (m.state !== "done" || m.mode !== "read" || m.reviewOf || m.auditOf) return false;
6025
+ if (m.refs && m.refs.invalid.length) return false;
6026
+ if (m.warnings.some((w) => w.startsWith("READ-ONLY VIOLATION"))) return false;
6027
+ if (m.audit && (isActive(m.audit.state) || m.audit.verdict === "disagree" || m.audit.verdict === "partial")) return false;
5384
6028
  try {
5385
- const file2 = path25.join(assetsDir(), path25.basename(route));
5386
- const { mtimeMs } = fs29.statSync(file2);
5387
- const hit = assetCache.get(route);
5388
- if (hit && hit.mtimeMs === mtimeMs) return hit.data;
5389
- const data = fs29.readFileSync(file2);
5390
- assetCache.set(route, { mtimeMs, data });
5391
- return data;
6029
+ return readSummary(m).trim() !== "";
5392
6030
  } catch {
5393
- return void 0;
6031
+ return false;
5394
6032
  }
5395
6033
  }
5396
- function handler(touch2) {
5397
- const send2 = (res, code, type, body) => {
5398
- res.writeHead(code, { ...HEADERS, "content-type": type });
5399
- res.end(body);
5400
- };
5401
- const json = (res, code, value) => send2(res, code, "application/json; charset=utf-8", JSON.stringify(value));
5402
- return (req, res) => {
5403
- touch2();
5404
- if (!LOCAL_HOST.test(req.headers.host ?? "")) return json(res, 403, { error: "forbidden host" });
5405
- if (req.method !== "GET" && req.method !== "HEAD") return json(res, 405, { error: "read-only" });
5406
- const url = new URL(req.url ?? "/", "http://127.0.0.1");
5407
- if (url.pathname === "/") return send2(res, 200, "text/html; charset=utf-8", readAsset("/assets/app.js") ? PAGE : MISSING);
5408
- const type = ASSETS[url.pathname];
5409
- if (type) {
5410
- const data = readAsset(url.pathname);
5411
- if (!data) return json(res, 404, { error: "dashboard files missing" });
5412
- res.writeHead(200, { ...HEADERS, "content-type": type });
5413
- return void res.end(req.method === "HEAD" ? void 0 : data);
5414
- }
5415
- if (url.pathname === "/api/state") {
5416
- const group = url.searchParams.get("group") ?? void 0;
5417
- const limit = Number(url.searchParams.get("limit") ?? 40);
5418
- return json(res, 200, dashState({ group: group && group.length <= 100 ? group : void 0, limit: Number.isFinite(limit) ? limit : 40 }));
5419
- }
5420
- if (url.pathname === "/api/history") {
5421
- const q = url.searchParams;
5422
- const days = Number(q.get("days") ?? 0);
5423
- return json(res, 200, listHistory({
5424
- text: (q.get("q") ?? "").slice(0, 200),
5425
- model: q.get("model") || void 0,
5426
- backend: q.get("backend") || void 0,
5427
- state: q.get("state") || void 0,
5428
- group: q.get("group") || void 0,
5429
- sinceMs: days > 0 ? Date.now() - days * 864e5 : void 0,
5430
- beforeId: RUN_ID3.test(q.get("before") ?? "") ? q.get("before") : void 0,
5431
- limit: Number(q.get("limit") ?? 30) || 30
5432
- }));
5433
- }
5434
- if (url.pathname === "/api/stats") {
5435
- const days = Number(url.searchParams.get("days") ?? 30);
5436
- return json(res, 200, { ...historyStats(days > 0 ? Date.now() - days * 864e5 : void 0), price: primaryPrice().name });
5437
- }
5438
- const m = /^\/api\/run\/([^/]+)$/.exec(url.pathname);
5439
- if (m) {
5440
- const d = runDetail(m[1]);
5441
- return d ? json(res, 200, d) : json(res, 404, { error: "no such run" });
6034
+ function findCached(k, now = Date.now()) {
6035
+ const maxAge = effective().cacheDays.value * 864e5;
6036
+ let best;
6037
+ for (const id of listRunIds().slice(-SCAN2)) {
6038
+ let m;
6039
+ try {
6040
+ m = readMeta(id);
6041
+ } catch {
6042
+ continue;
5442
6043
  }
5443
- return json(res, 404, { error: "not found" });
5444
- };
6044
+ if (m.cache?.key !== k.key || m.cache.state !== k.state) continue;
6045
+ const at = Date.parse(m.endedAt ?? m.startedAt);
6046
+ if (now - at > maxAge || best && at <= best.at || !heldUp(m)) continue;
6047
+ best = { m, at };
6048
+ }
6049
+ return best?.m;
5445
6050
  }
5446
- function startDash(opts) {
5447
- let lastRequest = Date.now();
5448
- const server = http.createServer(handler(() => lastRequest = Date.now()));
5449
- return new Promise((resolve2, reject) => {
5450
- server.once("error", reject);
5451
- server.listen(opts.port, "127.0.0.1", () => {
5452
- const port = server.address().port;
5453
- let done;
5454
- const closed = new Promise((r) => done = r);
5455
- let timer;
5456
- const close = () => {
5457
- if (timer) clearInterval(timer);
5458
- server.closeAllConnections?.();
5459
- return new Promise((r) => server.close(() => (done(), r())));
5460
- };
5461
- if (opts.idleMs && opts.idleMs > 0) {
5462
- timer = setInterval(() => Date.now() - lastRequest > opts.idleMs && void close(), Math.min(6e4, opts.idleMs));
5463
- timer.unref();
5464
- }
5465
- resolve2({ port, url: `http://127.0.0.1:${port}/`, closed, close });
5466
- });
5467
- });
6051
+ function ago(iso, now = Date.now()) {
6052
+ const s = Math.max(0, (now - Date.parse(iso)) / 1e3);
6053
+ if (s < 3600) return `${Math.max(1, Math.round(s / 60))} min ago`;
6054
+ if (s < 86400) return `${Math.round(s / 3600)} h ago`;
6055
+ return `${Math.round(s / 86400)} d ago`;
5468
6056
  }
5469
- var registryFile = () => path25.join(home(), "dash.json");
5470
- async function ping(port) {
5471
- try {
5472
- const r = await fetch(`http://127.0.0.1:${port}/api/state?limit=1`, { signal: AbortSignal.timeout(1500) });
5473
- return r.ok;
5474
- } catch {
5475
- return false;
6057
+ var cachedNote = (m) => `pitroom \u27F2 cached answer \xB7 the same question on the same code as run ${m.id} (${ago(m.endedAt ?? m.startedAt)}) \xB7 no worker ran \xB7 --fresh asks one again`;
6058
+
6059
+ // src/core/review.ts
6060
+ import crypto10 from "node:crypto";
6061
+ import fs32 from "node:fs";
6062
+ import path28 from "node:path";
6063
+ var TEMPLATE2 = { task: "task-reviewer", fix: "re-review", range: "code-reviewer" };
6064
+ var section = (title, body) => `## ${title}
6065
+
6066
+ ${body.trim() || "(empty)"}
6067
+ `;
6068
+ function ancestors(m) {
6069
+ const out = [];
6070
+ for (let id = m.parent; id; ) {
6071
+ const a = readMeta(id);
6072
+ out.push(a);
6073
+ id = a.parent;
5476
6074
  }
6075
+ return out;
5477
6076
  }
5478
- async function runningDash() {
5479
- try {
5480
- const r = JSON.parse(fs29.readFileSync(registryFile(), "utf8"));
5481
- if (isAlive(r.pid) && await ping(r.port)) return { ...r, url: `http://127.0.0.1:${r.port}/` };
5482
- } catch {
6077
+ function briefOf(m) {
6078
+ const root = [m, ...ancestors(m)].at(-1);
6079
+ const f = runFile(root.id, "brief.md");
6080
+ return fs32.existsSync(f) ? fs32.readFileSync(f, "utf8") : root.task;
6081
+ }
6082
+ function latestReview(ids) {
6083
+ for (const id of listRunIds().reverse()) {
6084
+ try {
6085
+ const m = readMeta(id);
6086
+ if (m.reviewOf && ids.includes(m.reviewOf) && m.state === "done") return m;
6087
+ } catch {
6088
+ }
5483
6089
  }
5484
- fs29.rmSync(registryFile(), { force: true });
5485
6090
  return void 0;
5486
6091
  }
5487
- function openBrowser(url) {
5488
- const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
5489
- try {
5490
- spawn4(cmd, args, { stdio: "ignore", detached: true }).on("error", () => void 0).unref();
5491
- } catch {
6092
+ function diffOf(m, from = m.baseTree) {
6093
+ const where = m.mode === "isolate" ? m.worktree : m.repoRoot;
6094
+ if (where && fs32.existsSync(where) && from && m.afterTree) return reviewDiff(where, from, m.afterTree);
6095
+ if (from !== m.baseTree) {
6096
+ 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);
5492
6097
  }
6098
+ return readRunFile(m.id, "changes.patch") ?? archivedRun(m.id)?.patch ?? "";
5493
6099
  }
5494
- async function dashCommand(o) {
5495
- const existing = await runningDash();
5496
- if (o.stop) {
5497
- if (!existing) throw new UserError("pitroom dash is not running");
5498
- process.kill(existing.pid, "SIGTERM");
5499
- fs29.rmSync(registryFile(), { force: true });
5500
- o.log(`stopped pitroom dash (${existing.url})`);
5501
- return 0;
5502
- }
5503
- if (existing && !o.serve) {
5504
- o.log(existing.url);
5505
- if (o.open) openBrowser(existing.url);
5506
- return 0;
6100
+ function runReview(id) {
6101
+ const m = freshMeta(id);
6102
+ if (isActive(m.state)) throw new UserError(`run ${m.id} is still ${m.state}; pitroom wait ${m.id} first`, 3);
6103
+ if (m.reviewOf) throw new UserError(`run ${m.id} is itself a review`);
6104
+ if (m.mode === "read") throw new UserError(`run ${m.id} was read-only; there is no change to review`);
6105
+ if (!m.changes?.length) throw new UserError(`run ${m.id} made no changes; nothing to review`);
6106
+ const chain = ancestors(m);
6107
+ const previous = latestReview(chain.map((a) => a.id));
6108
+ const dir = m.mode === "isolate" && m.worktree && fs32.existsSync(m.worktree) ? m.cwd : m.dir;
6109
+ const job = { of: m.id, dir, implementer: m.ran ?? m.worker, group: m.group, plan: m.plan };
6110
+ const title = m.plan ? `Task ${m.plan.step}: ${m.plan.title}` : `run ${m.id}`;
6111
+ if (!previous) {
6112
+ return {
6113
+ ...job,
6114
+ kind: "task",
6115
+ package: [`# Review package \xB7 ${title}
6116
+ `, section("BRIEF", briefOf(m)), section("REPORT", readSummary(m)), section("DIFF", diffOf(m))].join("\n")
6117
+ };
5507
6118
  }
5508
- if (o.detach) {
5509
- const child = spawn4(process.execPath, [process.argv[1], "dash", "--serve", ...o.port !== void 0 ? ["--port", String(o.port)] : [], "--idle", "1h"], {
5510
- detached: true,
5511
- stdio: "ignore",
5512
- env: process.env
5513
- });
5514
- child.on("error", () => void 0);
5515
- child.unref();
5516
- for (let i = 0; i < 60; i++) {
5517
- await new Promise((r2) => setTimeout(r2, 100));
5518
- const r = await runningDash();
5519
- if (r && r.pid === child.pid) {
5520
- o.log(r.url);
5521
- if (o.open) openBrowser(r.url);
5522
- return 0;
5523
- }
6119
+ const reviewed = chain.find((a) => a.id === previous.reviewOf);
6120
+ const from = m.mode === "isolate" ? reviewed.afterTree : m.baseTree;
6121
+ return {
6122
+ ...job,
6123
+ kind: "fix",
6124
+ package: [
6125
+ `# Re-review package \xB7 ${title} \xB7 fix round after review ${previous.id}
6126
+ `,
6127
+ section("BRIEF", briefOf(m)),
6128
+ section("PREVIOUS FINDINGS", readSummary(previous)),
6129
+ section("FIX REPORT", readSummary(m)),
6130
+ section("FIX DIFF", diffOf(m, from))
6131
+ ].join("\n")
6132
+ };
6133
+ }
6134
+ function rangeReview(range, dir, planFile) {
6135
+ const root = repoRoot(dir);
6136
+ if (!root) throw new UserError("--range needs a git repository");
6137
+ const i = range.indexOf("..");
6138
+ const a = i > 0 ? range.slice(0, i) : "";
6139
+ const b = i > 0 ? range.slice(i + 2) || "HEAD" : "";
6140
+ if (!a || b.startsWith(".")) throw new UserError(`--range takes A..B (e.g. main..HEAD), not "${range}"`);
6141
+ for (const ref of [a, b]) if (!commitOf(root, ref)) throw new UserError(`not a commit: ${ref}`);
6142
+ const plan = planFile ? loadPlan(planFile) : void 0;
6143
+ const notes = plan ? readNotes(plan) : [];
6144
+ const requirements = plan ? [
6145
+ `Plan: ${plan.file}`,
6146
+ "### Global Constraints",
6147
+ plan.constraints || "(none stated in the plan)",
6148
+ "### Tasks",
6149
+ plan.tasks.map((t) => `- Task ${t.step}: ${t.title}`).join("\n")
6150
+ ].join("\n\n") : "(none given; judge the change on its own terms)";
6151
+ return {
6152
+ kind: "range",
6153
+ of: `${a}..${b}`,
6154
+ dir: root,
6155
+ group: plan ? planName(plan.file) : void 0,
6156
+ package: [
6157
+ `# Review package \xB7 ${a}..${b}
6158
+ `,
6159
+ section("WHAT WAS IMPLEMENTED", plan ? `${plan.title}
6160
+
6161
+ ${plan.header}` : "The commits below."),
6162
+ section("REQUIREMENTS", requirements),
6163
+ ...notes.length ? [section("NOTES FROM EXECUTION", notes.map((n) => `- ${n.text}`).join("\n"))] : [],
6164
+ rangeDiff(root, a, b)
6165
+ ].join("\n")
6166
+ };
6167
+ }
6168
+ function pickReviewer(job) {
6169
+ const eff = effective();
6170
+ const tiers = eff.tiers.value;
6171
+ if (job.kind === "range") return tiers.capable;
6172
+ if (tiers.review) return tiers.review;
6173
+ const implementer = job.implementer;
6174
+ if (!implementer) return void 0;
6175
+ const def = parseTarget(eff.worker.value, DEFAULT_BACKEND).backend;
6176
+ const candidates = [tiers.standard, tiers.capable, ...eff.fallback.value, eff.worker.value].filter((s) => !!s);
6177
+ return candidates.find((c) => parseTarget(c, def).backend !== implementer.backend);
6178
+ }
6179
+ function writePackage(job) {
6180
+ const g = gitDir(job.dir);
6181
+ const root = repoRoot(job.dir);
6182
+ if (!g || !root) throw new UserError(`not a git repository: ${job.dir}`);
6183
+ const name = `review-${crypto10.randomBytes(4).toString("hex")}.md`;
6184
+ const inside2 = !path28.relative(fs32.realpathSync(root), fs32.realpathSync(g)).startsWith("..");
6185
+ const file2 = inside2 ? path28.join(g, "pitroom", name) : path28.join(root, ".pitroom", name);
6186
+ if (!inside2) ignoreInGit(root, "/.pitroom/");
6187
+ fs32.mkdirSync(path28.dirname(file2), { recursive: true });
6188
+ fs32.writeFileSync(file2, job.package);
6189
+ return file2;
6190
+ }
6191
+ function ignoreInGit(root, pattern) {
6192
+ const exclude = path28.resolve(root, git(root, ["rev-parse", "--git-path", "info/exclude"]).stdout.trim());
6193
+ const text = fs32.existsSync(exclude) ? fs32.readFileSync(exclude, "utf8") : "";
6194
+ if (text.split(/\r?\n/).includes(pattern)) return;
6195
+ fs32.mkdirSync(path28.dirname(exclude), { recursive: true });
6196
+ fs32.appendFileSync(exclude, `${text && !text.endsWith("\n") ? "\n" : ""}# Pitroom review packages in worktrees (removed after each review)
6197
+ ${pattern}
6198
+ `);
6199
+ }
6200
+
6201
+ // src/core/models.ts
6202
+ var base = (model) => (model ?? "").split("#")[0];
6203
+ function usage() {
6204
+ const eff = effective();
6205
+ const out = /* @__PURE__ */ new Map();
6206
+ const add = (spec, label) => {
6207
+ let t;
6208
+ try {
6209
+ t = parseTarget(spec, "opencode");
6210
+ } catch {
6211
+ return;
5524
6212
  }
5525
- throw new UserError("pitroom dash did not start (is the port taken? try --port 0)");
6213
+ const model = base(t.model) || base(eff.models.value[t.backend]);
6214
+ if (!model) return;
6215
+ const key = `${t.backend}:${model}`;
6216
+ out.set(key, [...out.get(key) ?? [], label]);
6217
+ };
6218
+ add(eff.worker.value, "default worker");
6219
+ for (const [name, spec] of Object.entries(eff.tiers.value)) add(spec, `tier ${name}`);
6220
+ eff.fallback.value.forEach((spec) => add(spec, "fallback"));
6221
+ for (const [backend, model] of Object.entries(eff.models.value)) add(`${backend}:${model}`, "models");
6222
+ return out;
6223
+ }
6224
+ function modelTable(opts = {}) {
6225
+ const costs = effective().costs.value;
6226
+ const used = usage();
6227
+ const seen = /* @__PURE__ */ new Map();
6228
+ for (const e of readLedger()) {
6229
+ if (!e.backend || !e.model) continue;
6230
+ const key = `${e.backend}:${base(e.model)}`;
6231
+ const a = seen.get(key) ?? { runs: 0, tokens: 0, usd: 0 };
6232
+ seen.set(key, { runs: a.runs + 1, tokens: a.tokens + e.tokens, usd: a.usd + e.workerCost });
5526
6233
  }
5527
- importRuns();
5528
- const explicit = o.port !== void 0;
5529
- let dash;
5530
- try {
5531
- dash = await startDash({ port: o.port ?? DEFAULT_PORT, idleMs: o.idleMs });
5532
- } catch (e) {
5533
- if (explicit || e.code !== "EADDRINUSE") throw new UserError(`pitroom dash: ${e.message}`);
5534
- dash = await startDash({ port: 0, idleMs: o.idleMs });
6234
+ const rows = [];
6235
+ const sources = [];
6236
+ let hiddenOpenCode = 0;
6237
+ const known = /* @__PURE__ */ new Set();
6238
+ for (const b of allBackends()) {
6239
+ if (opts.backend && b.id !== opts.backend) continue;
6240
+ const catalog5 = b.catalog?.() ?? { models: [], source: "no model list for this worker" };
6241
+ sources.push(`${b.id}: ${catalog5.source}`);
6242
+ for (const m of catalog5.models) {
6243
+ const key = `${b.id}:${m.id}`;
6244
+ known.add(key);
6245
+ const s = seen.get(key);
6246
+ const row = {
6247
+ worker: b.id,
6248
+ model: m.id,
6249
+ efforts: m.efforts?.length ? m.efforts : void 0,
6250
+ defaultEffort: m.defaultEffort,
6251
+ cost: costs[key],
6252
+ runs: s?.runs ?? 0,
6253
+ avgTokens: s ? Math.round(s.tokens / s.runs) : void 0,
6254
+ reportedUsdPerRun: s && s.usd > 0 ? s.usd / s.runs : void 0,
6255
+ inUse: used.get(key) ?? []
6256
+ };
6257
+ const relevant = row.inUse.length || row.runs || row.cost !== void 0;
6258
+ if (b.id === "opencode" && !opts.all && !relevant) hiddenOpenCode++;
6259
+ else rows.push(row);
6260
+ }
5535
6261
  }
5536
- fs29.mkdirSync(home(), { recursive: true });
5537
- fs29.writeFileSync(registryFile(), JSON.stringify({ pid: process.pid, port: dash.port }));
5538
- const stop = () => void dash.close();
5539
- process.once("SIGINT", stop);
5540
- process.once("SIGTERM", stop);
5541
- if (!o.serve) o.log(`${dash.url}
5542
- (read-only, this machine only; Ctrl-C stops it)`);
5543
- if (o.open) openBrowser(dash.url);
5544
- await dash.closed;
5545
- try {
5546
- if (JSON.parse(fs29.readFileSync(registryFile(), "utf8")).pid === process.pid) fs29.rmSync(registryFile(), { force: true });
5547
- } catch {
6262
+ for (const [key, labels] of used) {
6263
+ const [worker, ...rest] = key.split(":");
6264
+ if (known.has(key) || opts.backend && worker !== opts.backend) continue;
6265
+ const s = seen.get(key);
6266
+ rows.push({ worker, model: rest.join(":"), cost: costs[key], runs: s?.runs ?? 0, avgTokens: s ? Math.round(s.tokens / s.runs) : void 0, inUse: labels });
5548
6267
  }
5549
- return 0;
6268
+ rows.sort(
6269
+ (a, b) => a.worker.localeCompare(b.worker) || Number(b.inUse.length > 0) - Number(a.inUse.length > 0) || (a.cost ?? Infinity) - (b.cost ?? Infinity) || b.runs - a.runs || a.model.localeCompare(b.model)
6270
+ );
6271
+ return { rows, sources, hiddenOpenCode };
6272
+ }
6273
+ var compact2 = (n) => n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n);
6274
+ function formatModels(t) {
6275
+ const efforts = (r) => r.efforts ? r.efforts.map((e) => e === r.defaultEffort ? `${e}*` : e).join("/") : "-";
6276
+ const cells = t.rows.map((r) => [
6277
+ r.worker,
6278
+ r.model,
6279
+ efforts(r),
6280
+ r.cost !== void 0 ? String(r.cost) : "?",
6281
+ r.runs ? String(r.runs) : "-",
6282
+ r.avgTokens !== void 0 ? compact2(r.avgTokens) : "-",
6283
+ r.reportedUsdPerRun !== void 0 ? `$${r.reportedUsdPerRun.toFixed(3)}` : "-",
6284
+ r.inUse.join(", ") || "-"
6285
+ ]);
6286
+ const head = ["worker", "model", "effort (* default)", "cost", "runs", "avg tokens", "$/run", "in use"];
6287
+ const widths = head.map((h, i) => Math.max(h.length, ...cells.map((c) => c[i].length)));
6288
+ const line = (c) => c.map((x, i) => x.padEnd(widths[i])).join(" ").trimEnd();
6289
+ const out = [line(head), ...cells.map(line), ""];
6290
+ out.push('cost: your relative cost from the config ("costs": {"codex:gpt-6-sol": 1, \u2026}); ? = not set. Pitroom cannot know vendor prices.');
6291
+ out.push("runs, avg tokens, $/run: from your own runs (only Claude Code and OpenCode report dollars). Choose one with -W worker:model --effort LEVEL.");
6292
+ if (t.hiddenOpenCode) out.push(`${t.hiddenOpenCode} more OpenCode models not shown (use --all).`);
6293
+ out.push(...t.sources.map((s) => `source ${s}`));
6294
+ return out.join("\n");
5550
6295
  }
5551
6296
 
5552
6297
  // src/cli/commands.ts
@@ -5568,7 +6313,15 @@ warning: ${w}`).join("")
5568
6313
  async function cmdRun(p) {
5569
6314
  const opts = { ...runOptions(p, readTask(p)), plan: planStep(p) };
5570
6315
  if (!opts.task.trim() && !opts.plan) throw new UserError('no task given (pitroom "find where X is handled")');
5571
- return launch(p, prepareRun(opts));
6316
+ const cache = cacheKey(opts);
6317
+ const hit = cache && !has(p, "fresh") ? findCached(cache) : void 0;
6318
+ if (hit) {
6319
+ console.log(has(p, "json") ? JSON.stringify({ ...hit, cached: true }, null, 2) : `${cachedNote(hit)}
6320
+
6321
+ ${formatReport(hit)}`);
6322
+ return exitCodeFor(hit);
6323
+ }
6324
+ return launch(p, prepareRun({ ...opts, cache }));
5572
6325
  }
5573
6326
  async function cmdReview(p) {
5574
6327
  const range = flag(p, "range");
@@ -5596,10 +6349,10 @@ async function cmdReview(p) {
5596
6349
  writeMeta(meta);
5597
6350
  }
5598
6351
  } catch (e) {
5599
- fs30.rmSync(packageFile, { force: true });
6352
+ fs33.rmSync(packageFile, { force: true });
5600
6353
  throw e;
5601
6354
  }
5602
- fs30.writeFileSync(runFile(meta.id, "package.md"), job.package);
6355
+ fs33.writeFileSync(runFile(meta.id, "package.md"), job.package);
5603
6356
  return launch(p, meta);
5604
6357
  }
5605
6358
  async function cmdAudit(p) {
@@ -5667,7 +6420,7 @@ function cmdPlan(p) {
5667
6420
  }
5668
6421
  function cmdCrew(p) {
5669
6422
  const file2 = flag(p, "task-file");
5670
- const tasks = (file2 ? fs30.readFileSync(file2, "utf8").split(/^\s*---\s*$/m) : p.positional).map((t) => t.trim()).filter(Boolean);
6423
+ const tasks = (file2 ? fs33.readFileSync(file2, "utf8").split(/^\s*---\s*$/m) : p.positional).map((t) => t.trim()).filter(Boolean);
5671
6424
  if (!tasks.length) throw new UserError('crew needs tasks: pitroom crew -g NAME "task 1" "task 2" (or --task-file with --- separators)');
5672
6425
  if (has(p, "write") && tasks.length > 1) {
5673
6426
  throw new UserError("parallel --write runs would edit the same tree; use --isolate (each worker gets its own isolated copy)");
@@ -5770,9 +6523,9 @@ function cmdHistory(p) {
5770
6523
  return 0;
5771
6524
  }
5772
6525
  importRuns();
5773
- const since = sinceMs(flag(p, "since"));
6526
+ const since2 = sinceMs(flag(p, "since"));
5774
6527
  if (sub === "stats") {
5775
- const s = historyStats(since);
6528
+ const s = historyStats(since2);
5776
6529
  if (has(p, "json")) return console.log(JSON.stringify(s, null, 2)), 0;
5777
6530
  const t = s.totals;
5778
6531
  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`);
@@ -5785,10 +6538,10 @@ ${[fmt2(head2), ...rows2.map(fmt2)].join("\n")}`);
5785
6538
  return 0;
5786
6539
  }
5787
6540
  const limit = flag(p, "limit") ? Number(flag(p, "limit")) : 20;
5788
- const { rows, total } = listHistory({ text: p.positional.join(" "), model: flag(p, "model"), state: flag(p, "state"), group: flag(p, "group"), sinceMs: since, limit });
6541
+ const { rows, total } = listHistory({ text: p.positional.join(" "), model: flag(p, "model"), state: flag(p, "state"), group: flag(p, "group"), sinceMs: since2, limit });
5789
6542
  if (has(p, "json")) return console.log(JSON.stringify({ total, rows }, null, 2)), 0;
5790
6543
  if (!rows.length) return console.log(total ? "nothing on this page" : "no matching runs"), 0;
5791
- 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]);
6544
+ const body = rows.map((r) => [r.id, when(r.startedAt), r.verifyFailed ? "verify failed" : 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]);
5792
6545
  const head = ["RUN", "WHEN", "STATE", "WORKER", "TIME", "TASK"];
5793
6546
  const widths = head.map((h, i) => Math.max(h.length, ...body.map((r) => r[i].length)));
5794
6547
  const fmt = (r, header = false) => r.map((c, i) => {
@@ -5870,7 +6623,7 @@ function sinceMs(s) {
5870
6623
  if (!m) throw new UserError("--since takes 7d, 30d, \u2026 or all");
5871
6624
  return Date.now() - Number(m[1]) * 864e5;
5872
6625
  }
5873
- var readStdin = () => process.stdin.isTTY ? "" : fs30.readFileSync(0, "utf8");
6626
+ var readStdin = () => process.stdin.isTTY ? "" : fs33.readFileSync(0, "utf8");
5874
6627
  function cmdStatusline(p) {
5875
6628
  const input = readStdin();
5876
6629
  const lines = [];
@@ -5910,8 +6663,8 @@ function cmdModels(p) {
5910
6663
  return 0;
5911
6664
  }
5912
6665
  function cmdHookStart() {
5913
- const script = path26.join(path26.dirname(path26.dirname(fileURLToPath4(import.meta.url))), "hooks", "session-start.mjs");
5914
- if (fs30.existsSync(script)) spawnSync8(process.execPath, [script], { stdio: ["ignore", "inherit", "ignore"], env: process.env });
6666
+ const script = path29.join(path29.dirname(path29.dirname(fileURLToPath4(import.meta.url))), "hooks", "session-start.mjs");
6667
+ if (fs33.existsSync(script)) spawnSync8(process.execPath, [script], { stdio: ["ignore", "inherit", "ignore"], env: process.env });
5915
6668
  return 0;
5916
6669
  }
5917
6670
  function cmdInit(p) {
@@ -5922,11 +6675,11 @@ function cmdInit(p) {
5922
6675
  return 0;
5923
6676
  }
5924
6677
  function cmdSavings(p) {
5925
- const since = flag(p, "since") ?? "all";
5926
- const t = totals(readLedger(sinceMs(since)));
6678
+ const since2 = flag(p, "since") ?? "all";
6679
+ const t = totals(readLedger(sinceMs(since2)));
5927
6680
  if (has(p, "models")) {
5928
6681
  const by = /* @__PURE__ */ new Map();
5929
- for (const e of readLedger(sinceMs(since))) {
6682
+ for (const e of readLedger(sinceMs(since2))) {
5930
6683
  const key = `${e.backend ?? "?"} ${e.model ?? "(default model)"}`;
5931
6684
  const a = by.get(key) ?? { runs: 0, tokens: 0, returned: 0, cost: 0, saved: 0 };
5932
6685
  by.set(key, { runs: a.runs + 1, tokens: a.tokens + e.tokens, returned: a.returned + e.returned, cost: a.cost + e.workerCost, saved: a.saved + e.saved });
@@ -5938,7 +6691,7 @@ function cmdSavings(p) {
5938
6691
  }
5939
6692
  return 0;
5940
6693
  }
5941
- const period = since === "all" ? "all time" : `last ${since.replace("d", " days")}`;
6694
+ const period = since2 === "all" ? "all time" : `last ${since2.replace("d", " days")}`;
5942
6695
  if (has(p, "json")) {
5943
6696
  console.log(JSON.stringify({ period, ...t, price: primaryPrice() }, null, 2));
5944
6697
  return 0;
@@ -5951,8 +6704,8 @@ function cmdSavings(p) {
5951
6704
  est. saved ${usd(t.saved)} (vs ${primaryPrice().name} list prices)`);
5952
6705
  const out = flag(p, "card");
5953
6706
  if (out) {
5954
- fs30.writeFileSync(out, card(t, period));
5955
- console.log(`card written to ${path26.resolve(out)}`);
6707
+ fs33.writeFileSync(out, card(t, period));
6708
+ console.log(`card written to ${path29.resolve(out)}`);
5956
6709
  }
5957
6710
  if (has(p, "badge")) console.log(`![pitroom](${badgeUrl(t)})`);
5958
6711
  return 0;
@@ -5994,7 +6747,7 @@ function cmdConfig(p) {
5994
6747
  console.log(JSON.stringify({ path: configPath(), settings: eff, warnings }, null, 2));
5995
6748
  return 0;
5996
6749
  }
5997
- console.log(`config file: ${configPath()}${fs30.existsSync(configPath()) ? "" : " (not present)"}`);
6750
+ console.log(`config file: ${configPath()}${fs33.existsSync(configPath()) ? "" : " (not present)"}`);
5998
6751
  for (const [key, s] of Object.entries(eff)) {
5999
6752
  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);
6000
6753
  console.log(` ${key.padEnd(15)} ${v} (${s.source})`);
@@ -6003,22 +6756,22 @@ function cmdConfig(p) {
6003
6756
  return 0;
6004
6757
  }
6005
6758
  function cmdClean(p) {
6006
- const days = Number(flag(p, "days") ?? 14);
6007
- const cutoff = Date.now() - days * 864e5;
6759
+ const days2 = Number(flag(p, "days") ?? 14);
6760
+ const cutoff = Date.now() - days2 * 864e5;
6008
6761
  const old = listRunIds().filter((id) => {
6009
6762
  const m = freshMeta(id);
6010
6763
  const pendingPatch = m.mode === "isolate" && !m.applied && !m.discarded && m.changes?.length;
6011
6764
  return TERMINAL.includes(m.state) && Date.parse(m.startedAt) < cutoff && !pendingPatch;
6012
6765
  });
6013
6766
  if (!has(p, "yes")) {
6014
- console.log(`${old.length} run(s) older than ${days} days would be removed (unapplied isolate patches are kept). Re-run with --yes.`);
6767
+ console.log(`${old.length} run(s) older than ${days2} days would be removed (unapplied isolate patches are kept). Re-run with --yes.`);
6015
6768
  return 0;
6016
6769
  }
6017
6770
  for (const id of old) {
6018
6771
  const m = readMeta(id);
6019
6772
  if (m.mode === "isolate" && !m.discarded && !m.applied) discardRun(m);
6020
6773
  recordRun(m);
6021
- fs30.rmSync(runDir(id), { recursive: true, force: true });
6774
+ fs33.rmSync(runDir(id), { recursive: true, force: true });
6022
6775
  }
6023
6776
  const snapshots = prune(Date.now(), 0);
6024
6777
  console.log(`removed ${old.length} run(s)${snapshots ? ` and ${snapshots} read snapshot${snapshots === 1 ? "" : "s"}` : ""}; the history and the savings ledger are kept`);
@@ -6029,9 +6782,9 @@ var cmdDiscard = (p) => (console.log(discardRun(freshMeta(resolveRun(p.positiona
6029
6782
 
6030
6783
  // src/core/doctor.ts
6031
6784
  import { spawnSync as spawnSync9 } from "node:child_process";
6032
- import fs31 from "node:fs";
6785
+ import fs34 from "node:fs";
6033
6786
  import os11 from "node:os";
6034
- import path27 from "node:path";
6787
+ import path30 from "node:path";
6035
6788
  var MARK = { ok: () => green("\u2714"), warn: () => yellow("!"), fail: () => red("\u2718") };
6036
6789
  var NEXT = [
6037
6790
  { when: /no default model|no fallback workers/, command: "pitroom init", why: "propose a starter config (fallback models from your catalogue)" },
@@ -6041,10 +6794,10 @@ var NEXT = [
6041
6794
  { when: /Gemini CLI is not signed in|IneligibleTierError/, command: "export GEMINI_API_KEY=\u2026", why: "a Google AI Studio key for the Gemini worker" }
6042
6795
  ];
6043
6796
  function firstNodeOnPath() {
6044
- for (const dir of (process.env.PATH ?? "").split(path27.delimiter).filter(Boolean)) {
6045
- const file2 = path27.join(dir, process.platform === "win32" ? "node.exe" : "node");
6797
+ for (const dir of (process.env.PATH ?? "").split(path30.delimiter).filter(Boolean)) {
6798
+ const file2 = path30.join(dir, process.platform === "win32" ? "node.exe" : "node");
6046
6799
  try {
6047
- if (!fs31.statSync(file2).isFile()) continue;
6800
+ if (!fs34.statSync(file2).isFile()) continue;
6048
6801
  } catch {
6049
6802
  continue;
6050
6803
  }
@@ -6071,12 +6824,12 @@ function doctor5(probe) {
6071
6824
  const addAll = (list2) => list2.forEach((c) => add(c.level, c.message));
6072
6825
  add("ok", `pitroom ${VERSION2} \xB7 node ${process.versions.node} \xB7 state in ${home()}`);
6073
6826
  const onPath = firstNodeOnPath();
6074
- if (onPath && onPath.version && tooOld(onPath.version) && path27.resolve(onPath.path) !== path27.resolve(process.execPath)) {
6827
+ if (onPath && onPath.version && tooOld(onPath.version) && path30.resolve(onPath.path) !== path30.resolve(process.execPath)) {
6075
6828
  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)`);
6076
6829
  }
6077
6830
  add(gitAvailable() ? "ok" : "warn", gitAvailable() ? "git available" : "git not found: --write/--isolate tracking disabled");
6078
6831
  const cfg = loadConfig();
6079
- add(cfg.warnings.length ? "warn" : "ok", `config: ${configPath()}${fs31.existsSync(configPath()) ? "" : " (not present, defaults in use)"}`);
6832
+ add(cfg.warnings.length ? "warn" : "ok", `config: ${configPath()}${fs34.existsSync(configPath()) ? "" : " (not present, defaults in use)"}`);
6080
6833
  for (const w of cfg.warnings) add("warn", w);
6081
6834
  if (process.platform !== "win32") {
6082
6835
  const guarded = guardEnv(process.env).PATH?.startsWith(shimDir());
@@ -6229,19 +6982,19 @@ function skillChecks() {
6229
6982
  else if (!viaPlugin) checks.push({ level: "warn", message: `no Pitroom skills in ${base2}; run \`pitroom install\`` });
6230
6983
  }
6231
6984
  if (geminiExtensionInstalled()) {
6232
- const linked = installedSkills().some((i) => i.base.includes(`${path27.sep}.agents${path27.sep}`) && i.names.length);
6985
+ const linked = installedSkills().some((i) => i.base.includes(`${path30.sep}.agents${path30.sep}`) && i.names.length);
6233
6986
  checks.push(
6234
6987
  linked ? { level: "warn", message: "Pitroom is installed as a Gemini CLI extension and linked into ~/.agents/skills: Gemini loads the skills twice; run `pitroom uninstall` or `gemini extensions uninstall pitroom`" } : { level: "ok", message: "Gemini CLI extension installed (skills + context)" }
6235
6988
  );
6236
6989
  }
6237
6990
  if (viaPlugin) {
6238
- const linked = installedSkills().some((i) => i.base.includes(`${path27.sep}.claude${path27.sep}`) && i.names.length);
6991
+ const linked = installedSkills().some((i) => i.base.includes(`${path30.sep}.claude${path30.sep}`) && i.names.length);
6239
6992
  checks.push(
6240
6993
  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)" }
6241
6994
  );
6242
6995
  }
6243
6996
  const launcher = launcherPath();
6244
- if (fs31.existsSync(launcher)) {
6997
+ if (fs34.existsSync(launcher)) {
6245
6998
  const r = spawnSync9(launcher, ["--version"], { encoding: "utf8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"] });
6246
6999
  checks.push(
6247
7000
  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)}` }
@@ -6253,12 +7006,12 @@ function skillChecks() {
6253
7006
  }
6254
7007
  function geminiExtensionInstalled() {
6255
7008
  const home3 = process.env.GEMINI_CLI_HOME ?? os11.homedir();
6256
- return fs31.existsSync(path27.join(home3, ".gemini", "extensions", "pitroom"));
7009
+ return fs34.existsSync(path30.join(home3, ".gemini", "extensions", "pitroom"));
6257
7010
  }
6258
7011
  function pluginInstalled() {
6259
7012
  try {
6260
- const f = path27.join(os11.homedir(), ".claude", "plugins", "installed_plugins.json");
6261
- const plugins = JSON.parse(fs31.readFileSync(f, "utf8")).plugins ?? {};
7013
+ const f = path30.join(os11.homedir(), ".claude", "plugins", "installed_plugins.json");
7014
+ const plugins = JSON.parse(fs34.readFileSync(f, "utf8")).plugins ?? {};
6262
7015
  return Object.keys(plugins).some((k) => k.startsWith("pitroom@"));
6263
7016
  } catch {
6264
7017
  return false;
@@ -6275,24 +7028,24 @@ function superpowersActive() {
6275
7028
  for (const p of openCodePlugins()) {
6276
7029
  if (/superpowers/i.test(p)) found.push(`OpenCode plugin ${p}`);
6277
7030
  }
6278
- for (const base2 of [path27.join(os11.homedir(), ".agents", "skills"), path27.join(os11.homedir(), ".claude", "skills")]) {
6279
- const dir = path27.join(base2, "using-superpowers");
6280
- if (fs31.existsSync(path27.join(dir, "SKILL.md"))) found.push(dir);
7031
+ for (const base2 of [path30.join(os11.homedir(), ".agents", "skills"), path30.join(os11.homedir(), ".claude", "skills")]) {
7032
+ const dir = path30.join(base2, "using-superpowers");
7033
+ if (fs34.existsSync(path30.join(dir, "SKILL.md"))) found.push(dir);
6281
7034
  }
6282
7035
  return found;
6283
7036
  }
6284
7037
  function claudePlugins() {
6285
7038
  try {
6286
- const f = path27.join(os11.homedir(), ".claude", "plugins", "installed_plugins.json");
6287
- return Object.keys(JSON.parse(fs31.readFileSync(f, "utf8")).plugins ?? {});
7039
+ const f = path30.join(os11.homedir(), ".claude", "plugins", "installed_plugins.json");
7040
+ return Object.keys(JSON.parse(fs34.readFileSync(f, "utf8")).plugins ?? {});
6288
7041
  } catch {
6289
7042
  return [];
6290
7043
  }
6291
7044
  }
6292
7045
  function claudePluginEnabled(key) {
6293
7046
  try {
6294
- const f = path27.join(os11.homedir(), ".claude", "settings.json");
6295
- return JSON.parse(fs31.readFileSync(f, "utf8")).enabledPlugins?.[key] !== false;
7047
+ const f = path30.join(os11.homedir(), ".claude", "settings.json");
7048
+ return JSON.parse(fs34.readFileSync(f, "utf8")).enabledPlugins?.[key] !== false;
6296
7049
  } catch {
6297
7050
  return true;
6298
7051
  }
@@ -6301,7 +7054,7 @@ function codexPlugins() {
6301
7054
  const plugins = /* @__PURE__ */ new Map();
6302
7055
  let text;
6303
7056
  try {
6304
- text = fs31.readFileSync(path27.join(process.env.CODEX_HOME ?? path27.join(os11.homedir(), ".codex"), "config.toml"), "utf8");
7057
+ text = fs34.readFileSync(path30.join(process.env.CODEX_HOME ?? path30.join(os11.homedir(), ".codex"), "config.toml"), "utf8");
6305
7058
  } catch {
6306
7059
  return plugins;
6307
7060
  }
@@ -6319,11 +7072,11 @@ function codexPlugins() {
6319
7072
  return plugins;
6320
7073
  }
6321
7074
  function openCodePlugins() {
6322
- const dir = path27.join(process.env.XDG_CONFIG_HOME ?? path27.join(os11.homedir(), ".config"), "opencode");
7075
+ const dir = path30.join(process.env.XDG_CONFIG_HOME ?? path30.join(os11.homedir(), ".config"), "opencode");
6323
7076
  const plugins = [];
6324
7077
  for (const name of ["opencode.json", "opencode.jsonc"]) {
6325
7078
  try {
6326
- const text = fs31.readFileSync(path27.join(dir, name), "utf8").replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, "");
7079
+ const text = fs34.readFileSync(path30.join(dir, name), "utf8").replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, "");
6327
7080
  const list2 = JSON.parse(text).plugin;
6328
7081
  if (Array.isArray(list2)) plugins.push(...list2.filter((p) => typeof p === "string"));
6329
7082
  } catch {
@@ -6331,7 +7084,7 @@ function openCodePlugins() {
6331
7084
  }
6332
7085
  for (const folder of ["plugin", "plugins"]) {
6333
7086
  try {
6334
- for (const f of fs31.readdirSync(path27.join(dir, folder))) plugins.push(path27.join(dir, folder, f));
7087
+ for (const f of fs34.readdirSync(path30.join(dir, folder))) plugins.push(path30.join(dir, folder, f));
6335
7088
  } catch {
6336
7089
  }
6337
7090
  }
@@ -6383,7 +7136,9 @@ Usage
6383
7136
  pitroom hook-start SessionStart hook: introduces Pitroom to the agent (Codex; Claude Code uses its plugin)
6384
7137
  pitroom cooldown [--clear] models that said "rate limited" and are skipped for a while (a quota used up);
6385
7138
  --clear tries them again
6386
- pitroom mcp serve Pitroom as MCP tools on stdio (Cursor, Claude Desktop, Gemini CLI, \u2026)
7139
+ pitroom mcp [-d DIR] [--http [--port N]]
7140
+ serve Pitroom as MCP tools on stdio (Cursor, Claude Desktop, Gemini CLI, \u2026),
7141
+ or with --http at http://127.0.0.1:7117/mcp (bearer token, local only)
6387
7142
  pitroom doctor [--probe] check workers, models, permissions, skills
6388
7143
  pitroom config effective settings, where each comes from, config file path
6389
7144
  pitroom init [--model ID] [--fallback A,B] [--yes] [--force]
@@ -6409,6 +7164,7 @@ Run options
6409
7164
  --in-place a read run reads the directory itself, not a clean snapshot (default: a snapshot without secret-looking
6410
7165
  files when the directory has any; config "readIn": auto | snapshot | project)
6411
7166
  --audit have another worker re-check this read run's answer; --no-audit skips it (default: the config's "audit")
7167
+ --fresh ask a worker even when the same read question was answered on the same code (config "cacheDays", default 7)
6412
7168
  --effort LEVEL reasoning effort for the worker: low, medium, high, xhigh, \u2026 (model#level)
6413
7169
  --plan PLAN with --step N: implement Task N of a plan (-i or -w); the task text is your notes
6414
7170
  --step N the plan task for --plan
@@ -6430,16 +7186,27 @@ Exit codes: 0 ok \xB7 1 worker failed \xB7 2 usage \xB7 3 refused/setup \xB7 4 t
6430
7186
  5 read-only violation \xB7 6 verify failed \xB7 75 still running (wait again)
6431
7187
  Workers: ${backendIds().join(", ")} (targets: "opencode", "opencode:provider/model", or a bare model)
6432
7188
  Env: PITROOM_WORKER, PITROOM_MODEL, PITROOM_FALLBACK="t1,t2", PITROOM_TIMEOUT, PITROOM_MAX_PARALLEL,
6433
- PITROOM_PRIMARY=sonnet|opus|haiku|gpt-5, PITROOM_PRICE="in,out", PITROOM_HOME, PITROOM_CONFIG,
7189
+ PITROOM_PRIMARY=sonnet|opus|haiku|gpt-5, PITROOM_PRICE="in,out", PITROOM_HOME, PITROOM_CONFIG, PITROOM_CACHE_DAYS,
6434
7190
  PITROOM_<WORKER>_BIN
6435
- Config: ~/.config/pitroom/config.json (worker, fallback, models, tiers, timeout, primary, price, link, web, maxParallel)`;
7191
+ Config: ~/.config/pitroom/config.json (worker, fallback, models, tiers, timeout, primary, price, link, web, maxParallel, audit, readIn, cacheDays)`;
6436
7192
  var COMMANDS = {
6437
7193
  run: cmdRun,
6438
7194
  crew: cmdCrew,
6439
7195
  review: cmdReview,
6440
7196
  audit: cmdAudit,
6441
7197
  cooldown: cmdCooldown,
6442
- mcp: () => serveMcp(),
7198
+ mcp: (p) => {
7199
+ const port = flag(p, "port");
7200
+ if (port !== void 0 && !has(p, "http")) throw new UserError("--port goes with --http (on stdio there is no port)");
7201
+ if (port !== void 0 && !(/^\d+$/.test(port) && Number(port) <= 65535)) throw new UserError("--port takes a number from 0 to 65535 (0 picks a free one)");
7202
+ const dir = flag(p, "dir");
7203
+ if (dir !== void 0) {
7204
+ if (!fs35.statSync(dir, { throwIfNoEntry: false })?.isDirectory()) throw new UserError(`-d ${dir}: no such directory`);
7205
+ process.chdir(dir);
7206
+ process.env.PWD = process.cwd();
7207
+ }
7208
+ return has(p, "http") ? serveMcpHttp({ port: port === void 0 ? DEFAULT_PORT2 : Number(port) }) : serveMcp();
7209
+ },
6443
7210
  plan: cmdPlan,
6444
7211
  status: cmdStatus,
6445
7212
  wait: cmdWait,