pitroom 0.14.1 → 0.16.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 fs33 from "node:fs";
5
+
3
6
  // src/core/errors.ts
4
7
  var UserError = class extends Error {
5
8
  constructor(message, code = 2) {
@@ -262,10 +265,10 @@ function classify(message) {
262
265
  if (/model[^.]*(not found|does not exist|invalid|not available)|invalid model|not_found_error/i.test(message)) return "model-unavailable";
263
266
  return "other";
264
267
  }
265
- function failure(run, stderr, exitCode) {
266
- if (exitCode === 0 && !run.error) return void 0;
268
+ function failure(run2, stderr, exitCode) {
269
+ if (exitCode === 0 && !run2.error) return void 0;
267
270
  const detail = stderr.trim().split("\n").filter(Boolean).pop();
268
- const message = run.error ?? detail ?? `claude exited with code ${exitCode}`;
271
+ const message = run2.error ?? detail ?? `claude exited with code ${exitCode}`;
269
272
  return { kind: classify(message), message };
270
273
  }
271
274
  function defaultModel() {
@@ -476,10 +479,10 @@ function classify2(message) {
476
479
  if (/unauthori[sz]ed|\b40[13]\b|not logged in|log ?in|api.?key|credits?\b|billing|subscription/i.test(message)) return "auth";
477
480
  return "other";
478
481
  }
479
- function failure2(run, stderr, exitCode) {
480
- if (exitCode === 0 && !run.error) return void 0;
482
+ function failure2(run2, stderr, exitCode) {
483
+ if (exitCode === 0 && !run2.error) return void 0;
481
484
  const detail = stderr.split("\n").find((l) => /^Error[: ]|ERROR/.test(l) && !/rmcp|models cache/.test(l));
482
- let message = run.error ?? (detail ? detail.trim() : `codex exited with code ${exitCode}`);
485
+ let message = run2.error ?? (detail ? detail.trim() : `codex exited with code ${exitCode}`);
483
486
  if (/not supported when using Codex/i.test(message)) message += " (an outdated Codex CLI says this too: npm i -g @openai/codex@latest)";
484
487
  return { kind: classify2(message), message };
485
488
  }
@@ -838,11 +841,11 @@ function classify3(message) {
838
841
  return "other";
839
842
  }
840
843
  var UNTRUSTED = "Gemini CLI does not trust this folder: open `gemini` in it once and trust it, or set PITROOM_GEMINI_TRUST=1 (the folder's own Gemini settings, hooks included, then load)";
841
- function failure3(run, stderr, exitCode) {
842
- if (exitCode === 0 && !run.error) return void 0;
844
+ function failure3(run2, stderr, exitCode) {
845
+ if (exitCode === 0 && !run2.error) return void 0;
843
846
  const lines = stderr.trim().split("\n").map((l) => l.trim()).filter(Boolean);
844
847
  const detail = lines.find((l) => /error|failed|exceeded|quota|exhausted|unsupported/i.test(l) && !/^at /.test(l)) ?? lines.at(-1);
845
- const raw = run.error ?? detail ?? `gemini exited with code ${exitCode}`;
848
+ const raw = run2.error ?? detail ?? `gemini exited with code ${exitCode}`;
846
849
  const message = /not running in a trusted directory/i.test(`${stderr} ${raw}`) ? UNTRUSTED : raw;
847
850
  return { kind: classify3(message), message };
848
851
  }
@@ -1283,10 +1286,10 @@ function classify4(message) {
1283
1286
  }
1284
1287
  return "other";
1285
1288
  }
1286
- function failure4(run, stderr, exitCode) {
1287
- if (exitCode === 0 && !run.error) return void 0;
1289
+ function failure4(run2, stderr, exitCode) {
1290
+ if (exitCode === 0 && !run2.error) return void 0;
1288
1291
  const detail = stderr.match(/error="([^"]+)"/g)?.pop()?.slice(7, -1);
1289
- const raw = run.error ?? `opencode exited with code ${exitCode}`;
1292
+ const raw = run2.error ?? `opencode exited with code ${exitCode}`;
1290
1293
  const message = detail && !raw.includes(detail) ? `${raw}: ${detail}` : raw;
1291
1294
  return { kind: classify4(message), message };
1292
1295
  }
@@ -1538,7 +1541,8 @@ var VALUE_FLAGS = {
1538
1541
  "--limit": "limit",
1539
1542
  "--plan": "plan",
1540
1543
  "--step": "step",
1541
- "--fallback": "fallback"
1544
+ "--fallback": "fallback",
1545
+ "--client": "client"
1542
1546
  };
1543
1547
  var BOOL_FLAGS = {
1544
1548
  "-r": "read",
@@ -1548,6 +1552,9 @@ var BOOL_FLAGS = {
1548
1552
  "-i": "isolate",
1549
1553
  "--isolate": "isolate",
1550
1554
  "--bg": "bg",
1555
+ "--mcp": "mcp",
1556
+ "--no-skills": "no-skills",
1557
+ "--dry-run": "dry-run",
1551
1558
  "--in-place": "in-place",
1552
1559
  "--audit": "audit",
1553
1560
  "--no-audit": "no-audit",
@@ -1574,6 +1581,7 @@ var BOOL_FLAGS = {
1574
1581
  "--stop": "stop",
1575
1582
  "--open": "open",
1576
1583
  "--serve": "serve",
1584
+ "--http": "http",
1577
1585
  "-h": "help",
1578
1586
  "--help": "help",
1579
1587
  "-v": "version",
@@ -1676,7 +1684,6 @@ function effortFlag(p) {
1676
1684
  }
1677
1685
 
1678
1686
  // src/cli/mcp.ts
1679
- import { spawn as spawn3 } from "node:child_process";
1680
1687
  import readline from "node:readline";
1681
1688
 
1682
1689
  // src/core/run.ts
@@ -2499,9 +2506,9 @@ function skillNames(root = packageRoot()) {
2499
2506
  return fs16.readdirSync(dir).filter((d) => fs16.existsSync(path14.join(dir, d, "SKILL.md"))).sort();
2500
2507
  }
2501
2508
  function skillTargets() {
2502
- const home2 = os9.homedir();
2503
- const targets = [path14.join(home2, ".agents", "skills")];
2504
- if (fs16.existsSync(path14.join(home2, ".claude"))) targets.push(path14.join(home2, ".claude", "skills"));
2509
+ const home3 = os9.homedir();
2510
+ const targets = [path14.join(home3, ".agents", "skills")];
2511
+ if (fs16.existsSync(path14.join(home3, ".claude"))) targets.push(path14.join(home3, ".claude", "skills"));
2505
2512
  return targets;
2506
2513
  }
2507
2514
  var launcherPath = () => path14.join(os9.homedir(), ".local", "bin", "pitroom");
@@ -2528,6 +2535,13 @@ function isOurLauncher(file2, root) {
2528
2535
  return false;
2529
2536
  }
2530
2537
  }
2538
+ function isPitroomLauncher(file2) {
2539
+ try {
2540
+ return fs16.readFileSync(file2, "utf8").includes(LAUNCHER_MARK);
2541
+ } catch {
2542
+ return false;
2543
+ }
2544
+ }
2531
2545
  function placeLauncher(bundle, root, force) {
2532
2546
  const dest = launcherPath();
2533
2547
  fs16.mkdirSync(path14.dirname(dest), { recursive: true });
@@ -2563,10 +2577,10 @@ function place(src, dest, opts) {
2563
2577
  }
2564
2578
  function install(opts) {
2565
2579
  const root = packageRoot();
2566
- const skills = skillNames(root);
2567
- if (!skills.length) throw new Error(`no skills found under ${path14.join(root, "skills")}`);
2580
+ const skills = opts.skills === false ? [] : skillNames(root);
2581
+ if (opts.skills !== false && !skills.length) throw new Error(`no skills found under ${path14.join(root, "skills")}`);
2568
2582
  const out = [];
2569
- for (const base2 of skillTargets()) {
2583
+ for (const base2 of opts.skills === false ? [] : skillTargets()) {
2570
2584
  for (const legacy of LEGACY) {
2571
2585
  const l = path14.join(base2, legacy);
2572
2586
  if (!skills.includes(legacy) && linksInto(l, root)) {
@@ -3118,12 +3132,12 @@ function hookCards(input) {
3118
3132
  const output = typeof response === "string" ? response : [response?.stdout, response?.stderr].filter(Boolean).join("\n");
3119
3133
  const ids = [...new Set(`${command}
3120
3134
  ${output}`.match(RUN_ID) ?? [])].filter((id) => fs21.existsSync(`${runsDir()}/${id}`));
3121
- const since = Date.now() - RECENT_HOURS * 36e5;
3135
+ const since2 = Date.now() - RECENT_HOURS * 36e5;
3122
3136
  for (const id of listRunIds().slice(-RECENT)) {
3123
3137
  if (ids.includes(id) || fs21.existsSync(runFile(id, "card-ended"))) continue;
3124
3138
  try {
3125
3139
  const m = readMeta(id);
3126
- if (!isActive(m.state) && Date.parse(m.endedAt ?? m.startedAt) > since) ids.push(id);
3140
+ if (!isActive(m.state) && Date.parse(m.endedAt ?? m.startedAt) > since2) ids.push(id);
3127
3141
  } catch {
3128
3142
  }
3129
3143
  }
@@ -3397,17 +3411,17 @@ function listHistory(q = {}) {
3397
3411
  }
3398
3412
  }
3399
3413
  var NOT_AUDIT = "json_extract(meta_json, '$.auditOf') IS NULL";
3400
- function auditStats(db, since) {
3414
+ function auditStats(db, since2) {
3401
3415
  const total = { runs: 0, agree: 0, partial: 0, disagree: 0, unclear: 0, tokens: 0 };
3402
3416
  const byWorker = /* @__PURE__ */ new Map();
3403
3417
  try {
3404
- 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);
3418
+ 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);
3405
3419
  total.runs = t.runs;
3406
3420
  total.tokens = t.tokens;
3407
3421
  const rows = db.prepare(`SELECT r.backend, r.model, json_extract(a.meta_json, '$.auditVerdict') v, COUNT(*) n
3408
3422
  FROM runs a JOIN runs r ON r.id = json_extract(a.meta_json, '$.auditOf')
3409
3423
  WHERE a.started_at >= ? AND a.state = 'done' AND json_extract(a.meta_json, '$.auditOf') IS NOT NULL
3410
- GROUP BY r.backend, r.model, v`).all(since);
3424
+ GROUP BY r.backend, r.model, v`).all(since2);
3411
3425
  for (const r of rows) {
3412
3426
  const v = r.v ?? "unclear";
3413
3427
  if (v in total && v !== "runs" && v !== "tokens") total[v] += r.n;
@@ -3445,12 +3459,12 @@ function historyStats(sinceMs2) {
3445
3459
  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: [] };
3446
3460
  const db = openDb();
3447
3461
  if (!db) return empty;
3448
- const since = sinceMs2 ? new Date(sinceMs2).toISOString() : "";
3462
+ const since2 = sinceMs2 ? new Date(sinceMs2).toISOString() : "";
3449
3463
  try {
3450
- 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);
3451
- 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);
3452
- 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);
3453
- const audits = auditStats(db, since);
3464
+ 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);
3465
+ 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);
3466
+ 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);
3467
+ const audits = auditStats(db, since2);
3454
3468
  const ledger = readLedger(sinceMs2);
3455
3469
  const byDay = /* @__PURE__ */ new Map();
3456
3470
  for (const e of ledger) byDay.set(e.at.slice(0, 10), (byDay.get(e.at.slice(0, 10)) ?? 0) + e.saved);
@@ -3620,7 +3634,7 @@ function progress(meta) {
3620
3634
  }
3621
3635
 
3622
3636
  // src/core/run.ts
3623
- var VERSION2 = true ? "0.14.1" : "0.0.0-dev";
3637
+ var VERSION2 = true ? "0.16.0" : "0.0.0-dev";
3624
3638
  function planWork(o) {
3625
3639
  if (!o.plan) return { task: o.task.trim(), tier: o.tier };
3626
3640
  if (o.continueFrom) throw new UserError("a follow-up continues its parent's task; drop --plan/--step");
@@ -3916,9 +3930,9 @@ function attempt(meta, backend, target) {
3916
3930
  }
3917
3931
  var read2 = (f) => fs24.existsSync(f) ? fs24.readFileSync(f, "utf8") : "";
3918
3932
  function retryableFailure(meta, backend, res) {
3919
- const run = backend.parse(read2(runFile(meta.id, "events.jsonl")));
3920
- if (run.usage.steps > 0) return void 0;
3921
- const f = backend.failure(run, read2(runFile(meta.id, "stderr.log")), res.code);
3933
+ const run2 = backend.parse(read2(runFile(meta.id, "events.jsonl")));
3934
+ if (run2.usage.steps > 0) return void 0;
3935
+ const f = backend.failure(run2, read2(runFile(meta.id, "stderr.log")), res.code);
3922
3936
  return f && f.kind !== "other" ? f : void 0;
3923
3937
  }
3924
3938
  var HINTS = {
@@ -3930,19 +3944,19 @@ var HINTS = {
3930
3944
  function finalize(meta, res) {
3931
3945
  const ran = meta.ran ?? meta.worker;
3932
3946
  const backend = getBackend(ran.backend);
3933
- const run = backend.parse(read2(runFile(meta.id, "events.jsonl")));
3934
- if (meta.snapshot?.dir && meta.repoRoot) run.finalText = backToProject(run.finalText, meta.snapshot.dir, meta.repoRoot);
3935
- meta.sessionId = run.sessionId ?? meta.sessionId;
3936
- meta.usage = run.usage;
3937
- fs24.writeFileSync(runFile(meta.id, "summary.md"), `${run.finalText}
3947
+ const run2 = backend.parse(read2(runFile(meta.id, "events.jsonl")));
3948
+ if (meta.snapshot?.dir && meta.repoRoot) run2.finalText = backToProject(run2.finalText, meta.snapshot.dir, meta.repoRoot);
3949
+ meta.sessionId = run2.sessionId ?? meta.sessionId;
3950
+ meta.usage = run2.usage;
3951
+ fs24.writeFileSync(runFile(meta.id, "summary.md"), `${run2.finalText}
3938
3952
  `);
3939
- if (meta.plan && !meta.reviewOf) meta.taskStatus = parseStatus(run.finalText);
3953
+ if (meta.plan && !meta.reviewOf) meta.taskStatus = parseStatus(run2.finalText);
3940
3954
  if (meta.reviewOf) {
3941
- meta.verdict = parseVerdict(run.finalText);
3955
+ meta.verdict = parseVerdict(run2.finalText);
3942
3956
  if (meta.packageFile) fs24.rmSync(meta.packageFile, { force: true });
3943
3957
  }
3944
3958
  if (!res.timedOut && !res.stopped && !meta.error) {
3945
- const f = backend.failure(run, read2(runFile(meta.id, "stderr.log")), res.code);
3959
+ const f = backend.failure(run2, read2(runFile(meta.id, "stderr.log")), res.code);
3946
3960
  if (f) meta.error = HINTS[f.kind] ? `${f.message} (${HINTS[f.kind]})` : f.message;
3947
3961
  }
3948
3962
  try {
@@ -3950,12 +3964,12 @@ function finalize(meta, res) {
3950
3964
  } catch (e) {
3951
3965
  meta.warnings.push(`could not compute changes: ${e.message}`);
3952
3966
  }
3953
- if (meta.mode === "read" && run.edits.length) {
3954
- meta.warnings.push(`READ-ONLY VIOLATION: worker modified ${[...new Set(run.edits)].join(", ")}`);
3967
+ if (meta.mode === "read" && run2.edits.length) {
3968
+ meta.warnings.push(`READ-ONLY VIOLATION: worker modified ${[...new Set(run2.edits)].join(", ")}`);
3955
3969
  }
3956
3970
  if (meta.sessionId && backend.resolveModel) meta.resolvedModel = backend.resolveModel(meta.sessionId);
3957
- meta.resolvedModel ??= run.model ?? ran.model;
3958
- const refs = extractRefs(run.finalText);
3971
+ meta.resolvedModel ??= run2.model ?? ran.model;
3972
+ const refs = extractRefs(run2.finalText);
3959
3973
  if (refs.length) {
3960
3974
  meta.refs = verifyRefs(refs, [meta.cwd, meta.repoRoot ?? "", meta.dir]);
3961
3975
  if (meta.refs.invalid.length) {
@@ -3964,14 +3978,14 @@ function finalize(meta, res) {
3964
3978
  }
3965
3979
  meta.state = res.timedOut ? "timeout" : res.stopped ? "stopped" : meta.error ? "failed" : "done";
3966
3980
  if (meta.auditOf && meta.state === "done") {
3967
- const found = parseAudit(run.finalText);
3981
+ const found = parseAudit(run2.finalText);
3968
3982
  meta.auditVerdict = found.verdict;
3969
3983
  meta.auditDisputed = found.disputed;
3970
3984
  }
3971
- if (meta.state === "done" && !run.finalText) meta.warnings.push("worker finished without a written answer");
3985
+ if (meta.state === "done" && !run2.finalText) meta.warnings.push("worker finished without a written answer");
3972
3986
  if (meta.state === "done" && meta.verify) meta.verifyResult = runVerify(meta);
3973
3987
  meta.endedAt = (/* @__PURE__ */ new Date()).toISOString();
3974
- meta.returnedTokens = estimateTokens(formatReport(meta, run.finalText));
3988
+ meta.returnedTokens = estimateTokens(formatReport(meta, run2.finalText));
3975
3989
  meta.savedUsd = meta.auditOf ? 0 : savedUsd(meta.usage, meta.returnedTokens);
3976
3990
  writeMeta(meta);
3977
3991
  if (meta.mode === "write" && meta.repoRoot) releaseWriteLock(meta.repoRoot, meta.id);
@@ -4048,17 +4062,124 @@ function cleanupWorktree(meta) {
4048
4062
  if (meta.worktree && fs24.existsSync(meta.worktree)) removeIsolatedCopy(meta.worktree, worktreesDir());
4049
4063
  }
4050
4064
 
4051
- // src/cli/mcp.ts
4052
- var PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"];
4065
+ // src/cli/mcp-support.ts
4066
+ import { spawn as spawn3 } from "node:child_process";
4067
+
4068
+ // src/core/group.ts
4069
+ function groupIds(group) {
4070
+ return listRunIds().filter((id) => {
4071
+ try {
4072
+ return readMeta(id).group === group;
4073
+ } catch {
4074
+ return false;
4075
+ }
4076
+ });
4077
+ }
4078
+ var oneLine5 = (s, max) => {
4079
+ const t = s.replace(/\s+/g, " ").trim();
4080
+ return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
4081
+ };
4082
+ function headline(meta, max = 160) {
4083
+ const text = readSummary(meta);
4084
+ const line = text.split("\n").find((l) => /^SUMMARY:/i.test(l.trim())) ?? text.split("\n").find((l) => l.trim()) ?? "";
4085
+ return oneLine5(line.replace(/^SUMMARY:\s*/i, ""), max);
4086
+ }
4087
+ function table(metas) {
4088
+ if (!metas.length) return "no runs";
4089
+ const rows = metas.map((m) => {
4090
+ const l = m.state === "running" ? live(m) : void 0;
4091
+ const steps = l ? String(l.steps) : m.usage ? String(m.usage.steps) : "-";
4092
+ const note = l?.last ? oneLine5(l.last, 48) : isActive(m.state) ? oneLine5(m.task, 48) : headline(m, 48) || oneLine5(m.error ?? m.task, 48);
4093
+ return [m.id, m.state, m.mode, duration(m), steps, describeTarget(m.ran ?? m.worker), note];
4094
+ });
4095
+ const head = ["RUN", "STATE", "MODE", "TIME", "STEPS", "WORKER", "NOW / RESULT"];
4096
+ const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
4097
+ const fmt = (r, header = false) => r.map((c, i) => {
4098
+ const cell2 = i === r.length - 1 ? c : c.padEnd(widths[i]);
4099
+ return header ? dim(cell2) : i === 1 ? stateColour(r[1], cell2) : cell2;
4100
+ }).join(" ");
4101
+ return [fmt(head, true), ...rows.map((r) => fmt(r))].join("\n");
4102
+ }
4103
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
4104
+ async function waitMany(ids, opts) {
4105
+ const deadline = Date.now() + opts.timeoutMs;
4106
+ for (; ; ) {
4107
+ const metas = ids.map((id) => freshMeta(id));
4108
+ const done = metas.filter((m) => TERMINAL.includes(m.state)).length;
4109
+ if (opts.any ? done > 0 : done === metas.length) return { metas, timedOut: false };
4110
+ if (Date.now() >= deadline) return { metas, timedOut: true };
4111
+ await sleep2(1500);
4112
+ }
4113
+ }
4114
+ async function watch(select, opts) {
4115
+ const seen = /* @__PURE__ */ new Map();
4116
+ const line = (s) => opts.write(`${s}
4117
+ `);
4118
+ const emit = (e, m) => {
4119
+ if (!opts.brief) return opts.write(`${JSON.stringify(e)}
4120
+ `);
4121
+ if (e.event === "started" && m) line(startedCard(m));
4122
+ else if (e.event === "fallback" && m) line(`\u{1F3C1} Pitroom \u21BB ${m.id} fell back from ${String(e.failed)}: ${String(e.reason)}`);
4123
+ else if (m && TERMINAL.includes(m.state)) line(endedCard(m));
4124
+ 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` : ""})`);
4125
+ };
4126
+ const deadline = opts.timeoutMs > 0 ? Date.now() + opts.timeoutMs : Infinity;
4127
+ for (; ; ) {
4128
+ const metas = select().map((id) => freshMeta(id));
4129
+ if (opts.json || opts.brief) {
4130
+ for (const m of metas) {
4131
+ const prev = seen.get(m.id);
4132
+ const l = m.state === "running" ? live(m) : { steps: m.usage?.steps ?? 0, toolCalls: m.usage?.toolCalls ?? 0 };
4133
+ const attempts = m.attempts?.length ?? 0;
4134
+ const base2 = { run: m.id, mode: m.mode, task: oneLine5(m.task, 60) };
4135
+ if (!prev) emit({ event: m.state === "queued" ? "queued" : "started", ...base2 }, m);
4136
+ else if (prev.state === "queued" && m.state === "running") emit({ event: "started", ...base2 }, m);
4137
+ if (attempts > (prev?.attempts ?? 0)) {
4138
+ const a = m.attempts[attempts - 1];
4139
+ emit({ event: "fallback", run: m.id, failed: a.target, reason: oneLine5(a.error, 120) }, m);
4140
+ }
4141
+ if (m.state === "running" && l.steps > (prev?.steps ?? 0)) {
4142
+ 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 });
4143
+ }
4144
+ if (TERMINAL.includes(m.state) && prev?.state !== m.state) {
4145
+ emit({
4146
+ event: m.state,
4147
+ run: m.id,
4148
+ time: duration(m),
4149
+ worker: describeTarget(m.ran ?? m.worker),
4150
+ summary: headline(m) || void 0,
4151
+ error: m.error ? oneLine5(m.error, 160) : void 0,
4152
+ refs: m.refs ? `${m.refs.valid}/${m.refs.total}` : void 0,
4153
+ changes: m.changes?.length || void 0
4154
+ }, m);
4155
+ }
4156
+ seen.set(m.id, { state: m.state, steps: l.steps, attempts });
4157
+ }
4158
+ } else {
4159
+ opts.write(`\x1B[H\x1B[2J${table(metas)}
4160
+
4161
+ ${(/* @__PURE__ */ new Date()).toLocaleTimeString()} \xB7 Ctrl-C to stop watching (workers keep running)
4162
+ `);
4163
+ }
4164
+ const allDone = metas.length > 0 && metas.every((m) => TERMINAL.includes(m.state));
4165
+ if (allDone || metas.length === 0 && seen.size === 0) {
4166
+ if (opts.json || opts.brief) {
4167
+ const ok = metas.filter((m) => m.state === "done").length;
4168
+ emit({ event: "all-done", runs: metas.length, ok, failed: metas.length - ok });
4169
+ }
4170
+ return { metas, timedOut: false };
4171
+ }
4172
+ if (Date.now() >= deadline) return { metas, timedOut: true };
4173
+ await sleep2(opts.intervalMs);
4174
+ }
4175
+ }
4176
+
4177
+ // src/cli/mcp-support.ts
4053
4178
  var MAX_OUTPUT = 12e4;
4054
4179
  var DEFAULT_WAIT = 50;
4055
4180
  var MAX_WAIT = 540;
4056
- var INSTRUCTIONS = [
4057
- "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.",
4058
- '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).',
4059
- 'A run can take minutes: pitroom_run waits up to waitSeconds, then returns the run id as "still running"; call pitroom_wait with it.',
4060
- "It is optional: for a small task you can do yourself, skip it."
4061
- ].join(" ");
4181
+ var MAX_TASKS = 20;
4182
+ var PROGRESS_EVERY_MS = 5e3;
4062
4183
  var ToolError = class extends Error {
4063
4184
  };
4064
4185
  var str = (a, key, required = false) => {
@@ -4094,6 +4215,12 @@ var oneOf = (a, key, allowed, fallback) => {
4094
4215
  if (!allowed.includes(v)) throw new ToolError(`"${key}" must be one of: ${allowed.join(", ")}`);
4095
4216
  return v;
4096
4217
  };
4218
+ var since = (a) => {
4219
+ const v = str(a, "since");
4220
+ if (v === void 0) return [];
4221
+ if (v !== "all" && !/^\d+d$/.test(v)) throw new ToolError('"since" takes 7d, 30d, \u2026 or all');
4222
+ return ["--since", v];
4223
+ };
4097
4224
  function workerFlags(a, only) {
4098
4225
  const out = [];
4099
4226
  const on = (key) => !only || only.includes(key);
@@ -4111,7 +4238,7 @@ function workerFlags(a, only) {
4111
4238
  var strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
4112
4239
  var clip6 = (s) => s.length > MAX_OUTPUT ? `${s.slice(0, MAX_OUTPUT)}
4113
4240
  \u2026 (${s.length - MAX_OUTPUT} more characters)` : s;
4114
- function pit(args, timeoutMs = 60 * 6e4) {
4241
+ function pit(args, timeoutMs = 60 * 6e4, signal) {
4115
4242
  return new Promise((resolve2) => {
4116
4243
  const script = process.argv[1];
4117
4244
  if (!script) return resolve2({ code: 1, out: "", err: "cannot locate the pitroom executable" });
@@ -4120,10 +4247,14 @@ function pit(args, timeoutMs = 60 * 6e4) {
4120
4247
  let err = "";
4121
4248
  child.stdout.on("data", (d) => out += d);
4122
4249
  child.stderr.on("data", (d) => err += d);
4123
- const timer = setTimeout(() => child.kill("SIGTERM"), timeoutMs);
4250
+ const stop = () => child.kill("SIGTERM");
4251
+ const timer = setTimeout(stop, timeoutMs);
4252
+ signal?.addEventListener("abort", stop, { once: true });
4253
+ if (signal?.aborted) stop();
4124
4254
  child.on("error", (e) => resolve2({ code: 1, out, err: err || String(e.message) }));
4125
4255
  child.on("close", (code) => {
4126
4256
  clearTimeout(timer);
4257
+ signal?.removeEventListener("abort", stop);
4127
4258
  resolve2({ code, out: strip(out).trim(), err: strip(err).trim() });
4128
4259
  });
4129
4260
  });
@@ -4135,7 +4266,42 @@ function asResult(r, still) {
4135
4266
  ${still}`) };
4136
4267
  return { text: clip6(text), isError: r.code === 2 || r.code === 3 || r.code === 1 && !r.out };
4137
4268
  }
4138
- async function startAndWait(start, seconds, task) {
4269
+ var plain = async (args, ctx, timeoutMs = 6e4) => asResult(await pit(args, timeoutMs, ctx.signal));
4270
+ function trackProgress(ctx, ids) {
4271
+ const tick = () => {
4272
+ try {
4273
+ const lines = ids().map((id) => freshMeta(id)).filter((m) => isActive(m.state)).map((m) => progress(m).replace(/^pitroom\s+/, ""));
4274
+ if (lines.length) ctx.progress(lines.slice(0, 3).join("\n") + (lines.length > 3 ? `
4275
+ \u2026 and ${lines.length - 3} more` : ""));
4276
+ } catch {
4277
+ }
4278
+ };
4279
+ const first = setTimeout(tick, 800);
4280
+ const timer = setInterval(tick, PROGRESS_EVERY_MS);
4281
+ return () => {
4282
+ clearTimeout(first);
4283
+ clearInterval(timer);
4284
+ };
4285
+ }
4286
+ async function waitFor(ids, seconds, ctx, group) {
4287
+ const args = group ? ["wait", "-g", group, "--timeout", String(seconds)] : ["wait", ...ids, "--timeout", String(seconds)];
4288
+ const done = trackProgress(ctx, () => group ? groupIds(group) : ids);
4289
+ let r;
4290
+ try {
4291
+ r = await pit(args, (seconds + 60) * 1e3, ctx.signal);
4292
+ } finally {
4293
+ done();
4294
+ }
4295
+ const again = group ? `{"group": "${group}"}` : `{"runs": ${JSON.stringify(ids)}}`;
4296
+ return asResult(r, `Not finished yet: call pitroom_wait with ${again} (waitSeconds up to ${MAX_WAIT}); pitroom_stop ends it.`);
4297
+ }
4298
+ var cancelled = (ctx) => ctx.signal.aborted && ctx.signal.reason === "cancelled";
4299
+ async function stopQuietly(args) {
4300
+ const r = await pit(args, 3e4);
4301
+ if (r.code !== 0) process.stderr.write(`pitroom mcp: ${args.join(" ")} failed: ${r.err || r.out}
4302
+ `);
4303
+ }
4304
+ async function startAndWait(start, seconds, ctx, task) {
4139
4305
  const started = await pit([...start, "--bg", "--json", ...task === void 0 ? [] : ["--", task]]);
4140
4306
  if (started.code !== 0) return asResult(started);
4141
4307
  let id;
@@ -4144,14 +4310,117 @@ async function startAndWait(start, seconds, task) {
4144
4310
  } catch {
4145
4311
  return { text: `could not read the run id from: ${started.out.slice(0, 200)}`, isError: true };
4146
4312
  }
4147
- return waitFor([id], seconds);
4313
+ const result = await waitFor([id], seconds, ctx);
4314
+ if (cancelled(ctx)) await stopQuietly(["stop", id]);
4315
+ return result;
4148
4316
  }
4149
- async function waitFor(ids, seconds, group) {
4150
- const args = group ? ["wait", "-g", group, "--timeout", String(seconds)] : ["wait", ...ids, "--timeout", String(seconds)];
4151
- const r = await pit(args, (seconds + 60) * 1e3);
4152
- const again = group ? `{"group": "${group}"}` : `{"runs": ${JSON.stringify(ids)}}`;
4153
- return asResult(r, `Not finished yet: call pitroom_wait with ${again} (waitSeconds up to ${MAX_WAIT}); pitroom_stop ends it.`);
4317
+ async function startCrew(flags, tasks, seconds, ctx) {
4318
+ const started = await pit(["crew", ...flags, "--json", "--", ...tasks]);
4319
+ if (started.code !== 0) return asResult(started);
4320
+ let group;
4321
+ try {
4322
+ group = JSON.parse(started.out).group;
4323
+ } catch {
4324
+ return { text: `could not read the group from: ${started.out.slice(0, 200)}`, isError: true };
4325
+ }
4326
+ const result = await waitFor([], seconds, ctx, group);
4327
+ if (cancelled(ctx)) await stopQuietly(["stop", "-g", group]);
4328
+ return result;
4329
+ }
4330
+
4331
+ // src/cli/mcp-extras.ts
4332
+ var RpcError = class extends Error {
4333
+ constructor(code, message) {
4334
+ super(message);
4335
+ this.code = code;
4336
+ }
4337
+ };
4338
+ var RUN = /^pitroom:\/\/run\/(\d{8}-\d{6}-[0-9a-f]{4})(\/patch)?$/;
4339
+ var LISTED = 30;
4340
+ var oneLine6 = (text, n) => {
4341
+ const flat = text.replace(/\s+/g, " ").trim();
4342
+ return flat.length > n ? `${flat.slice(0, n - 1)}\u2026` : flat;
4343
+ };
4344
+ function listResources() {
4345
+ const resources = [];
4346
+ for (const id of listRunIds().slice(-LISTED).reverse()) {
4347
+ try {
4348
+ const m = freshMeta(id);
4349
+ const what2 = `${m.state} \xB7 ${m.mode} \xB7 ${m.worker.backend}${m.worker.model ? `:${m.worker.model.split("/").pop()}` : ""}`;
4350
+ resources.push({ uri: `pitroom://run/${id}`, name: `run ${id}`, title: oneLine6(m.task, 70), description: `${what2}: the report`, mimeType: "text/plain" });
4351
+ 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" });
4352
+ } catch {
4353
+ }
4354
+ }
4355
+ return { resources };
4356
+ }
4357
+ var resourceTemplates = () => ({
4358
+ resourceTemplates: [
4359
+ { 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" },
4360
+ { 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" }
4361
+ ]
4362
+ });
4363
+ async function readResource(uri, ctx) {
4364
+ const m = RUN.exec(uri);
4365
+ if (!m) throw new RpcError(-32002, `unknown resource: ${uri}`);
4366
+ const patch = m[2] !== void 0;
4367
+ const r = await pit(["show", m[1], patch ? "--patch" : "--full"], 6e4, ctx.signal);
4368
+ 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`);
4369
+ return { contents: [{ uri, mimeType: patch ? "text/x-diff" : "text/plain", text: clip6(r.out) }] };
4370
+ }
4371
+ var PROMPTS = [
4372
+ {
4373
+ name: "research",
4374
+ title: "Research with a worker",
4375
+ description: "Find, map or explain code through a read-only worker, and verify what comes back.",
4376
+ arguments: [{ name: "question", description: "What to find out about the project.", required: true }],
4377
+ text: (a) => `Use the pitroom_run tool (mode "read") to find out: ${a.question}
4378
+
4379
+ 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.`
4380
+ },
4381
+ {
4382
+ name: "implement",
4383
+ title: "Get a change made",
4384
+ description: "A worker makes a change in an isolated copy; you review the exact diff and apply it only when it is right.",
4385
+ arguments: [{ name: "task", description: "The change to make, with the context a worker needs.", required: true }],
4386
+ text: (a) => `Get this change made by a worker: ${a.task}
4387
+
4388
+ 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.`
4389
+ },
4390
+ {
4391
+ name: "review",
4392
+ title: "Review changes with another model",
4393
+ description: "A read-only reviewer from another model judges a commit range or the latest change.",
4394
+ arguments: [{ name: "range", description: "A commit range such as main..HEAD. Default: the latest run's change." }],
4395
+ 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."
4396
+ },
4397
+ {
4398
+ name: "crew",
4399
+ title: "Split work across workers",
4400
+ description: "Independent tasks run in parallel as one group of workers.",
4401
+ arguments: [{ name: "tasks", description: "The tasks, one per line.", required: true }],
4402
+ text: (a) => `Run these independent tasks in parallel with pitroom_crew (one worker each):
4403
+
4404
+ ${a.tasks}
4405
+
4406
+ 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.`
4407
+ }
4408
+ ];
4409
+ var listPrompts = () => ({ prompts: PROMPTS.map(({ name, title, description, arguments: args }) => ({ name, title, description, arguments: args })) });
4410
+ function getPrompt(name, given) {
4411
+ const p = PROMPTS.find((x) => x.name === name);
4412
+ if (!p) throw new RpcError(-32602, `unknown prompt: ${String(name)}`);
4413
+ const args = {};
4414
+ const raw = given && typeof given === "object" && !Array.isArray(given) ? given : {};
4415
+ for (const a of p.arguments) {
4416
+ const v = raw[a.name];
4417
+ if (typeof v === "string" && v.trim()) args[a.name] = v.trim();
4418
+ else if (a.required) throw new RpcError(-32602, `missing argument: ${a.name}`);
4419
+ }
4420
+ return { description: p.description, messages: [{ role: "user", content: { type: "text", text: p.text(args) } }] };
4154
4421
  }
4422
+
4423
+ // src/cli/mcp-tools.ts
4155
4424
  var WORKER_PROPS = {
4156
4425
  worker: { type: "string", description: 'Worker target "backend[:model]", e.g. "opencode", "codex", "claude:haiku", "gemini:gemini-3.8-flash". Default: the configured worker.' },
4157
4426
  model: { type: "string", description: "Model for the preferred worker." },
@@ -4166,16 +4435,19 @@ var WORKER_PROPS = {
4166
4435
  };
4167
4436
  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}).` } };
4168
4437
  var RUN_ID2 = { type: "string", description: 'A run id as printed by a run, or "last".' };
4438
+ var SINCE = { type: "string", description: "A window: 7d, 30d, \u2026 or all." };
4439
+ var READ_ONLY = { readOnlyHint: true, openWorldHint: false };
4169
4440
  var TOOLS2 = [
4170
4441
  {
4171
4442
  name: "pitroom_run",
4172
4443
  title: "Run a worker",
4173
- 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.',
4444
+ 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 pitroom_revert. Returns the worker's answer and a receipt, or "still running" with the run id. Progress is reported while it waits; cancelling the call stops the run.`,
4174
4445
  inputSchema: {
4175
4446
  type: "object",
4176
4447
  properties: {
4177
4448
  task: { type: "string", description: "What the worker should do, with the context it needs." },
4178
4449
  mode: { type: "string", enum: ["read", "isolate", "write"], description: "read (default), isolate, or write." },
4450
+ continue: { type: "string", description: "A finished run to follow up in the same worker session (the task is then the follow-up)." },
4179
4451
  inPlace: { type: "boolean", description: "Read mode only: read the directory itself instead of a clean snapshot without secret-looking files." },
4180
4452
  audit: { type: "boolean", description: "Read mode only: have another worker re-check the answer afterwards." },
4181
4453
  ...WORKER_PROPS,
@@ -4184,15 +4456,40 @@ var TOOLS2 = [
4184
4456
  required: ["task"]
4185
4457
  },
4186
4458
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
4187
- async call(a) {
4459
+ async call(a, ctx) {
4188
4460
  const task = str(a, "task", true);
4189
4461
  const mode = oneOf(a, "mode", ["read", "isolate", "write"], "read");
4190
4462
  const flags = mode === "isolate" ? ["-i"] : mode === "write" ? ["-w"] : [];
4463
+ const follow = str(a, "continue");
4464
+ if (follow) flags.push("--continue", follow);
4191
4465
  if (mode === "read") {
4192
4466
  if (bool(a, "inPlace")) flags.push("--in-place");
4193
4467
  if (bool(a, "audit")) flags.push("--audit");
4194
4468
  }
4195
- return startAndWait(["run", ...flags, ...workerFlags(a)], waitSeconds(a), task);
4469
+ return startAndWait(["run", ...flags, ...workerFlags(a)], waitSeconds(a), ctx, task);
4470
+ }
4471
+ },
4472
+ {
4473
+ name: "pitroom_crew",
4474
+ title: "Run workers in parallel",
4475
+ description: 'Start several independent tasks at once as one group of workers (read, or isolate: each edits its own copy) and wait for the group. Returns every report, or "still running" with the group name for pitroom_wait. Only for tasks that do not depend on each other. Cancelling the call stops the group.',
4476
+ inputSchema: {
4477
+ type: "object",
4478
+ properties: {
4479
+ tasks: { type: "array", items: { type: "string" }, minItems: 1, maxItems: MAX_TASKS, description: "One self-contained task per worker." },
4480
+ mode: { type: "string", enum: ["read", "isolate"], description: "read (default) or isolate." },
4481
+ ...WORKER_PROPS,
4482
+ ...WAIT_PROP
4483
+ },
4484
+ required: ["tasks"]
4485
+ },
4486
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
4487
+ async call(a, ctx) {
4488
+ const tasks = strs(a, "tasks").filter((t) => t.trim());
4489
+ if (!tasks.length) throw new ToolError('"tasks" needs at least one task');
4490
+ if (tasks.length > MAX_TASKS) throw new ToolError(`at most ${MAX_TASKS} tasks at once: start the rest when these are done`);
4491
+ const mode = oneOf(a, "mode", ["read", "isolate"], "read");
4492
+ return startCrew([...mode === "isolate" ? ["-i"] : [], ...workerFlags(a)], tasks, waitSeconds(a), ctx);
4196
4493
  }
4197
4494
  },
4198
4495
  {
@@ -4203,12 +4500,12 @@ var TOOLS2 = [
4203
4500
  type: "object",
4204
4501
  properties: { runs: { type: "array", items: { type: "string" }, description: "Run ids." }, group: { type: "string", description: "Or a group name." }, ...WAIT_PROP }
4205
4502
  },
4206
- annotations: { readOnlyHint: true, openWorldHint: false },
4207
- async call(a) {
4503
+ annotations: READ_ONLY,
4504
+ async call(a, ctx) {
4208
4505
  const runs = strs(a, "runs");
4209
4506
  const group = str(a, "group");
4210
4507
  if (!runs.length && !group) throw new ToolError('give "runs" or "group"');
4211
- return waitFor(runs, waitSeconds(a), runs.length ? void 0 : group);
4508
+ return waitFor(runs, waitSeconds(a), ctx, runs.length ? void 0 : group);
4212
4509
  }
4213
4510
  },
4214
4511
  {
@@ -4216,11 +4513,23 @@ var TOOLS2 = [
4216
4513
  title: "Run status",
4217
4514
  description: "The state and live progress of a run (default: the latest), or of a group.",
4218
4515
  inputSchema: { type: "object", properties: { run: RUN_ID2, group: { type: "string", description: "A group name." } } },
4219
- annotations: { readOnlyHint: true, openWorldHint: false },
4220
- async call(a) {
4516
+ annotations: READ_ONLY,
4517
+ async call(a, ctx) {
4518
+ const group = str(a, "group");
4519
+ const run2 = str(a, "run");
4520
+ if (group && run2) throw new ToolError('give "run" or "group", not both');
4521
+ return plain(["status", ...group ? ["-g", group] : run2 ? [run2] : []], ctx);
4522
+ }
4523
+ },
4524
+ {
4525
+ name: "pitroom_list",
4526
+ title: "List runs",
4527
+ description: "The latest runs with their state, worker and task; with running only the active ones, or one group's.",
4528
+ inputSchema: { type: "object", properties: { running: { type: "boolean", description: "Only runs that are queued or running." }, group: { type: "string", description: "A group name." } } },
4529
+ annotations: READ_ONLY,
4530
+ async call(a, ctx) {
4221
4531
  const group = str(a, "group");
4222
- const run = str(a, "run");
4223
- return asResult(await pit(["status", ...group ? ["-g", group] : run ? [run] : []], 6e4));
4532
+ return plain(["ls", ...bool(a, "running") ? ["--running"] : [], ...group ? ["-g", group] : []], ctx);
4224
4533
  }
4225
4534
  },
4226
4535
  {
@@ -4228,10 +4537,85 @@ var TOOLS2 = [
4228
4537
  title: "Show a run",
4229
4538
  description: "A finished run's report again; with patch the exact diff of an isolated change, with full the untruncated answer.",
4230
4539
  inputSchema: { type: "object", properties: { run: RUN_ID2, patch: { type: "boolean" }, full: { type: "boolean" } }, required: ["run"] },
4231
- annotations: { readOnlyHint: true, openWorldHint: false },
4232
- async call(a) {
4233
- const run = str(a, "run", true);
4234
- return asResult(await pit(["show", run, ...bool(a, "patch") ? ["--patch"] : bool(a, "full") ? ["--full"] : []], 6e4));
4540
+ annotations: READ_ONLY,
4541
+ async call(a, ctx) {
4542
+ const run2 = str(a, "run", true);
4543
+ return plain(["show", run2, ...bool(a, "patch") ? ["--patch"] : bool(a, "full") ? ["--full"] : []], ctx);
4544
+ }
4545
+ },
4546
+ {
4547
+ name: "pitroom_history",
4548
+ title: "Search past runs",
4549
+ description: "Finished runs from the history, searchable: full-text over the task, the answer and the steps. Use it to find an earlier answer before asking a worker again.",
4550
+ inputSchema: {
4551
+ type: "object",
4552
+ properties: {
4553
+ text: { type: "string", description: "Words to search for." },
4554
+ state: { type: "string", description: "done, problem, failed, \u2026" },
4555
+ model: { type: "string", description: "Only runs of this model." },
4556
+ since: SINCE,
4557
+ limit: { type: "number", description: "How many runs (default 20)." }
4558
+ }
4559
+ },
4560
+ annotations: READ_ONLY,
4561
+ async call(a, ctx) {
4562
+ const limit = a.limit;
4563
+ 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');
4564
+ const text = str(a, "text");
4565
+ const state = str(a, "state");
4566
+ const model = str(a, "model");
4567
+ return plain(["history", ...since(a), ...state ? ["--state", state] : [], ...model ? ["--model", model] : [], ...limit ? ["--limit", String(limit)] : [], ...text ? ["--", text] : []], ctx);
4568
+ }
4569
+ },
4570
+ {
4571
+ name: "pitroom_stats",
4572
+ title: "Worker statistics",
4573
+ description: "Runs, success rate, average time and tokens per worker and model, and how often their answers were confirmed by audits. Use it to pick a worker.",
4574
+ inputSchema: { type: "object", properties: { since: SINCE } },
4575
+ annotations: READ_ONLY,
4576
+ async call(a, ctx) {
4577
+ return plain(["history", "stats", ...since(a)], ctx);
4578
+ }
4579
+ },
4580
+ {
4581
+ name: "pitroom_savings",
4582
+ title: "What the workers saved",
4583
+ description: "The tokens and money workers took over from you, per model, from the receipts of the runs.",
4584
+ inputSchema: { type: "object", properties: { since: SINCE, models: { type: "boolean", description: "Break it down per model." } } },
4585
+ annotations: READ_ONLY,
4586
+ async call(a, ctx) {
4587
+ return plain(["savings", ...since(a), ...bool(a, "models") ? ["--models"] : []], ctx);
4588
+ }
4589
+ },
4590
+ {
4591
+ name: "pitroom_models",
4592
+ title: "Worker models",
4593
+ description: "The models a worker backend offers, with effort levels, the costs set in the config and recent usage. Use it to choose a model for pitroom_run.",
4594
+ inputSchema: { type: "object", properties: { worker: { type: "string", description: "A backend: opencode, codex, claude, gemini. Default: the configured one." }, all: { type: "boolean", description: "Include models that are hidden by default." } } },
4595
+ annotations: READ_ONLY,
4596
+ async call(a, ctx) {
4597
+ const worker = str(a, "worker");
4598
+ return plain(["models", ...worker ? [worker] : [], ...bool(a, "all") ? ["--all"] : []], ctx);
4599
+ }
4600
+ },
4601
+ {
4602
+ name: "pitroom_cooldown",
4603
+ title: "Rate-limited models",
4604
+ description: 'The models that said "rate limited" and are being skipped for now (runs go to the next worker). With clear they are tried again immediately.',
4605
+ inputSchema: { type: "object", properties: { clear: { type: "boolean", description: "Forget the cooldowns." } } },
4606
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
4607
+ async call(a, ctx) {
4608
+ return plain(["cooldown", ...bool(a, "clear") ? ["--clear"] : []], ctx);
4609
+ }
4610
+ },
4611
+ {
4612
+ name: "pitroom_doctor",
4613
+ title: "Check the setup",
4614
+ description: "Checks the setup without spending tokens: the workers found and their logins, the config, the skills, where the MCP server is registered. Use it when a run fails to start.",
4615
+ inputSchema: { type: "object", properties: {} },
4616
+ annotations: READ_ONLY,
4617
+ async call(_a, ctx) {
4618
+ return plain(["doctor"], ctx, 12e4);
4235
4619
  }
4236
4620
  },
4237
4621
  {
@@ -4252,13 +4636,13 @@ var TOOLS2 = [
4252
4636
  }
4253
4637
  },
4254
4638
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
4255
- async call(a) {
4256
- const run = str(a, "run");
4639
+ async call(a, ctx) {
4640
+ const run2 = str(a, "run");
4257
4641
  const range = str(a, "range");
4258
- if (!run === !range) throw new ToolError('give exactly one of "run" or "range"');
4642
+ if (!run2 === !range) throw new ToolError('give exactly one of "run" or "range"');
4259
4643
  const plan = str(a, "plan");
4260
4644
  const flags = workerFlags(a, ["worker", "tier", "dir", "group"]);
4261
- return startAndWait(["review", ...range ? ["--range", range, ...plan ? ["--plan", plan] : []] : [run], ...flags], waitSeconds(a));
4645
+ return startAndWait(["review", ...range ? ["--range", range, ...plan ? ["--plan", plan] : []] : [run2], ...flags], waitSeconds(a), ctx);
4262
4646
  }
4263
4647
  },
4264
4648
  {
@@ -4267,23 +4651,24 @@ var TOOLS2 = [
4267
4651
  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.",
4268
4652
  inputSchema: { type: "object", properties: { run: RUN_ID2, worker: WORKER_PROPS.worker, ...WAIT_PROP }, required: ["run"] },
4269
4653
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
4270
- async call(a) {
4271
- const run = str(a, "run", true);
4654
+ async call(a, ctx) {
4655
+ const run2 = str(a, "run", true);
4272
4656
  const worker = str(a, "worker");
4273
- return startAndWait(["audit", run, ...worker ? ["-W", worker] : []], waitSeconds(a));
4657
+ return startAndWait(["audit", run2, ...worker ? ["-W", worker] : []], waitSeconds(a), ctx);
4274
4658
  }
4275
4659
  },
4276
4660
  {
4277
4661
  name: "pitroom_apply",
4278
4662
  title: "Apply an isolated change",
4279
- 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.",
4663
+ description: "Land an isolated run's patch on the user's working tree (checked first; refused if it no longer applies cleanly), or every patch of a group in order. A patch that deletes files is refused unless allowDelete is true: only pass it after checking the deletions are what the user asked for.",
4280
4664
  inputSchema: { type: "object", properties: { run: RUN_ID2, group: { type: "string" }, allowDelete: { type: "boolean" } } },
4281
4665
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
4282
- async call(a) {
4666
+ async call(a, ctx) {
4283
4667
  const group = str(a, "group");
4284
- const run = str(a, "run");
4285
- if (!run && !group) throw new ToolError('give "run" or "group"');
4286
- return asResult(await pit(["apply", ...group && !run ? ["-g", group] : [run], ...bool(a, "allowDelete") ? ["--allow-delete"] : []], 12e4));
4668
+ const run2 = str(a, "run");
4669
+ if (!run2 && !group) throw new ToolError('give "run" or "group"');
4670
+ if (run2 && group) throw new ToolError('give "run" or "group", not both');
4671
+ return plain(["apply", ...group && !run2 ? ["-g", group] : [run2], ...bool(a, "allowDelete") ? ["--allow-delete"] : []], ctx, 12e4);
4287
4672
  }
4288
4673
  },
4289
4674
  {
@@ -4292,8 +4677,18 @@ var TOOLS2 = [
4292
4677
  description: "Drop an isolated run's private copy (its patch is kept).",
4293
4678
  inputSchema: { type: "object", properties: { run: RUN_ID2 }, required: ["run"] },
4294
4679
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
4295
- async call(a) {
4296
- return asResult(await pit(["discard", str(a, "run", true)], 6e4));
4680
+ async call(a, ctx) {
4681
+ return plain(["discard", str(a, "run", true)], ctx);
4682
+ }
4683
+ },
4684
+ {
4685
+ name: "pitroom_revert",
4686
+ title: "Undo an in-place change",
4687
+ description: 'Undo what a mode "write" run changed in the working tree (checked first; refused when the files changed since).',
4688
+ inputSchema: { type: "object", properties: { run: RUN_ID2 }, required: ["run"] },
4689
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
4690
+ async call(a, ctx) {
4691
+ return plain(["revert", str(a, "run", true)], ctx, 12e4);
4297
4692
  }
4298
4693
  },
4299
4694
  {
@@ -4302,31 +4697,53 @@ var TOOLS2 = [
4302
4697
  description: "Stop a running or queued run, or every run of a group.",
4303
4698
  inputSchema: { type: "object", properties: { run: RUN_ID2, group: { type: "string" } } },
4304
4699
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
4305
- async call(a) {
4306
- const run = str(a, "run");
4700
+ async call(a, ctx) {
4701
+ const run2 = str(a, "run");
4307
4702
  const group = str(a, "group");
4308
- if (!run && !group) throw new ToolError('give "run" or "group"');
4309
- return asResult(await pit(["stop", ...run ? [run] : ["-g", group]], 6e4));
4703
+ if (!run2 && !group) throw new ToolError('give "run" or "group"');
4704
+ if (run2 && group) throw new ToolError('give "run" or "group", not both');
4705
+ return plain(["stop", ...run2 ? [run2] : ["-g", group]], ctx);
4310
4706
  }
4311
4707
  }
4312
4708
  ];
4709
+
4710
+ // src/cli/mcp.ts
4711
+ var PROTOCOLS = ["2025-06-18", "2025-03-26", "2024-11-05"];
4712
+ var INSTRUCTIONS = [
4713
+ "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.",
4714
+ '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_crew for independent tasks in parallel.',
4715
+ 'A run can take minutes: pitroom_run waits up to waitSeconds, then returns the run id as "still running"; call pitroom_wait with it.',
4716
+ "It is optional: for a small task you can do yourself, skip it."
4717
+ ].join(" ");
4313
4718
  var log = (msg) => void process.stderr.write(`pitroom mcp: ${msg}
4314
4719
  `);
4315
- var send = (msg) => void process.stdout.write(`${JSON.stringify(msg)}
4316
- `);
4317
- var RpcError = class extends Error {
4318
- constructor(code, message) {
4319
- super(message);
4320
- this.code = code;
4321
- }
4322
- };
4323
- async function dispatch(method, params) {
4720
+ var newSession = () => ({ inflight: /* @__PURE__ */ new Map() });
4721
+ function endSession(session) {
4722
+ for (const { abort } of session.inflight.values()) abort.abort("closed");
4723
+ }
4724
+ function progressToken(params) {
4725
+ const meta = params && typeof params === "object" ? params._meta : void 0;
4726
+ const token = meta && typeof meta === "object" ? meta.progressToken : void 0;
4727
+ return typeof token === "string" || typeof token === "number" ? token : void 0;
4728
+ }
4729
+ function contextFor(params, abort, notify) {
4730
+ const token = progressToken(params);
4731
+ let n = 0;
4732
+ return {
4733
+ signal: abort.signal,
4734
+ progress: (message) => {
4735
+ if (token === void 0 || abort.signal.aborted) return;
4736
+ notify({ jsonrpc: "2.0", method: "notifications/progress", params: { progressToken: token, progress: ++n, message } });
4737
+ }
4738
+ };
4739
+ }
4740
+ async function dispatch(method, params, ctx) {
4324
4741
  switch (method) {
4325
4742
  case "initialize": {
4326
4743
  const asked = typeof params.protocolVersion === "string" ? params.protocolVersion : "";
4327
4744
  return {
4328
4745
  protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0],
4329
- capabilities: { tools: { listChanged: false } },
4746
+ capabilities: { tools: { listChanged: false }, resources: { listChanged: false, subscribe: false }, prompts: { listChanged: false } },
4330
4747
  serverInfo: { name: "pitroom", title: "Pitroom", version: VERSION2 },
4331
4748
  instructions: INSTRUCTIONS
4332
4749
  };
@@ -4340,7 +4757,7 @@ async function dispatch(method, params) {
4340
4757
  if (!tool) throw new RpcError(-32602, `unknown tool: ${String(params.name)}`);
4341
4758
  const args = params.arguments && typeof params.arguments === "object" && !Array.isArray(params.arguments) ? params.arguments : {};
4342
4759
  try {
4343
- const r = await tool.call(args);
4760
+ const r = await tool.call(args, ctx);
4344
4761
  return { content: [{ type: "text", text: r.text }], isError: r.isError === true };
4345
4762
  } catch (e) {
4346
4763
  if (e instanceof ToolError) return { content: [{ type: "text", text: e.message }], isError: true };
@@ -4348,995 +4765,1306 @@ async function dispatch(method, params) {
4348
4765
  return { content: [{ type: "text", text: `pitroom: ${e.message}` }], isError: true };
4349
4766
  }
4350
4767
  }
4768
+ case "resources/list":
4769
+ return listResources();
4770
+ case "resources/templates/list":
4771
+ return resourceTemplates();
4772
+ case "resources/read":
4773
+ if (typeof params.uri !== "string") throw new RpcError(-32602, '"uri" is required');
4774
+ return readResource(params.uri, ctx);
4775
+ case "prompts/list":
4776
+ return listPrompts();
4777
+ case "prompts/get":
4778
+ return getPrompt(params.name, params.arguments);
4351
4779
  default:
4352
4780
  throw new RpcError(-32601, `method not found: ${method}`);
4353
4781
  }
4354
4782
  }
4355
- async function handle(msg) {
4783
+ function cancel(session, params) {
4784
+ const id = params.requestId;
4785
+ if (typeof id !== "string" && typeof id !== "number") return;
4786
+ session.inflight.get(id)?.abort.abort("cancelled");
4787
+ }
4788
+ async function handle(session, msg, notify) {
4356
4789
  if (!msg || typeof msg !== "object" || Array.isArray(msg)) return { jsonrpc: "2.0", id: null, error: { code: -32600, message: "invalid request" } };
4357
4790
  const m = msg;
4358
4791
  if (typeof m.method !== "string") return void 0;
4359
- const isRequest = m.id !== void 0 && m.id !== null;
4792
+ const params = m.params && typeof m.params === "object" && !Array.isArray(m.params) ? m.params : {};
4793
+ if (m.method === "notifications/cancelled") {
4794
+ cancel(session, params);
4795
+ return void 0;
4796
+ }
4797
+ const isRequest = typeof m.id === "string" || typeof m.id === "number";
4798
+ const abort = new AbortController();
4799
+ const entry = { abort, ctx: contextFor(params, abort, notify) };
4800
+ if (isRequest) session.inflight.set(m.id, entry);
4360
4801
  try {
4361
- const result = await dispatch(m.method, m.params && typeof m.params === "object" ? m.params : {});
4802
+ const result = await dispatch(m.method, params, entry.ctx);
4803
+ if (abort.signal.aborted && abort.signal.reason === "cancelled") return void 0;
4362
4804
  return isRequest ? { jsonrpc: "2.0", id: m.id, result } : void 0;
4363
4805
  } catch (e) {
4364
- if (!isRequest) return void 0;
4806
+ if (!isRequest || abort.signal.aborted && abort.signal.reason === "cancelled") return void 0;
4365
4807
  const code = e instanceof RpcError ? e.code : -32603;
4366
4808
  return { jsonrpc: "2.0", id: m.id, error: { code, message: e.message } };
4809
+ } finally {
4810
+ if (isRequest && session.inflight.get(m.id) === entry) session.inflight.delete(m.id);
4367
4811
  }
4368
4812
  }
4369
- async function handleLine(line) {
4370
- let parsed;
4371
- try {
4372
- parsed = JSON.parse(line);
4373
- } catch {
4374
- send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
4375
- return;
4376
- }
4813
+ async function handlePayload(session, parsed, notify) {
4377
4814
  if (Array.isArray(parsed)) {
4378
- const replies = (await Promise.all(parsed.map(handle))).filter((r) => !!r);
4379
- if (replies.length) send(replies);
4380
- return;
4815
+ const replies = (await Promise.all(parsed.map((m) => handle(session, m, notify)))).filter((r) => !!r);
4816
+ return replies.length ? replies : void 0;
4381
4817
  }
4382
- const reply = await handle(parsed);
4383
- if (reply) send(reply);
4818
+ return handle(session, parsed, notify);
4384
4819
  }
4820
+ var parseError = () => ({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
4385
4821
  async function serveMcp() {
4822
+ process.stdout.on("error", () => {
4823
+ });
4824
+ const send = (msg) => void process.stdout.write(`${JSON.stringify(msg)}
4825
+ `);
4826
+ const session = newSession();
4386
4827
  const rl = readline.createInterface({ input: process.stdin });
4387
- const inflight = /* @__PURE__ */ new Set();
4388
- rl.on("line", (line) => {
4828
+ const pending = /* @__PURE__ */ new Set();
4829
+ const handleLine = async (line) => {
4830
+ let parsed;
4831
+ try {
4832
+ parsed = JSON.parse(line);
4833
+ } catch {
4834
+ send(parseError());
4835
+ return;
4836
+ }
4837
+ const reply2 = await handlePayload(session, parsed, send);
4838
+ if (reply2) send(reply2);
4839
+ };
4840
+ rl.on("line", (line) => {
4389
4841
  if (!line.trim()) return;
4390
4842
  const p = handleLine(line).catch((e) => log(String(e.stack ?? e)));
4391
- inflight.add(p);
4392
- void p.finally(() => inflight.delete(p));
4843
+ pending.add(p);
4844
+ void p.finally(() => pending.delete(p));
4393
4845
  });
4394
4846
  await new Promise((resolve2) => rl.once("close", resolve2));
4395
- await Promise.allSettled([...inflight]);
4847
+ await Promise.allSettled([...pending]);
4396
4848
  return 0;
4397
4849
  }
4398
4850
 
4399
- // src/cli/commands.ts
4400
- import { spawnSync as spawnSync7 } from "node:child_process";
4401
- import fs29 from "node:fs";
4402
- import path25 from "node:path";
4403
- import { fileURLToPath as fileURLToPath4 } from "node:url";
4851
+ // src/cli/mcp-http.ts
4852
+ import crypto7 from "node:crypto";
4853
+ import fs26 from "node:fs";
4854
+ import http2 from "node:http";
4855
+ import path22 from "node:path";
4404
4856
 
4405
- // src/core/init.ts
4857
+ // src/core/dash.ts
4858
+ import { spawn as spawn4 } from "node:child_process";
4859
+ import crypto6 from "node:crypto";
4406
4860
  import fs25 from "node:fs";
4861
+ import http from "node:http";
4407
4862
  import path21 from "node:path";
4408
- var FREE = /-free$/;
4409
- var TIER_ORDER = [["cheap", "opencode"], ["standard", "codex"], ["capable", "claude"]];
4410
- var isFound = (binary5) => path21.isAbsolute(binary5) && fs25.existsSync(binary5);
4411
- function planInit(opts = {}) {
4412
- const file2 = configPath();
4413
- const workers = backendIds().map((id) => {
4414
- const b = getBackend(id);
4415
- const binary5 = b.binary();
4416
- return { id, name: b.name, found: isFound(binary5), binary: binary5 };
4417
- });
4418
- const notes = [];
4419
- const config = {};
4420
- let blocked;
4421
- let opencode2;
4422
- if (workers.find((w) => w.id === "opencode")?.found) {
4423
- const b = getBackend("opencode");
4424
- let models = [];
4425
- try {
4426
- models = b.catalog ? b.catalog().models.map((m) => m.id) : b.listModels?.() ?? [];
4427
- } catch {
4428
- notes.push("could not list OpenCode models (is it logged in?)");
4429
- }
4430
- const defaultModel4 = b.defaultModel?.();
4431
- const chosen = opts.model ?? defaultModel4;
4432
- const free = models.filter((m) => FREE.test(m) && m !== chosen);
4433
- opencode2 = { defaultModel: defaultModel4, models: models.length, free: free.slice(0, 6) };
4434
- if (opts.model) {
4435
- if (models.length && !models.includes(opts.model)) throw new UserError(`"${opts.model}" is not in \`opencode models\``);
4436
- if (!models.length) notes.push(`could not check "${opts.model}": \`opencode models\` listed nothing`);
4437
- config.models = { opencode: opts.model };
4438
- } else if (!defaultModel4) {
4439
- blocked = `OpenCode has no default model: choose one with --model <id>${free.length ? ` (free models you have: ${free.slice(0, 4).join(", ")})` : ""}`;
4440
- }
4441
- const fallback = opts.fallback ?? free.slice(0, 2);
4442
- for (const m of fallback) {
4443
- if (models.length && !models.includes(m)) throw new UserError(`"${m}" is not in \`opencode models\``);
4444
- }
4445
- if (fallback.length) {
4446
- config.fallback = fallback.map((m) => `opencode:${m}`);
4447
- if (!opts.fallback) notes.push(`fallback suggested from the free models in your catalogue: ${fallback.join(", ")} (change it with --fallback a,b)`);
4448
- } else {
4449
- notes.push("no fallback: no free OpenCode model found to suggest (pass --fallback a,b to name some)");
4863
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
4864
+
4865
+ // src/core/file-diff.ts
4866
+ var PREVIEW_LINES = 300;
4867
+ function gitPath(value, prefix = true) {
4868
+ let path29 = value;
4869
+ if (value.startsWith('"') && value.endsWith('"')) {
4870
+ const bytes = [];
4871
+ const escapes = { t: " ", n: "\n", r: "\r", b: "\b", f: "\f", v: "\v" };
4872
+ for (const match of value.slice(1, -1).matchAll(/\\([0-7]{1,3}|.)|([^\\]+)/g)) {
4873
+ if (match[1] && /^[0-7]+$/.test(match[1])) bytes.push(parseInt(match[1], 8));
4874
+ else bytes.push(...new TextEncoder().encode(match[2] ?? escapes[match[1]] ?? match[1]));
4450
4875
  }
4876
+ path29 = new TextDecoder().decode(new Uint8Array(bytes));
4451
4877
  }
4452
- const tiers = Object.fromEntries(TIER_ORDER.filter(([, id]) => workers.find((w) => w.id === id)?.found));
4453
- if (Object.keys(tiers).length > 1) config.tiers = tiers;
4454
- if (!workers.some((w) => w.found)) blocked = "no worker CLI found: install OpenCode (https://opencode.ai), Codex CLI, Claude Code or Gemini CLI first";
4455
- else if (!blocked && !Object.keys(config).length) notes.push("nothing to propose: the default model and the single worker need no config");
4456
- return { path: file2, exists: fs25.existsSync(file2), workers, opencode: opencode2, config, blocked, notes };
4457
- }
4458
- function writeInit(plan, force) {
4459
- if (plan.blocked) throw new UserError(plan.blocked);
4460
- if (!Object.keys(plan.config).length) throw new UserError("nothing to write: the proposal is empty");
4461
- 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)`);
4462
- fs25.mkdirSync(path21.dirname(plan.path), { recursive: true });
4463
- if (plan.exists) fs25.copyFileSync(plan.path, `${plan.path}.bak`);
4464
- fs25.writeFileSync(plan.path, `${JSON.stringify(plan.config, null, 2)}
4465
- `);
4466
- return plan.path;
4878
+ return prefix ? path29.replace(/^[ab]\//, "") : path29;
4467
4879
  }
4468
- function formatInit(plan, written) {
4469
- const out = [bold("pitroom init") + dim(": worker CLIs on this machine")];
4470
- 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")}`);
4471
- if (plan.opencode) {
4472
- const o = plan.opencode;
4473
- 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" : ""}` : ""}`);
4474
- }
4475
- out.push("", `config file: ${plan.path}${plan.exists ? " (exists)" : " (not present)"}`, JSON.stringify(plan.config, null, 2));
4476
- for (const n of plan.notes) out.push(` note: ${n}`);
4477
- if (written) out.push("", green(`\u2714 written to ${written}`), dim(" next: pitroom doctor, then pitroom install"));
4478
- else if (plan.blocked) out.push("", yellow(`! ${plan.blocked}`));
4479
- else out.push("", `nothing written yet: pass --yes to write it${plan.exists ? " (with --force, since the file exists)" : ""}`);
4480
- return out.join("\n");
4880
+ function headerPath(header) {
4881
+ const quoted = header.match(/^diff --git ("(?:[^"\\]|\\.)*"|\S+) ("(?:[^"\\]|\\.)*"|\S+)$/);
4882
+ if (quoted) return gitPath(quoted[2]);
4883
+ return gitPath(header.slice(header.lastIndexOf(" b/") + 1));
4481
4884
  }
4482
-
4483
- // src/core/group.ts
4484
- function groupIds(group) {
4485
- return listRunIds().filter((id) => {
4486
- try {
4487
- return readMeta(id).group === group;
4488
- } catch {
4489
- return false;
4885
+ function fileDiffs(patch, changes, sourceTruncated = false) {
4886
+ const files = [];
4887
+ let file2;
4888
+ let oldLine = 0;
4889
+ let newLine = 0;
4890
+ let oldLeft = 0;
4891
+ let newLeft = 0;
4892
+ let inHunk = false;
4893
+ let row = 0;
4894
+ const finishHunk = () => {
4895
+ if (file2 && (oldLeft > 0 || newLeft > 0)) file2.incomplete = true;
4896
+ oldLeft = newLeft = 0;
4897
+ inHunk = false;
4898
+ };
4899
+ const add = (line) => {
4900
+ if (!file2) return;
4901
+ if (file2.lines.length < PREVIEW_LINES) file2.lines.push({ ...line, id: String(row) });
4902
+ else file2.omittedLines++;
4903
+ row++;
4904
+ };
4905
+ for (const line of patch.split("\n")) {
4906
+ if (line.startsWith("diff --git ")) {
4907
+ finishHunk();
4908
+ file2 = { path: headerPath(line), status: "M", lines: [], additions: 0, deletions: 0, omittedLines: 0, binary: false, incomplete: false };
4909
+ files.push(file2);
4910
+ row = 0;
4911
+ continue;
4912
+ }
4913
+ if (!file2) continue;
4914
+ const hunk = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
4915
+ if (hunk) {
4916
+ finishHunk();
4917
+ oldLine = Number(hunk[1]);
4918
+ newLine = Number(hunk[3]);
4919
+ oldLeft = Number(hunk[2] ?? 1);
4920
+ newLeft = Number(hunk[4] ?? 1);
4921
+ inHunk = true;
4922
+ add({ type: "hunk", content: line });
4923
+ } else if (inHunk && line.startsWith("\")) {
4924
+ add({ type: "meta", content: line });
4925
+ } else if (inHunk && (oldLeft > 0 || newLeft > 0)) {
4926
+ if (line.startsWith("+") && newLeft > 0) {
4927
+ file2.additions++;
4928
+ newLeft--;
4929
+ add({ type: "added", newLine: newLine++, content: line.slice(1) });
4930
+ } else if (line.startsWith("-") && oldLeft > 0) {
4931
+ file2.deletions++;
4932
+ oldLeft--;
4933
+ add({ type: "removed", oldLine: oldLine++, content: line.slice(1) });
4934
+ } else if (line.startsWith(" ") && oldLeft > 0 && newLeft > 0) {
4935
+ oldLeft--;
4936
+ newLeft--;
4937
+ add({ type: "context", oldLine: oldLine++, newLine: newLine++, content: line.slice(1) });
4938
+ } else {
4939
+ file2.incomplete = true;
4940
+ }
4941
+ } else if (!inHunk) {
4942
+ if (line.startsWith("+++ ") && line !== "+++ /dev/null") file2.path = gitPath(line.slice(4).replace(/\t$/, ""));
4943
+ else if (line.startsWith("--- ") && line !== "--- /dev/null") file2.path = gitPath(line.slice(4).replace(/\t$/, ""));
4944
+ else if (/^(GIT binary patch|Binary files .* differ)$/.test(line)) file2.binary = true;
4945
+ else if (/^(new file mode|deleted file mode|old mode|new mode|rename from|rename to|similarity index) /.test(line)) {
4946
+ if (line.startsWith("new file mode ")) file2.status = "A";
4947
+ if (line.startsWith("deleted file mode ")) file2.status = "D";
4948
+ if (line.startsWith("rename to ")) {
4949
+ file2.path = gitPath(line.slice(10), false);
4950
+ file2.status = "R";
4951
+ }
4952
+ add({ type: "meta", content: line });
4953
+ }
4490
4954
  }
4955
+ }
4956
+ finishHunk();
4957
+ if (sourceTruncated && file2) file2.incomplete = true;
4958
+ for (const f of files) {
4959
+ if (f.incomplete || f.binary) f.additions = f.deletions = void 0;
4960
+ }
4961
+ const byPath = new Map(files.map((f) => [f.path, f]));
4962
+ const result = changes.map((change) => {
4963
+ const diff = byPath.get(change.path);
4964
+ byPath.delete(change.path);
4965
+ return diff ? { ...diff, status: change.status } : {
4966
+ ...change,
4967
+ lines: [],
4968
+ omittedLines: 0,
4969
+ binary: false,
4970
+ incomplete: sourceTruncated,
4971
+ unavailable: true
4972
+ };
4491
4973
  });
4974
+ return [...result, ...byPath.values()];
4492
4975
  }
4493
- var oneLine5 = (s, max) => {
4976
+
4977
+ // src/core/dash-page.ts
4978
+ 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){}})()";
4979
+ 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";
4980
+ var PAGE = `<!doctype html>
4981
+ <html lang="en" class="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
4982
+ <meta name="color-scheme" content="dark light"><title>Pitroom</title><link rel="icon" href="${ICON3}">
4983
+ <script>${THEME_SCRIPT}</script><link rel="stylesheet" href="/assets/app.css"></head>
4984
+ <body><div id="root"></div><script type="module" src="/assets/app.js"></script></body></html>`;
4985
+ 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>`;
4986
+
4987
+ // src/core/dash.ts
4988
+ var DEFAULT_PORT = 7878;
4989
+ var WEEK_MS2 = 7 * 24 * 3600 * 1e3;
4990
+ var SCAN = 400;
4991
+ var RUN_ID3 = /^\d{8}-\d{6}-[0-9a-f]{4}$/;
4992
+ var LOCAL_HOST = /^(127\.0\.0\.1|localhost|\[::1\]):\d+$/i;
4993
+ var oneLine7 = (s, max) => {
4494
4994
  const t = s.replace(/\s+/g, " ").trim();
4495
4995
  return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
4496
4996
  };
4497
- function headline(meta, max = 160) {
4498
- const text = readSummary(meta);
4499
- const line = text.split("\n").find((l) => /^SUMMARY:/i.test(l.trim())) ?? text.split("\n").find((l) => l.trim()) ?? "";
4500
- return oneLine5(line.replace(/^SUMMARY:\s*/i, ""), max);
4997
+ function findings(m) {
4998
+ const v = m.verdict;
4999
+ if (!v) return "";
5000
+ const parts = [v.critical && `${v.critical} critical`, v.important && `${v.important} important`, v.minor && `${v.minor} minor`].filter(Boolean);
5001
+ return parts.length ? parts.join(" \xB7 ") : "no findings";
4501
5002
  }
4502
- function table(metas) {
4503
- if (!metas.length) return "no runs";
4504
- const rows = metas.map((m) => {
4505
- const l = m.state === "running" ? live(m) : void 0;
4506
- const steps = l ? String(l.steps) : m.usage ? String(m.usage.steps) : "-";
4507
- const note = l?.last ? oneLine5(l.last, 48) : isActive(m.state) ? oneLine5(m.task, 48) : headline(m, 48) || oneLine5(m.error ?? m.task, 48);
4508
- return [m.id, m.state, m.mode, duration(m), steps, describeTarget(m.ran ?? m.worker), note];
4509
- });
4510
- const head = ["RUN", "STATE", "MODE", "TIME", "STEPS", "WORKER", "NOW / RESULT"];
4511
- const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
4512
- const fmt = (r, header = false) => r.map((c, i) => {
4513
- const cell2 = i === r.length - 1 ? c : c.padEnd(widths[i]);
4514
- return header ? dim(cell2) : i === 1 ? stateColour(r[1], cell2) : cell2;
4515
- }).join(" ");
4516
- return [fmt(head, true), ...rows.map((r) => fmt(r))].join("\n");
5003
+ function toRun(m) {
5004
+ const l = m.state === "running" ? live(m) : void 0;
5005
+ return {
5006
+ id: m.id,
5007
+ state: m.state,
5008
+ kind: kind(m),
5009
+ // the model that really ran, once the CLI has said which; until then just the worker, never a guess
5010
+ worker: workerName(ranTarget(m)),
5011
+ task: what(m),
5012
+ group: m.group,
5013
+ startedAt: m.startedAt,
5014
+ time: elapsed(m),
5015
+ steps: l ? l.steps : m.usage?.steps ?? 0,
5016
+ tokens: m.usage?.total,
5017
+ saved: m.savedUsd || void 0,
5018
+ verdict: m.verdict ? `SPEC ${m.verdict.spec.toUpperCase()} \xB7 QUALITY ${m.verdict.quality.toUpperCase()}` : m.auditVerdict ? `AUDIT ${m.auditVerdict.toUpperCase()}` : void 0,
5019
+ audit: auditBadge(m),
5020
+ changes: m.changes?.length || void 0,
5021
+ applied: m.applied || void 0,
5022
+ note: l?.last ? oneLine7(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) || oneLine7(m.error ?? "", 200)
5023
+ };
4517
5024
  }
4518
- var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
4519
- async function waitMany(ids, opts) {
4520
- const deadline = Date.now() + opts.timeoutMs;
4521
- for (; ; ) {
4522
- const metas = ids.map((id) => freshMeta(id));
4523
- const done = metas.filter((m) => TERMINAL.includes(m.state)).length;
4524
- if (opts.any ? done > 0 : done === metas.length) return { metas, timedOut: false };
4525
- if (Date.now() >= deadline) return { metas, timedOut: true };
4526
- await sleep2(1500);
4527
- }
4528
- }
4529
- async function watch(select, opts) {
4530
- const seen = /* @__PURE__ */ new Map();
4531
- const line = (s) => opts.write(`${s}
4532
- `);
4533
- const emit = (e, m) => {
4534
- if (!opts.brief) return opts.write(`${JSON.stringify(e)}
4535
- `);
4536
- if (e.event === "started" && m) line(startedCard(m));
4537
- else if (e.event === "fallback" && m) line(`\u{1F3C1} Pitroom \u21BB ${m.id} fell back from ${String(e.failed)}: ${String(e.reason)}`);
4538
- else if (m && TERMINAL.includes(m.state)) line(endedCard(m));
4539
- 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` : ""})`);
4540
- };
4541
- const deadline = opts.timeoutMs > 0 ? Date.now() + opts.timeoutMs : Infinity;
4542
- for (; ; ) {
4543
- const metas = select().map((id) => freshMeta(id));
4544
- if (opts.json || opts.brief) {
4545
- for (const m of metas) {
4546
- const prev = seen.get(m.id);
4547
- const l = m.state === "running" ? live(m) : { steps: m.usage?.steps ?? 0, toolCalls: m.usage?.toolCalls ?? 0 };
4548
- const attempts = m.attempts?.length ?? 0;
4549
- const base2 = { run: m.id, mode: m.mode, task: oneLine5(m.task, 60) };
4550
- if (!prev) emit({ event: m.state === "queued" ? "queued" : "started", ...base2 }, m);
4551
- else if (prev.state === "queued" && m.state === "running") emit({ event: "started", ...base2 }, m);
4552
- if (attempts > (prev?.attempts ?? 0)) {
4553
- const a = m.attempts[attempts - 1];
4554
- emit({ event: "fallback", run: m.id, failed: a.target, reason: oneLine5(a.error, 120) }, m);
4555
- }
4556
- if (m.state === "running" && l.steps > (prev?.steps ?? 0)) {
4557
- 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 });
4558
- }
4559
- if (TERMINAL.includes(m.state) && prev?.state !== m.state) {
4560
- emit({
4561
- event: m.state,
4562
- run: m.id,
4563
- time: duration(m),
4564
- worker: describeTarget(m.ran ?? m.worker),
4565
- summary: headline(m) || void 0,
4566
- error: m.error ? oneLine5(m.error, 160) : void 0,
4567
- refs: m.refs ? `${m.refs.valid}/${m.refs.total}` : void 0,
4568
- changes: m.changes?.length || void 0
4569
- }, m);
4570
- }
4571
- seen.set(m.id, { state: m.state, steps: l.steps, attempts });
4572
- }
4573
- } else {
4574
- opts.write(`\x1B[H\x1B[2J${table(metas)}
4575
-
4576
- ${(/* @__PURE__ */ new Date()).toLocaleTimeString()} \xB7 Ctrl-C to stop watching (workers keep running)
4577
- `);
4578
- }
4579
- const allDone = metas.length > 0 && metas.every((m) => TERMINAL.includes(m.state));
4580
- if (allDone || metas.length === 0 && seen.size === 0) {
4581
- if (opts.json || opts.brief) {
4582
- const ok = metas.filter((m) => m.state === "done").length;
4583
- emit({ event: "all-done", runs: metas.length, ok, failed: metas.length - ok });
4584
- }
4585
- return { metas, timedOut: false };
4586
- }
4587
- if (Date.now() >= deadline) return { metas, timedOut: true };
4588
- await sleep2(opts.intervalMs);
4589
- }
4590
- }
4591
-
4592
- // src/core/plan-status.ts
4593
- import crypto6 from "node:crypto";
4594
- import fs26 from "node:fs";
4595
- import path22 from "node:path";
4596
- function notesFile(plan) {
4597
- const id = crypto6.createHash("sha1").update(plan.file).digest("hex").slice(0, 12);
4598
- return path22.join(home(), "plans", id, "notes.md");
4599
- }
4600
- function readNotes(plan) {
4601
- const f = notesFile(plan);
4602
- if (!fs26.existsSync(f)) return [];
4603
- return fs26.readFileSync(f, "utf8").split("\n").slice(1).filter(Boolean).map((l) => {
4604
- const m = /^(\d{4}-\d\d-\d\d \d\d:\d\d) (.*)$/.exec(l);
4605
- return m ? { at: m[1], text: m[2] } : { at: "", text: l };
4606
- });
4607
- }
4608
- function addNote(planFile, text) {
4609
- const plan = loadPlan(planFile);
4610
- const f = notesFile(plan);
4611
- fs26.mkdirSync(path22.dirname(f), { recursive: true });
4612
- if (!fs26.existsSync(f)) fs26.writeFileSync(f, `# plan: ${plan.file}
4613
- `);
4614
- const at = (/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace("T", " ");
4615
- fs26.appendFileSync(f, `${at} ${text.replace(/\s+/g, " ").trim()}
4616
- `);
4617
- return f;
4618
- }
4619
- function planStatus(planFile) {
4620
- const plan = loadPlan(planFile);
5025
+ function dashState(opts = {}) {
5026
+ const limit = Math.min(Math.max(opts.limit ?? 40, 1), 200);
4621
5027
  const runs = [];
4622
- for (const id of listRunIds()) {
4623
- try {
4624
- const m = freshMeta(id);
4625
- if (m.plan?.file === plan.file) runs.push(m);
4626
- } catch {
4627
- }
4628
- }
4629
- const notes = readNotes(plan);
4630
- const tasks = plan.tasks.map((t) => {
4631
- const impl = runs.filter((m) => m.plan.step === t.step && !m.reviewOf);
4632
- const rev = runs.filter((m) => m.plan.step === t.step && m.reviewOf).at(-1);
4633
- const last = impl.at(-1);
4634
- return {
4635
- step: t.step,
4636
- title: t.title,
4637
- runs: impl.length,
4638
- last: last && { id: last.id, state: last.state, status: last.taskStatus },
4639
- review: rev && { id: rev.id, state: rev.state, kind: rev.reviewKind ?? "task", verdict: rev.verdict },
4640
- applied: impl.some((m) => m.applied || m.mode === "write" && m.state === "done" && !m.reverted && !!m.changes?.length),
4641
- note: [...notes].reverse().find((n) => n.text.startsWith(`Task ${t.step}:`))?.text
4642
- };
4643
- });
4644
- return { plan, tasks, rulings: notes.filter((n) => /\bRuling:/.test(n.text)), notesFile: notesFile(plan) };
4645
- }
4646
- var cell = (s, max) => {
4647
- const t = s.replace(/\s+/g, " ").trim();
4648
- return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
4649
- };
4650
- function formatPlanStatus(s) {
4651
- const head = ["TASK", "STATE", "STATUS", "REVIEW", "ROUNDS", "APPLIED", "TITLE", "NOTE"];
4652
- const rows = s.tasks.map((t) => {
4653
- const v = t.review?.verdict;
4654
- const review = v ? `${v.spec}/${v.quality} (c${v.critical} i${v.important} m${v.minor})` : t.review?.state ?? "-";
4655
- return [
4656
- String(t.step),
4657
- t.last?.state ?? "-",
4658
- t.last?.status ?? "-",
4659
- review,
4660
- t.runs ? String(t.runs - 1) : "-",
4661
- t.runs ? t.applied ? "yes" : "no" : "-",
4662
- cell(t.title, 32),
4663
- cell(t.note ?? "", 60)
4664
- ];
4665
- });
4666
- const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
4667
- const fmt = (r) => r.map((c, i) => i === r.length - 1 ? c : c.padEnd(widths[i])).join(" ").trimEnd();
4668
- const rulings = s.rulings.length ? s.rulings.map((n) => ` ${n.at} ${n.text}`).join("\n") : " none";
4669
- return [
4670
- `plan ${s.plan.file} \xB7 ${s.plan.title || planName(s.plan.file)} \xB7 ${s.tasks.length} tasks`,
4671
- "",
4672
- fmt(head),
4673
- ...rows.map(fmt),
4674
- "",
4675
- `Rulings:
4676
- ${rulings}`,
4677
- `notes: ${s.notesFile}`
4678
- ].join("\n");
4679
- }
4680
-
4681
- // src/core/review.ts
4682
- import crypto7 from "node:crypto";
4683
- import fs27 from "node:fs";
4684
- import path23 from "node:path";
4685
- var TEMPLATE2 = { task: "task-reviewer", fix: "re-review", range: "code-reviewer" };
4686
- var section = (title, body) => `## ${title}
4687
-
4688
- ${body.trim() || "(empty)"}
4689
- `;
4690
- function ancestors(m) {
4691
- const out = [];
4692
- for (let id = m.parent; id; ) {
4693
- const a = readMeta(id);
4694
- out.push(a);
4695
- id = a.parent;
4696
- }
4697
- return out;
4698
- }
4699
- function briefOf(m) {
4700
- const root = [m, ...ancestors(m)].at(-1);
4701
- const f = runFile(root.id, "brief.md");
4702
- return fs27.existsSync(f) ? fs27.readFileSync(f, "utf8") : root.task;
4703
- }
4704
- function latestReview(ids) {
4705
- for (const id of listRunIds().reverse()) {
5028
+ const groups = /* @__PURE__ */ new Set();
5029
+ for (const id of listRunIds().slice(-SCAN).reverse()) {
5030
+ let m;
4706
5031
  try {
4707
- const m = readMeta(id);
4708
- if (m.reviewOf && ids.includes(m.reviewOf) && m.state === "done") return m;
5032
+ m = freshMeta(id);
4709
5033
  } catch {
5034
+ continue;
4710
5035
  }
5036
+ if (m.group) groups.add(m.group);
5037
+ if (opts.group && m.group !== opts.group) continue;
5038
+ if (runs.length < limit) runs.push(toRun(m));
4711
5039
  }
4712
- return void 0;
4713
- }
4714
- function diffOf(m, from = m.baseTree) {
4715
- const where = m.mode === "isolate" ? m.worktree : m.repoRoot;
4716
- if (where && fs27.existsSync(where) && from && m.afterTree) return reviewDiff(where, from, m.afterTree);
4717
- if (from !== m.baseTree) {
4718
- 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);
4719
- }
4720
- return readRunFile(m.id, "changes.patch") ?? archivedRun(m.id)?.patch ?? "";
4721
- }
4722
- function runReview(id) {
4723
- const m = freshMeta(id);
4724
- if (isActive(m.state)) throw new UserError(`run ${m.id} is still ${m.state}; pitroom wait ${m.id} first`, 3);
4725
- if (m.reviewOf) throw new UserError(`run ${m.id} is itself a review`);
4726
- if (m.mode === "read") throw new UserError(`run ${m.id} was read-only; there is no change to review`);
4727
- if (!m.changes?.length) throw new UserError(`run ${m.id} made no changes; nothing to review`);
4728
- const chain = ancestors(m);
4729
- const previous = latestReview(chain.map((a) => a.id));
4730
- const dir = m.mode === "isolate" && m.worktree && fs27.existsSync(m.worktree) ? m.cwd : m.dir;
4731
- const job = { of: m.id, dir, implementer: m.ran ?? m.worker, group: m.group, plan: m.plan };
4732
- const title = m.plan ? `Task ${m.plan.step}: ${m.plan.title}` : `run ${m.id}`;
4733
- if (!previous) {
4734
- return {
4735
- ...job,
4736
- kind: "task",
4737
- package: [`# Review package \xB7 ${title}
4738
- `, section("BRIEF", briefOf(m)), section("REPORT", readSummary(m)), section("DIFF", diffOf(m))].join("\n")
4739
- };
4740
- }
4741
- const reviewed = chain.find((a) => a.id === previous.reviewOf);
4742
- const from = m.mode === "isolate" ? reviewed.afterTree : m.baseTree;
4743
5040
  return {
4744
- ...job,
4745
- kind: "fix",
4746
- package: [
4747
- `# Re-review package \xB7 ${title} \xB7 fix round after review ${previous.id}
4748
- `,
4749
- section("BRIEF", briefOf(m)),
4750
- section("PREVIOUS FINDINGS", readSummary(previous)),
4751
- section("FIX REPORT", readSummary(m)),
4752
- section("FIX DIFF", diffOf(m, from))
4753
- ].join("\n")
5041
+ price: primaryPrice().name,
5042
+ running: runs.filter((r) => r.state === "running" || r.state === "queued").length,
5043
+ saved: totals(readLedger(Date.now() - WEEK_MS2)).saved,
5044
+ groups: [...groups].sort(),
5045
+ runs
4754
5046
  };
4755
5047
  }
4756
- function rangeReview(range, dir, planFile) {
4757
- const root = repoRoot(dir);
4758
- if (!root) throw new UserError("--range needs a git repository");
4759
- const i = range.indexOf("..");
4760
- const a = i > 0 ? range.slice(0, i) : "";
4761
- const b = i > 0 ? range.slice(i + 2) || "HEAD" : "";
4762
- if (!a || b.startsWith(".")) throw new UserError(`--range takes A..B (e.g. main..HEAD), not "${range}"`);
4763
- for (const ref of [a, b]) if (!commitOf(root, ref)) throw new UserError(`not a commit: ${ref}`);
4764
- const plan = planFile ? loadPlan(planFile) : void 0;
4765
- const notes = plan ? readNotes(plan) : [];
4766
- const requirements = plan ? [
4767
- `Plan: ${plan.file}`,
4768
- "### Global Constraints",
4769
- plan.constraints || "(none stated in the plan)",
4770
- "### Tasks",
4771
- plan.tasks.map((t) => `- Task ${t.step}: ${t.title}`).join("\n")
4772
- ].join("\n\n") : "(none given; judge the change on its own terms)";
5048
+ var TASK_MAX = 6e3;
5049
+ var PATCH_LINES = 300;
5050
+ function runDetail(id) {
5051
+ if (!RUN_ID3.test(id)) return void 0;
5052
+ let m;
5053
+ try {
5054
+ m = freshMeta(id);
5055
+ } catch {
5056
+ return void 0;
5057
+ }
5058
+ const start = Date.parse(m.startedAt);
5059
+ const kept = isActive(m.state) ? void 0 : archivedRun(id);
5060
+ let steps = kept?.steps ?? [];
5061
+ if (!steps.length) try {
5062
+ const events = readRunFile(id, "events.jsonl");
5063
+ steps = (events ? getBackend((m.ran ?? m.worker).backend).parse(events).timeline ?? [] : []).map((s) => ({
5064
+ ...s,
5065
+ t: s.at && Number.isFinite(start) ? Math.max(0, Math.round((s.at - start) / 1e3)) : void 0
5066
+ }));
5067
+ } catch {
5068
+ }
5069
+ const rel = (t) => [m.cwd, m.dir].filter(Boolean).reduce((x, base2) => x.split(`${base2}/`).join(""), t);
5070
+ steps = steps.map((x) => ({ ...x, text: rel(x.text) }));
5071
+ const answer = isActive(m.state) ? "" : readSummary(m).replace(/^SUMMARY:\s*SPEC[^\n]*\n+/i, "").replace(/^DETAILS:[ \t]*\n+/i, "").trim();
5072
+ const lastSay = steps.at(-1);
5073
+ if (lastSay?.kind === "say" && answer && (answer.includes(lastSay.text.slice(0, 80)) || lastSay.text.includes(answer.slice(0, 80)))) steps.pop();
5074
+ const fullPatch = readRunFile(id, "changes.patch");
5075
+ const patch = fullPatch ?? kept?.patch ?? "";
5076
+ const patchLines = patch.split("\n");
5077
+ const u = m.usage;
4773
5078
  return {
4774
- kind: "range",
4775
- of: `${a}..${b}`,
4776
- dir: root,
4777
- group: plan ? planName(plan.file) : void 0,
4778
- package: [
4779
- `# Review package \xB7 ${a}..${b}
4780
- `,
4781
- section("WHAT WAS IMPLEMENTED", plan ? `${plan.title}
4782
-
4783
- ${plan.header}` : "The commits below."),
4784
- section("REQUIREMENTS", requirements),
4785
- ...notes.length ? [section("NOTES FROM EXECUTION", notes.map((n) => `- ${n.text}`).join("\n"))] : [],
4786
- rangeDiff(root, a, b)
4787
- ].join("\n")
5079
+ id,
5080
+ state: m.state,
5081
+ card: toRun(m),
5082
+ 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),
5083
+ steps,
5084
+ answer,
5085
+ changes: m.changes ?? [],
5086
+ fileDiffs: fileDiffs(patch, m.changes ?? [], fullPatch === void 0 && kept?.patchTruncated),
5087
+ patch: patch ? `${patchLines.slice(0, PATCH_LINES).join("\n")}${patchLines.length > PATCH_LINES ? `
5088
+ \u2026 ${patchLines.length - PATCH_LINES} more lines (pitroom show ${id} --patch)` : ""}` : void 0,
5089
+ info: {
5090
+ worker: (m.ran ?? m.worker).backend,
5091
+ // the model has its own line
5092
+ model: m.resolvedModel,
5093
+ mode: m.mode,
5094
+ started: m.startedAt,
5095
+ time: elapsed(m),
5096
+ steps: u?.steps,
5097
+ toolCalls: u?.toolCalls,
5098
+ tokens: u?.total,
5099
+ returnedTokens: m.returnedTokens,
5100
+ cost: u?.cost,
5101
+ saved: m.savedUsd,
5102
+ group: m.group,
5103
+ directory: m.dir
5104
+ },
5105
+ attempts: m.attempts ?? [],
5106
+ warnings: m.warnings ?? [],
5107
+ refs: m.refs ? { valid: m.refs.valid, total: m.refs.total, invalid: m.refs.invalid.map((r) => `${r.ref} (${r.reason})`) } : void 0,
5108
+ verify: m.verifyResult && m.verify ? { command: m.verify, ok: m.verifyResult.ok, tail: m.verifyResult.tail } : void 0,
5109
+ error: m.error,
5110
+ 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 ?? [] },
5111
+ report: isActive(m.state) ? progress(m) : formatReport(m, void 0, 120)
4788
5112
  };
4789
5113
  }
4790
- function pickReviewer(job) {
4791
- const eff = effective();
4792
- const tiers = eff.tiers.value;
4793
- if (job.kind === "range") return tiers.capable;
4794
- if (tiers.review) return tiers.review;
4795
- const implementer = job.implementer;
4796
- if (!implementer) return void 0;
4797
- const def = parseTarget(eff.worker.value, DEFAULT_BACKEND).backend;
4798
- const candidates = [tiers.standard, tiers.capable, ...eff.fallback.value, eff.worker.value].filter((s) => !!s);
4799
- return candidates.find((c) => parseTarget(c, def).backend !== implementer.backend);
4800
- }
4801
- function writePackage(job) {
4802
- const g = gitDir(job.dir);
4803
- if (!g) throw new UserError(`not a git repository: ${job.dir}`);
4804
- const file2 = path23.join(g, "pitroom", `review-${crypto7.randomBytes(4).toString("hex")}.md`);
4805
- fs27.mkdirSync(path23.dirname(file2), { recursive: true });
4806
- fs27.writeFileSync(file2, job.package);
4807
- return file2;
5114
+ var HEADERS = {
5115
+ "cache-control": "no-store",
5116
+ "x-content-type-options": "nosniff",
5117
+ "referrer-policy": "no-referrer",
5118
+ // Own files only, plus the hash of the one inline theme script; styles may be inline (the components position popups with them).
5119
+ "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'`
5120
+ };
5121
+ var ASSETS = { "/assets/app.js": "text/javascript; charset=utf-8", "/assets/app.css": "text/css; charset=utf-8" };
5122
+ var assetsDir = () => fileURLToPath3(new URL("./ui/", import.meta.url));
5123
+ var assetCache = /* @__PURE__ */ new Map();
5124
+ function readAsset(route) {
5125
+ try {
5126
+ const file2 = path21.join(assetsDir(), path21.basename(route));
5127
+ const { mtimeMs } = fs25.statSync(file2);
5128
+ const hit = assetCache.get(route);
5129
+ if (hit && hit.mtimeMs === mtimeMs) return hit.data;
5130
+ const data = fs25.readFileSync(file2);
5131
+ assetCache.set(route, { mtimeMs, data });
5132
+ return data;
5133
+ } catch {
5134
+ return void 0;
5135
+ }
4808
5136
  }
4809
-
4810
- // src/core/models.ts
4811
- var base = (model) => (model ?? "").split("#")[0];
4812
- function usage() {
4813
- const eff = effective();
4814
- const out = /* @__PURE__ */ new Map();
4815
- const add = (spec, label) => {
4816
- let t;
4817
- try {
4818
- t = parseTarget(spec, "opencode");
4819
- } catch {
4820
- return;
5137
+ function handler(touch2) {
5138
+ const send = (res, code, type, body) => {
5139
+ res.writeHead(code, { ...HEADERS, "content-type": type });
5140
+ res.end(body);
5141
+ };
5142
+ const json = (res, code, value) => send(res, code, "application/json; charset=utf-8", JSON.stringify(value));
5143
+ return (req, res) => {
5144
+ touch2();
5145
+ if (!LOCAL_HOST.test(req.headers.host ?? "")) return json(res, 403, { error: "forbidden host" });
5146
+ if (req.method !== "GET" && req.method !== "HEAD") return json(res, 405, { error: "read-only" });
5147
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
5148
+ if (url.pathname === "/") return send(res, 200, "text/html; charset=utf-8", readAsset("/assets/app.js") ? PAGE : MISSING);
5149
+ const type = ASSETS[url.pathname];
5150
+ if (type) {
5151
+ const data = readAsset(url.pathname);
5152
+ if (!data) return json(res, 404, { error: "dashboard files missing" });
5153
+ res.writeHead(200, { ...HEADERS, "content-type": type });
5154
+ return void res.end(req.method === "HEAD" ? void 0 : data);
4821
5155
  }
4822
- const model = base(t.model) || base(eff.models.value[t.backend]);
4823
- if (!model) return;
4824
- const key = `${t.backend}:${model}`;
4825
- out.set(key, [...out.get(key) ?? [], label]);
5156
+ if (url.pathname === "/api/state") {
5157
+ const group = url.searchParams.get("group") ?? void 0;
5158
+ const limit = Number(url.searchParams.get("limit") ?? 40);
5159
+ return json(res, 200, dashState({ group: group && group.length <= 100 ? group : void 0, limit: Number.isFinite(limit) ? limit : 40 }));
5160
+ }
5161
+ if (url.pathname === "/api/history") {
5162
+ const q = url.searchParams;
5163
+ const days = Number(q.get("days") ?? 0);
5164
+ return json(res, 200, listHistory({
5165
+ text: (q.get("q") ?? "").slice(0, 200),
5166
+ model: q.get("model") || void 0,
5167
+ backend: q.get("backend") || void 0,
5168
+ state: q.get("state") || void 0,
5169
+ group: q.get("group") || void 0,
5170
+ sinceMs: days > 0 ? Date.now() - days * 864e5 : void 0,
5171
+ beforeId: RUN_ID3.test(q.get("before") ?? "") ? q.get("before") : void 0,
5172
+ limit: Number(q.get("limit") ?? 30) || 30
5173
+ }));
5174
+ }
5175
+ if (url.pathname === "/api/stats") {
5176
+ const days = Number(url.searchParams.get("days") ?? 30);
5177
+ return json(res, 200, { ...historyStats(days > 0 ? Date.now() - days * 864e5 : void 0), price: primaryPrice().name });
5178
+ }
5179
+ const m = /^\/api\/run\/([^/]+)$/.exec(url.pathname);
5180
+ if (m) {
5181
+ const d = runDetail(m[1]);
5182
+ return d ? json(res, 200, d) : json(res, 404, { error: "no such run" });
5183
+ }
5184
+ return json(res, 404, { error: "not found" });
4826
5185
  };
4827
- add(eff.worker.value, "default worker");
4828
- for (const [name, spec] of Object.entries(eff.tiers.value)) add(spec, `tier ${name}`);
4829
- eff.fallback.value.forEach((spec) => add(spec, "fallback"));
4830
- for (const [backend, model] of Object.entries(eff.models.value)) add(`${backend}:${model}`, "models");
4831
- return out;
4832
5186
  }
4833
- function modelTable(opts = {}) {
4834
- const costs = effective().costs.value;
4835
- const used = usage();
4836
- const seen = /* @__PURE__ */ new Map();
4837
- for (const e of readLedger()) {
4838
- if (!e.backend || !e.model) continue;
4839
- const key = `${e.backend}:${base(e.model)}`;
4840
- const a = seen.get(key) ?? { runs: 0, tokens: 0, usd: 0 };
4841
- seen.set(key, { runs: a.runs + 1, tokens: a.tokens + e.tokens, usd: a.usd + e.workerCost });
4842
- }
4843
- const rows = [];
4844
- const sources = [];
4845
- let hiddenOpenCode = 0;
4846
- const known = /* @__PURE__ */ new Set();
4847
- for (const b of allBackends()) {
4848
- if (opts.backend && b.id !== opts.backend) continue;
4849
- const catalog5 = b.catalog?.() ?? { models: [], source: "no model list for this worker" };
4850
- sources.push(`${b.id}: ${catalog5.source}`);
4851
- for (const m of catalog5.models) {
4852
- const key = `${b.id}:${m.id}`;
4853
- known.add(key);
4854
- const s = seen.get(key);
4855
- const row = {
4856
- worker: b.id,
4857
- model: m.id,
4858
- efforts: m.efforts?.length ? m.efforts : void 0,
4859
- defaultEffort: m.defaultEffort,
4860
- cost: costs[key],
4861
- runs: s?.runs ?? 0,
4862
- avgTokens: s ? Math.round(s.tokens / s.runs) : void 0,
4863
- reportedUsdPerRun: s && s.usd > 0 ? s.usd / s.runs : void 0,
4864
- inUse: used.get(key) ?? []
5187
+ function startDash(opts) {
5188
+ let lastRequest = Date.now();
5189
+ const server = http.createServer(handler(() => lastRequest = Date.now()));
5190
+ return new Promise((resolve2, reject) => {
5191
+ server.once("error", reject);
5192
+ server.listen(opts.port, "127.0.0.1", () => {
5193
+ const port = server.address().port;
5194
+ let done;
5195
+ const closed = new Promise((r) => done = r);
5196
+ let timer;
5197
+ const close = () => {
5198
+ if (timer) clearInterval(timer);
5199
+ server.closeAllConnections?.();
5200
+ return new Promise((r) => server.close(() => (done(), r())));
4865
5201
  };
4866
- const relevant = row.inUse.length || row.runs || row.cost !== void 0;
4867
- if (b.id === "opencode" && !opts.all && !relevant) hiddenOpenCode++;
4868
- else rows.push(row);
4869
- }
4870
- }
4871
- for (const [key, labels] of used) {
4872
- const [worker, ...rest] = key.split(":");
4873
- if (known.has(key) || opts.backend && worker !== opts.backend) continue;
4874
- const s = seen.get(key);
4875
- 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 });
4876
- }
4877
- rows.sort(
4878
- (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)
4879
- );
4880
- return { rows, sources, hiddenOpenCode };
5202
+ if (opts.idleMs && opts.idleMs > 0) {
5203
+ timer = setInterval(() => Date.now() - lastRequest > opts.idleMs && void close(), Math.min(6e4, opts.idleMs));
5204
+ timer.unref();
5205
+ }
5206
+ resolve2({ port, url: `http://127.0.0.1:${port}/`, closed, close });
5207
+ });
5208
+ });
4881
5209
  }
4882
- var compact2 = (n) => n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n);
4883
- function formatModels(t) {
4884
- const efforts = (r) => r.efforts ? r.efforts.map((e) => e === r.defaultEffort ? `${e}*` : e).join("/") : "-";
4885
- const cells = t.rows.map((r) => [
4886
- r.worker,
4887
- r.model,
4888
- efforts(r),
4889
- r.cost !== void 0 ? String(r.cost) : "?",
4890
- r.runs ? String(r.runs) : "-",
4891
- r.avgTokens !== void 0 ? compact2(r.avgTokens) : "-",
4892
- r.reportedUsdPerRun !== void 0 ? `$${r.reportedUsdPerRun.toFixed(3)}` : "-",
4893
- r.inUse.join(", ") || "-"
4894
- ]);
4895
- const head = ["worker", "model", "effort (* default)", "cost", "runs", "avg tokens", "$/run", "in use"];
4896
- const widths = head.map((h, i) => Math.max(h.length, ...cells.map((c) => c[i].length)));
4897
- const line = (c) => c.map((x, i) => x.padEnd(widths[i])).join(" ").trimEnd();
4898
- const out = [line(head), ...cells.map(line), ""];
4899
- out.push('cost: your relative cost from the config ("costs": {"codex:gpt-6-sol": 1, \u2026}); ? = not set. Pitroom cannot know vendor prices.');
4900
- 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.");
4901
- if (t.hiddenOpenCode) out.push(`${t.hiddenOpenCode} more OpenCode models not shown (use --all).`);
4902
- out.push(...t.sources.map((s) => `source ${s}`));
4903
- return out.join("\n");
5210
+ var registryFile = () => path21.join(home(), "dash.json");
5211
+ async function ping(port) {
5212
+ try {
5213
+ const r = await fetch(`http://127.0.0.1:${port}/api/state?limit=1`, { signal: AbortSignal.timeout(1500) });
5214
+ return r.ok;
5215
+ } catch {
5216
+ return false;
5217
+ }
4904
5218
  }
4905
-
4906
- // src/core/dash.ts
4907
- import { spawn as spawn4 } from "node:child_process";
4908
- import crypto8 from "node:crypto";
4909
- import fs28 from "node:fs";
4910
- import http from "node:http";
4911
- import path24 from "node:path";
4912
- import { fileURLToPath as fileURLToPath3 } from "node:url";
4913
-
4914
- // src/core/file-diff.ts
4915
- var PREVIEW_LINES = 300;
4916
- function gitPath(value, prefix = true) {
4917
- let path27 = value;
4918
- if (value.startsWith('"') && value.endsWith('"')) {
4919
- const bytes = [];
4920
- const escapes = { t: " ", n: "\n", r: "\r", b: "\b", f: "\f", v: "\v" };
4921
- for (const match of value.slice(1, -1).matchAll(/\\([0-7]{1,3}|.)|([^\\]+)/g)) {
4922
- if (match[1] && /^[0-7]+$/.test(match[1])) bytes.push(parseInt(match[1], 8));
4923
- else bytes.push(...new TextEncoder().encode(match[2] ?? escapes[match[1]] ?? match[1]));
4924
- }
4925
- path27 = new TextDecoder().decode(new Uint8Array(bytes));
5219
+ async function runningDash() {
5220
+ try {
5221
+ const r = JSON.parse(fs25.readFileSync(registryFile(), "utf8"));
5222
+ if (isAlive(r.pid) && await ping(r.port)) return { ...r, url: `http://127.0.0.1:${r.port}/` };
5223
+ } catch {
4926
5224
  }
4927
- return prefix ? path27.replace(/^[ab]\//, "") : path27;
5225
+ fs25.rmSync(registryFile(), { force: true });
5226
+ return void 0;
4928
5227
  }
4929
- function headerPath(header) {
4930
- const quoted = header.match(/^diff --git ("(?:[^"\\]|\\.)*"|\S+) ("(?:[^"\\]|\\.)*"|\S+)$/);
4931
- if (quoted) return gitPath(quoted[2]);
4932
- return gitPath(header.slice(header.lastIndexOf(" b/") + 1));
5228
+ function openBrowser(url) {
5229
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
5230
+ try {
5231
+ spawn4(cmd, args, { stdio: "ignore", detached: true }).on("error", () => void 0).unref();
5232
+ } catch {
5233
+ }
4933
5234
  }
4934
- function fileDiffs(patch, changes, sourceTruncated = false) {
4935
- const files = [];
4936
- let file2;
4937
- let oldLine = 0;
4938
- let newLine = 0;
4939
- let oldLeft = 0;
4940
- let newLeft = 0;
4941
- let inHunk = false;
4942
- let row = 0;
4943
- const finishHunk = () => {
4944
- if (file2 && (oldLeft > 0 || newLeft > 0)) file2.incomplete = true;
4945
- oldLeft = newLeft = 0;
4946
- inHunk = false;
4947
- };
4948
- const add = (line) => {
4949
- if (!file2) return;
4950
- if (file2.lines.length < PREVIEW_LINES) file2.lines.push({ ...line, id: String(row) });
4951
- else file2.omittedLines++;
4952
- row++;
4953
- };
4954
- for (const line of patch.split("\n")) {
4955
- if (line.startsWith("diff --git ")) {
4956
- finishHunk();
4957
- file2 = { path: headerPath(line), status: "M", lines: [], additions: 0, deletions: 0, omittedLines: 0, binary: false, incomplete: false };
4958
- files.push(file2);
4959
- row = 0;
4960
- continue;
4961
- }
4962
- if (!file2) continue;
4963
- const hunk = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
4964
- if (hunk) {
4965
- finishHunk();
4966
- oldLine = Number(hunk[1]);
4967
- newLine = Number(hunk[3]);
4968
- oldLeft = Number(hunk[2] ?? 1);
4969
- newLeft = Number(hunk[4] ?? 1);
4970
- inHunk = true;
4971
- add({ type: "hunk", content: line });
4972
- } else if (inHunk && line.startsWith("\")) {
4973
- add({ type: "meta", content: line });
4974
- } else if (inHunk && (oldLeft > 0 || newLeft > 0)) {
4975
- if (line.startsWith("+") && newLeft > 0) {
4976
- file2.additions++;
4977
- newLeft--;
4978
- add({ type: "added", newLine: newLine++, content: line.slice(1) });
4979
- } else if (line.startsWith("-") && oldLeft > 0) {
4980
- file2.deletions++;
4981
- oldLeft--;
4982
- add({ type: "removed", oldLine: oldLine++, content: line.slice(1) });
4983
- } else if (line.startsWith(" ") && oldLeft > 0 && newLeft > 0) {
4984
- oldLeft--;
4985
- newLeft--;
4986
- add({ type: "context", oldLine: oldLine++, newLine: newLine++, content: line.slice(1) });
4987
- } else {
4988
- file2.incomplete = true;
4989
- }
4990
- } else if (!inHunk) {
4991
- if (line.startsWith("+++ ") && line !== "+++ /dev/null") file2.path = gitPath(line.slice(4).replace(/\t$/, ""));
4992
- else if (line.startsWith("--- ") && line !== "--- /dev/null") file2.path = gitPath(line.slice(4).replace(/\t$/, ""));
4993
- else if (/^(GIT binary patch|Binary files .* differ)$/.test(line)) file2.binary = true;
4994
- else if (/^(new file mode|deleted file mode|old mode|new mode|rename from|rename to|similarity index) /.test(line)) {
4995
- if (line.startsWith("new file mode ")) file2.status = "A";
4996
- if (line.startsWith("deleted file mode ")) file2.status = "D";
4997
- if (line.startsWith("rename to ")) {
4998
- file2.path = gitPath(line.slice(10), false);
4999
- file2.status = "R";
5000
- }
5001
- add({ type: "meta", content: line });
5235
+ async function dashCommand(o) {
5236
+ const existing = await runningDash();
5237
+ if (o.stop) {
5238
+ if (!existing) throw new UserError("pitroom dash is not running");
5239
+ process.kill(existing.pid, "SIGTERM");
5240
+ fs25.rmSync(registryFile(), { force: true });
5241
+ o.log(`stopped pitroom dash (${existing.url})`);
5242
+ return 0;
5243
+ }
5244
+ if (existing && !o.serve) {
5245
+ o.log(existing.url);
5246
+ if (o.open) openBrowser(existing.url);
5247
+ return 0;
5248
+ }
5249
+ if (o.detach) {
5250
+ const child = spawn4(process.execPath, [process.argv[1], "dash", "--serve", ...o.port !== void 0 ? ["--port", String(o.port)] : [], "--idle", "1h"], {
5251
+ detached: true,
5252
+ stdio: "ignore",
5253
+ env: process.env
5254
+ });
5255
+ child.on("error", () => void 0);
5256
+ child.unref();
5257
+ for (let i = 0; i < 60; i++) {
5258
+ await new Promise((r2) => setTimeout(r2, 100));
5259
+ const r = await runningDash();
5260
+ if (r && r.pid === child.pid) {
5261
+ o.log(r.url);
5262
+ if (o.open) openBrowser(r.url);
5263
+ return 0;
5002
5264
  }
5003
5265
  }
5266
+ throw new UserError("pitroom dash did not start (is the port taken? try --port 0)");
5004
5267
  }
5005
- finishHunk();
5006
- if (sourceTruncated && file2) file2.incomplete = true;
5007
- for (const f of files) {
5008
- if (f.incomplete || f.binary) f.additions = f.deletions = void 0;
5268
+ importRuns();
5269
+ const explicit = o.port !== void 0;
5270
+ let dash;
5271
+ try {
5272
+ dash = await startDash({ port: o.port ?? DEFAULT_PORT, idleMs: o.idleMs });
5273
+ } catch (e) {
5274
+ if (explicit || e.code !== "EADDRINUSE") throw new UserError(`pitroom dash: ${e.message}`);
5275
+ dash = await startDash({ port: 0, idleMs: o.idleMs });
5009
5276
  }
5010
- const byPath = new Map(files.map((f) => [f.path, f]));
5011
- const result = changes.map((change) => {
5012
- const diff = byPath.get(change.path);
5013
- byPath.delete(change.path);
5014
- return diff ? { ...diff, status: change.status } : {
5015
- ...change,
5016
- lines: [],
5017
- omittedLines: 0,
5018
- binary: false,
5019
- incomplete: sourceTruncated,
5020
- unavailable: true
5021
- };
5022
- });
5023
- return [...result, ...byPath.values()];
5277
+ fs25.mkdirSync(home(), { recursive: true });
5278
+ fs25.writeFileSync(registryFile(), JSON.stringify({ pid: process.pid, port: dash.port }));
5279
+ const stop = () => void dash.close();
5280
+ process.once("SIGINT", stop);
5281
+ process.once("SIGTERM", stop);
5282
+ if (!o.serve) o.log(`${dash.url}
5283
+ (read-only, this machine only; Ctrl-C stops it)`);
5284
+ if (o.open) openBrowser(dash.url);
5285
+ await dash.closed;
5286
+ try {
5287
+ if (JSON.parse(fs25.readFileSync(registryFile(), "utf8")).pid === process.pid) fs25.rmSync(registryFile(), { force: true });
5288
+ } catch {
5289
+ }
5290
+ return 0;
5024
5291
  }
5025
5292
 
5026
- // src/core/dash-page.ts
5027
- 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){}})()";
5028
- 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";
5029
- var PAGE = `<!doctype html>
5030
- <html lang="en" class="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
5031
- <meta name="color-scheme" content="dark light"><title>Pitroom</title><link rel="icon" href="${ICON3}">
5032
- <script>${THEME_SCRIPT}</script><link rel="stylesheet" href="/assets/app.css"></head>
5033
- <body><div id="root"></div><script type="module" src="/assets/app.js"></script></body></html>`;
5034
- 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>`;
5035
-
5036
- // src/core/dash.ts
5037
- var DEFAULT_PORT = 7878;
5038
- var WEEK_MS2 = 7 * 24 * 3600 * 1e3;
5039
- var SCAN = 400;
5040
- var RUN_ID3 = /^\d{8}-\d{6}-[0-9a-f]{4}$/;
5041
- var LOCAL_HOST = /^(127\.0\.0\.1|localhost|\[::1\]):\d+$/i;
5042
- var oneLine6 = (s, max) => {
5043
- const t = s.replace(/\s+/g, " ").trim();
5044
- return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
5293
+ // src/cli/mcp-http.ts
5294
+ var DEFAULT_PORT2 = 7117;
5295
+ var MAX_BODY = 4e6;
5296
+ var MAX_SESSIONS = 64;
5297
+ var IDLE_MS = 60 * 6e4;
5298
+ var PATH = "/mcp";
5299
+ var tokenFile = () => path22.join(home(), "mcp-token");
5300
+ var readToken = (file2) => {
5301
+ try {
5302
+ const kept = fs26.readFileSync(file2, "utf8").trim();
5303
+ return kept.length >= 16 ? kept : void 0;
5304
+ } catch {
5305
+ return void 0;
5306
+ }
5045
5307
  };
5046
- function findings(m) {
5047
- const v = m.verdict;
5048
- if (!v) return "";
5049
- const parts = [v.critical && `${v.critical} critical`, v.important && `${v.important} important`, v.minor && `${v.minor} minor`].filter(Boolean);
5050
- return parts.length ? parts.join(" \xB7 ") : "no findings";
5308
+ function mcpToken() {
5309
+ const env = process.env.PITROOM_MCP_TOKEN?.trim();
5310
+ if (env) {
5311
+ if (env.length < 16) throw new UserError("PITROOM_MCP_TOKEN is too short to be a secret (16 characters at least)", 3);
5312
+ return { token: env, from: "PITROOM_MCP_TOKEN" };
5313
+ }
5314
+ const file2 = tokenFile();
5315
+ let token = readToken(file2);
5316
+ if (!token) {
5317
+ fs26.mkdirSync(path22.dirname(file2), { recursive: true });
5318
+ token = crypto7.randomBytes(32).toString("hex");
5319
+ try {
5320
+ fs26.writeFileSync(file2, `${token}
5321
+ `, { mode: 384, flag: "wx" });
5322
+ } catch (e) {
5323
+ if (e.code !== "EEXIST") throw e;
5324
+ token = readToken(file2);
5325
+ if (!token) throw new UserError(`${file2} holds no usable token: delete it and start again`, 3);
5326
+ }
5327
+ }
5328
+ try {
5329
+ fs26.chmodSync(file2, 384);
5330
+ } catch {
5331
+ }
5332
+ return { token, from: file2 };
5051
5333
  }
5052
- function toRun(m) {
5053
- const l = m.state === "running" ? live(m) : void 0;
5054
- return {
5055
- id: m.id,
5056
- state: m.state,
5057
- kind: kind(m),
5058
- // the model that really ran, once the CLI has said which; until then just the worker, never a guess
5059
- worker: workerName(ranTarget(m)),
5060
- task: what(m),
5061
- group: m.group,
5062
- startedAt: m.startedAt,
5063
- time: elapsed(m),
5064
- steps: l ? l.steps : m.usage?.steps ?? 0,
5065
- tokens: m.usage?.total,
5066
- saved: m.savedUsd || void 0,
5067
- verdict: m.verdict ? `SPEC ${m.verdict.spec.toUpperCase()} \xB7 QUALITY ${m.verdict.quality.toUpperCase()}` : m.auditVerdict ? `AUDIT ${m.auditVerdict.toUpperCase()}` : void 0,
5068
- audit: auditBadge(m),
5069
- changes: m.changes?.length || void 0,
5070
- applied: m.applied || void 0,
5071
- 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)
5334
+ var LOCAL_ORIGIN = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i;
5335
+ var sameSecret = (given, token) => {
5336
+ const a = crypto7.createHash("sha256").update(given).digest();
5337
+ const b = crypto7.createHash("sha256").update(token).digest();
5338
+ return crypto7.timingSafeEqual(a, b);
5339
+ };
5340
+ function reply(res, status, body, headers = {}) {
5341
+ if (res.headersSent) return void res.end();
5342
+ const text = body === void 0 ? "" : JSON.stringify(body);
5343
+ res.writeHead(status, { ...text ? { "Content-Type": "application/json" } : {}, "Content-Length": Buffer.byteLength(text), ...headers });
5344
+ res.end(text);
5345
+ }
5346
+ var failure5 = (code, message) => ({ jsonrpc: "2.0", id: null, error: { code, message } });
5347
+ function readBody(req) {
5348
+ return new Promise((resolve2, reject) => {
5349
+ const chunks = [];
5350
+ let size = 0;
5351
+ req.on("data", (c) => {
5352
+ size += c.length;
5353
+ if (size <= MAX_BODY) chunks.push(c);
5354
+ });
5355
+ req.on("end", () => resolve2(size > MAX_BODY ? "too-big" : Buffer.concat(chunks).toString("utf8")));
5356
+ req.on("error", reject);
5357
+ });
5358
+ }
5359
+ var wantsStream = (parsed) => {
5360
+ const messages = Array.isArray(parsed) ? parsed : [parsed];
5361
+ return messages.some((m) => m && typeof m === "object" && m.id !== void 0 && m.id !== null && progressToken(m.params) !== void 0);
5362
+ };
5363
+ function evictIdle(sessions) {
5364
+ let oldest;
5365
+ for (const entry of sessions) if (!entry[1].session.inflight.size && (!oldest || entry[1].lastSeen < oldest[1].lastSeen)) oldest = entry;
5366
+ if (!oldest) return false;
5367
+ sessions.delete(oldest[0]);
5368
+ return true;
5369
+ }
5370
+ function makeHandler(token, sessions) {
5371
+ return async (req, res) => {
5372
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
5373
+ if (url.pathname !== PATH) return reply(res, 404, failure5(-32600, `not found: the endpoint is ${PATH}`));
5374
+ if (!LOCAL_HOST.test(req.headers.host ?? "")) return reply(res, 403, failure5(-32600, "forbidden host"));
5375
+ const origin = req.headers.origin;
5376
+ if (origin !== void 0 && !LOCAL_ORIGIN.test(origin)) return reply(res, 403, failure5(-32600, "forbidden origin"));
5377
+ const auth = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? "")?.[1]?.trim();
5378
+ if (!auth || !sameSecret(auth, token)) return reply(res, 401, failure5(-32001, "a bearer token is needed"), { "WWW-Authenticate": "Bearer" });
5379
+ const sid = typeof req.headers["mcp-session-id"] === "string" ? req.headers["mcp-session-id"] : void 0;
5380
+ if (req.method === "DELETE") {
5381
+ const kept2 = sid ? sessions.get(sid) : void 0;
5382
+ if (!kept2) return reply(res, 404, failure5(-32600, "no such session"));
5383
+ endSession(kept2.session);
5384
+ sessions.delete(sid);
5385
+ return reply(res, 204);
5386
+ }
5387
+ if (req.method !== "POST") return reply(res, 405, failure5(-32600, "POST requests only"), { Allow: "POST, DELETE" });
5388
+ const body = await readBody(req);
5389
+ if (body === "too-big") return reply(res, 413, failure5(-32600, `a request body is at most ${MAX_BODY} bytes`));
5390
+ let parsed;
5391
+ try {
5392
+ parsed = JSON.parse(body);
5393
+ } catch {
5394
+ return reply(res, 400, parseError());
5395
+ }
5396
+ const headers = {};
5397
+ let kept;
5398
+ const single = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : void 0;
5399
+ if (single?.method === "initialize") {
5400
+ const old = sid ? sessions.get(sid) : void 0;
5401
+ if (old) {
5402
+ endSession(old.session);
5403
+ sessions.delete(sid);
5404
+ }
5405
+ if (sessions.size >= MAX_SESSIONS && !evictIdle(sessions)) return reply(res, 503, failure5(-32e3, "too many sessions with requests in flight"));
5406
+ const id = crypto7.randomUUID();
5407
+ kept = { session: newSession(), lastSeen: Date.now() };
5408
+ sessions.set(id, kept);
5409
+ headers["Mcp-Session-Id"] = id;
5410
+ } else {
5411
+ if (!sid) return reply(res, 400, failure5(-32600, "Mcp-Session-Id is missing: initialize first"));
5412
+ kept = sessions.get(sid);
5413
+ if (!kept) return reply(res, 404, failure5(-32600, "no such session: initialize again"));
5414
+ }
5415
+ kept.lastSeen = Date.now();
5416
+ if (!wantsStream(parsed)) {
5417
+ const out2 = await handlePayload(kept.session, parsed, () => {
5418
+ });
5419
+ kept.lastSeen = Date.now();
5420
+ return out2 === void 0 ? reply(res, 202, void 0, headers) : reply(res, 200, out2, headers);
5421
+ }
5422
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", ...headers });
5423
+ const event = (msg) => {
5424
+ if (!res.writableEnded && !res.destroyed) res.write(`event: message
5425
+ data: ${JSON.stringify(msg)}
5426
+
5427
+ `);
5428
+ };
5429
+ const out = await handlePayload(kept.session, parsed, event);
5430
+ if (out !== void 0) event(out);
5431
+ kept.lastSeen = Date.now();
5432
+ res.end();
5072
5433
  };
5073
5434
  }
5074
- function dashState(opts = {}) {
5075
- const limit = Math.min(Math.max(opts.limit ?? 40, 1), 200);
5076
- const runs = [];
5077
- const groups = /* @__PURE__ */ new Set();
5078
- for (const id of listRunIds().slice(-SCAN).reverse()) {
5079
- let m;
5435
+ async function serveMcpHttp(opts) {
5436
+ const { token, from } = mcpToken();
5437
+ const sessions = /* @__PURE__ */ new Map();
5438
+ const handler2 = makeHandler(token, sessions);
5439
+ const server = http2.createServer((req, res) => {
5440
+ handler2(req, res).catch((e) => {
5441
+ process.stderr.write(`pitroom mcp: ${String(e.stack ?? e)}
5442
+ `);
5443
+ reply(res, 500, failure5(-32603, "internal error"));
5444
+ });
5445
+ });
5446
+ const sweep = setInterval(() => {
5447
+ for (const [id, k] of sessions) if (Date.now() - k.lastSeen > IDLE_MS && !k.session.inflight.size) sessions.delete(id);
5448
+ }, 6e4);
5449
+ sweep.unref();
5450
+ const port = await new Promise((resolve2, reject) => {
5451
+ 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));
5452
+ server.listen(opts.port, "127.0.0.1", () => resolve2(server.address().port));
5453
+ });
5454
+ const url = `http://127.0.0.1:${port}${PATH}`;
5455
+ const secret = from === "PITROOM_MCP_TOKEN" ? "$PITROOM_MCP_TOKEN" : `$(cat "${from}")`;
5456
+ console.log(`pitroom mcp: listening on ${url} (127.0.0.1 only; token from ${from})`);
5457
+ console.log(` working in ${process.cwd()} (-d DIR starts it in another project; the tools' "dir" argument picks one per call)`);
5458
+ console.log(` Claude Code: claude mcp add --transport http pitroom ${url} --header "Authorization: Bearer ${secret}"`);
5459
+ console.log(' other clients: URL as above, header "Authorization: Bearer <the token>"');
5460
+ console.log(" anyone with the token can run workers as you: keep it private");
5461
+ await new Promise((resolve2) => {
5462
+ const stop = () => resolve2();
5463
+ process.once("SIGINT", stop);
5464
+ process.once("SIGTERM", stop);
5465
+ });
5466
+ clearInterval(sweep);
5467
+ for (const k of sessions.values()) endSession(k.session);
5468
+ server.closeAllConnections();
5469
+ await new Promise((resolve2) => server.close(() => resolve2()));
5470
+ return 0;
5471
+ }
5472
+
5473
+ // src/cli/commands.ts
5474
+ import { spawnSync as spawnSync8 } from "node:child_process";
5475
+ import fs31 from "node:fs";
5476
+ import path27 from "node:path";
5477
+ import { fileURLToPath as fileURLToPath4 } from "node:url";
5478
+
5479
+ // src/core/init.ts
5480
+ import fs27 from "node:fs";
5481
+ import path23 from "node:path";
5482
+ var FREE = /-free$/;
5483
+ var TIER_ORDER = [["cheap", "opencode"], ["standard", "codex"], ["capable", "claude"]];
5484
+ var isFound = (binary5) => path23.isAbsolute(binary5) && fs27.existsSync(binary5);
5485
+ function planInit(opts = {}) {
5486
+ const file2 = configPath();
5487
+ const workers = backendIds().map((id) => {
5488
+ const b = getBackend(id);
5489
+ const binary5 = b.binary();
5490
+ return { id, name: b.name, found: isFound(binary5), binary: binary5 };
5491
+ });
5492
+ const notes = [];
5493
+ const config = {};
5494
+ let blocked;
5495
+ let opencode2;
5496
+ if (workers.find((w) => w.id === "opencode")?.found) {
5497
+ const b = getBackend("opencode");
5498
+ let models = [];
5080
5499
  try {
5081
- m = freshMeta(id);
5500
+ models = b.catalog ? b.catalog().models.map((m) => m.id) : b.listModels?.() ?? [];
5082
5501
  } catch {
5083
- continue;
5502
+ notes.push("could not list OpenCode models (is it logged in?)");
5503
+ }
5504
+ const defaultModel4 = b.defaultModel?.();
5505
+ const chosen = opts.model ?? defaultModel4;
5506
+ const free = models.filter((m) => FREE.test(m) && m !== chosen);
5507
+ opencode2 = { defaultModel: defaultModel4, models: models.length, free: free.slice(0, 6) };
5508
+ if (opts.model) {
5509
+ if (models.length && !models.includes(opts.model)) throw new UserError(`"${opts.model}" is not in \`opencode models\``);
5510
+ if (!models.length) notes.push(`could not check "${opts.model}": \`opencode models\` listed nothing`);
5511
+ config.models = { opencode: opts.model };
5512
+ } else if (!defaultModel4) {
5513
+ blocked = `OpenCode has no default model: choose one with --model <id>${free.length ? ` (free models you have: ${free.slice(0, 4).join(", ")})` : ""}`;
5514
+ }
5515
+ const fallback = opts.fallback ?? free.slice(0, 2);
5516
+ for (const m of fallback) {
5517
+ if (models.length && !models.includes(m)) throw new UserError(`"${m}" is not in \`opencode models\``);
5518
+ }
5519
+ if (fallback.length) {
5520
+ config.fallback = fallback.map((m) => `opencode:${m}`);
5521
+ if (!opts.fallback) notes.push(`fallback suggested from the free models in your catalogue: ${fallback.join(", ")} (change it with --fallback a,b)`);
5522
+ } else {
5523
+ notes.push("no fallback: no free OpenCode model found to suggest (pass --fallback a,b to name some)");
5084
5524
  }
5085
- if (m.group) groups.add(m.group);
5086
- if (opts.group && m.group !== opts.group) continue;
5087
- if (runs.length < limit) runs.push(toRun(m));
5088
5525
  }
5089
- return {
5090
- price: primaryPrice().name,
5091
- running: runs.filter((r) => r.state === "running" || r.state === "queued").length,
5092
- saved: totals(readLedger(Date.now() - WEEK_MS2)).saved,
5093
- groups: [...groups].sort(),
5094
- runs
5095
- };
5526
+ const tiers = Object.fromEntries(TIER_ORDER.filter(([, id]) => workers.find((w) => w.id === id)?.found));
5527
+ if (Object.keys(tiers).length > 1) config.tiers = tiers;
5528
+ if (!workers.some((w) => w.found)) blocked = "no worker CLI found: install OpenCode (https://opencode.ai), Codex CLI, Claude Code or Gemini CLI first";
5529
+ else if (!blocked && !Object.keys(config).length) notes.push("nothing to propose: the default model and the single worker need no config");
5530
+ return { path: file2, exists: fs27.existsSync(file2), workers, opencode: opencode2, config, blocked, notes };
5096
5531
  }
5097
- var TASK_MAX = 6e3;
5098
- var PATCH_LINES = 300;
5099
- function runDetail(id) {
5100
- if (!RUN_ID3.test(id)) return void 0;
5101
- let m;
5532
+ function writeInit(plan, force) {
5533
+ if (plan.blocked) throw new UserError(plan.blocked);
5534
+ if (!Object.keys(plan.config).length) throw new UserError("nothing to write: the proposal is empty");
5535
+ if (plan.exists && !force) throw new UserError(`${plan.path} already exists: pass --force to replace it (the old file is kept as ${path23.basename(plan.path)}.bak)`);
5536
+ fs27.mkdirSync(path23.dirname(plan.path), { recursive: true });
5537
+ if (plan.exists) fs27.copyFileSync(plan.path, `${plan.path}.bak`);
5538
+ fs27.writeFileSync(plan.path, `${JSON.stringify(plan.config, null, 2)}
5539
+ `);
5540
+ return plan.path;
5541
+ }
5542
+ function formatInit(plan, written) {
5543
+ const out = [bold("pitroom init") + dim(": worker CLIs on this machine")];
5544
+ 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")}`);
5545
+ if (plan.opencode) {
5546
+ const o = plan.opencode;
5547
+ 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" : ""}` : ""}`);
5548
+ }
5549
+ out.push("", `config file: ${plan.path}${plan.exists ? " (exists)" : " (not present)"}`, JSON.stringify(plan.config, null, 2));
5550
+ for (const n of plan.notes) out.push(` note: ${n}`);
5551
+ if (written) out.push("", green(`\u2714 written to ${written}`), dim(" next: pitroom doctor, then pitroom install"));
5552
+ else if (plan.blocked) out.push("", yellow(`! ${plan.blocked}`));
5553
+ else out.push("", `nothing written yet: pass --yes to write it${plan.exists ? " (with --force, since the file exists)" : ""}`);
5554
+ return out.join("\n");
5555
+ }
5556
+
5557
+ // src/core/mcp-install.ts
5558
+ import { spawnSync as spawnSync7 } from "node:child_process";
5559
+ import fs28 from "node:fs";
5560
+ import os10 from "node:os";
5561
+ import path24 from "node:path";
5562
+ var CLIENT_IDS = ["claude-code", "codex", "gemini", "cursor", "claude-desktop"];
5563
+ var NAME = "pitroom";
5564
+ function mcpCommand() {
5565
+ const launcher = launcherPath();
5566
+ if (isPitroomLauncher(launcher)) return { command: launcher, args: ["mcp"] };
5567
+ for (const dir of (process.env.PATH ?? "").split(path24.delimiter).filter(Boolean)) {
5568
+ const p = path24.join(dir, process.platform === "win32" ? "pitroom.cmd" : "pitroom");
5569
+ if (fs28.existsSync(p)) return { command: path24.resolve(p), args: ["mcp"] };
5570
+ }
5571
+ return { command: process.execPath, args: [process.argv[1] ?? "pitroom", "mcp"] };
5572
+ }
5573
+ var same = (a, b) => {
5574
+ const o = b;
5575
+ return !!o && o.command === a.command && Array.isArray(o.args) && o.args.join("\0") === a.args.join("\0");
5576
+ };
5577
+ function run(backendId, args) {
5578
+ const { command, prefix } = resolveCommand(getBackend(backendId).binary());
5579
+ const r = spawnSync7(command, [...prefix, ...args], { encoding: "utf8", timeout: 6e4, stdio: ["ignore", "pipe", "pipe"] });
5580
+ return { ok: r.status === 0 && !r.error, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
5581
+ }
5582
+ var runnable = (backendId) => run(backendId, ["--version"]).ok;
5583
+ var readJson = (file2) => {
5102
5584
  try {
5103
- m = freshMeta(id);
5585
+ return JSON.parse(fs28.readFileSync(file2, "utf8"));
5104
5586
  } catch {
5105
5587
  return void 0;
5106
5588
  }
5107
- const start = Date.parse(m.startedAt);
5108
- const kept = isActive(m.state) ? void 0 : archivedRun(id);
5109
- let steps = kept?.steps ?? [];
5110
- if (!steps.length) try {
5111
- const events = readRunFile(id, "events.jsonl");
5112
- steps = (events ? getBackend((m.ran ?? m.worker).backend).parse(events).timeline ?? [] : []).map((s) => ({
5113
- ...s,
5114
- t: s.at && Number.isFinite(start) ? Math.max(0, Math.round((s.at - start) / 1e3)) : void 0
5115
- }));
5116
- } catch {
5117
- }
5118
- const rel = (t) => [m.cwd, m.dir].filter(Boolean).reduce((x, base2) => x.split(`${base2}/`).join(""), t);
5119
- steps = steps.map((x) => ({ ...x, text: rel(x.text) }));
5120
- const answer = isActive(m.state) ? "" : readSummary(m).replace(/^SUMMARY:\s*SPEC[^\n]*\n+/i, "").replace(/^DETAILS:[ \t]*\n+/i, "").trim();
5121
- const lastSay = steps.at(-1);
5122
- if (lastSay?.kind === "say" && answer && (answer.includes(lastSay.text.slice(0, 80)) || lastSay.text.includes(answer.slice(0, 80)))) steps.pop();
5123
- const fullPatch = readRunFile(id, "changes.patch");
5124
- const patch = fullPatch ?? kept?.patch ?? "";
5125
- const patchLines = patch.split("\n");
5126
- const u = m.usage;
5589
+ };
5590
+ var jsonState = (file2, c) => {
5591
+ const entry = readJson(file2)?.mcpServers?.[NAME];
5592
+ return entry === void 0 ? "absent" : same(c, entry) ? "same" : "different";
5593
+ };
5594
+ var home2 = () => os10.homedir();
5595
+ var geminiDir = () => path24.join(process.env.GEMINI_CLI_HOME ?? home2(), ".gemini");
5596
+ function cliClient(id, backend, name, where, o) {
5127
5597
  return {
5128
5598
  id,
5129
- state: m.state,
5130
- card: toRun(m),
5131
- 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),
5132
- steps,
5133
- answer,
5134
- changes: m.changes ?? [],
5135
- fileDiffs: fileDiffs(patch, m.changes ?? [], fullPatch === void 0 && kept?.patchTruncated),
5136
- patch: patch ? `${patchLines.slice(0, PATCH_LINES).join("\n")}${patchLines.length > PATCH_LINES ? `
5137
- \u2026 ${patchLines.length - PATCH_LINES} more lines (pitroom show ${id} --patch)` : ""}` : void 0,
5138
- info: {
5139
- worker: (m.ran ?? m.worker).backend,
5140
- // the model has its own line
5141
- model: m.resolvedModel,
5142
- mode: m.mode,
5143
- started: m.startedAt,
5144
- time: elapsed(m),
5145
- steps: u?.steps,
5146
- toolCalls: u?.toolCalls,
5147
- tokens: u?.total,
5148
- returnedTokens: m.returnedTokens,
5149
- cost: u?.cost,
5150
- saved: m.savedUsd,
5151
- group: m.group,
5152
- directory: m.dir
5599
+ name,
5600
+ where,
5601
+ found: () => runnable(backend),
5602
+ state: o.state,
5603
+ add: (c) => {
5604
+ const r = run(backend, o.add(c));
5605
+ return { ok: r.ok, message: r.ok ? where : r.out.split("\n")[0]?.trim() || `${name}'s command failed` };
5153
5606
  },
5154
- attempts: m.attempts ?? [],
5155
- warnings: m.warnings ?? [],
5156
- refs: m.refs ? { valid: m.refs.valid, total: m.refs.total, invalid: m.refs.invalid.map((r) => `${r.ref} (${r.reason})`) } : void 0,
5157
- verify: m.verifyResult && m.verify ? { command: m.verify, ok: m.verifyResult.ok, tail: m.verifyResult.tail } : void 0,
5158
- error: m.error,
5159
- 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 ?? [] },
5160
- report: isActive(m.state) ? progress(m) : formatReport(m, void 0, 120)
5607
+ remove: () => {
5608
+ const r = run(backend, o.remove());
5609
+ return { ok: r.ok, message: r.ok ? where : r.out.split("\n")[0]?.trim() || `${name}'s command failed` };
5610
+ }
5161
5611
  };
5162
5612
  }
5163
- var HEADERS = {
5164
- "cache-control": "no-store",
5165
- "x-content-type-options": "nosniff",
5166
- "referrer-policy": "no-referrer",
5167
- // Own files only, plus the hash of the one inline theme script; styles may be inline (the components position popups with them).
5168
- "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'`
5169
- };
5170
- var ASSETS = { "/assets/app.js": "text/javascript; charset=utf-8", "/assets/app.css": "text/css; charset=utf-8" };
5171
- var assetsDir = () => fileURLToPath3(new URL("./ui/", import.meta.url));
5172
- var assetCache = /* @__PURE__ */ new Map();
5173
- function readAsset(route) {
5613
+ function jsonClient(id, name, file2, dir) {
5614
+ const rel = () => file2().replace(home2(), "~");
5615
+ return {
5616
+ id,
5617
+ name,
5618
+ found: () => fs28.existsSync(dir()),
5619
+ state: (c) => jsonState(file2(), c),
5620
+ add: (c) => {
5621
+ let config = {};
5622
+ if (fs28.existsSync(file2())) {
5623
+ config = readJson(file2());
5624
+ if (!config || typeof config !== "object" || Array.isArray(config)) return { ok: false, message: `${rel()} is not valid JSON: left alone` };
5625
+ const backup = `${file2()}.bak-pitroom`;
5626
+ if (!fs28.existsSync(backup)) fs28.copyFileSync(file2(), backup);
5627
+ }
5628
+ config.mcpServers = { ...config.mcpServers ?? {}, [NAME]: { command: c.command, args: c.args } };
5629
+ fs28.mkdirSync(path24.dirname(file2()), { recursive: true });
5630
+ const tmp = `${file2()}.${process.pid}.tmp`;
5631
+ fs28.writeFileSync(tmp, `${JSON.stringify(config, null, 2)}
5632
+ `);
5633
+ fs28.renameSync(tmp, file2());
5634
+ return { ok: true, message: rel() };
5635
+ },
5636
+ remove: () => {
5637
+ const config = readJson(file2());
5638
+ if (!config?.mcpServers?.[NAME]) return { ok: true, message: rel() };
5639
+ delete config.mcpServers[NAME];
5640
+ fs28.writeFileSync(file2(), `${JSON.stringify(config, null, 2)}
5641
+ `);
5642
+ return { ok: true, message: rel() };
5643
+ }
5644
+ };
5645
+ }
5646
+ function desktopFile() {
5647
+ if (process.platform === "darwin") return path24.join(home2(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
5648
+ if (process.platform === "win32") return path24.join(process.env.APPDATA ?? path24.join(home2(), "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
5649
+ return path24.join(process.env.XDG_CONFIG_HOME ?? path24.join(home2(), ".config"), "Claude", "claude_desktop_config.json");
5650
+ }
5651
+ function codexState(c) {
5652
+ let text;
5174
5653
  try {
5175
- const file2 = path24.join(assetsDir(), path24.basename(route));
5176
- const { mtimeMs } = fs28.statSync(file2);
5177
- const hit = assetCache.get(route);
5178
- if (hit && hit.mtimeMs === mtimeMs) return hit.data;
5179
- const data = fs28.readFileSync(file2);
5180
- assetCache.set(route, { mtimeMs, data });
5181
- return data;
5654
+ text = fs28.readFileSync(path24.join(process.env.CODEX_HOME ?? path24.join(home2(), ".codex"), "config.toml"), "utf8");
5182
5655
  } catch {
5183
- return void 0;
5656
+ return "absent";
5657
+ }
5658
+ const at = text.search(/^\[mcp_servers\.pitroom\]\s*$/m);
5659
+ if (at < 0) return "absent";
5660
+ const table2 = text.slice(at).split(/\n(?=\[)/)[0] ?? "";
5661
+ try {
5662
+ const command = /^command\s*=\s*("(?:[^"\\]|\\.)*")/m.exec(table2)?.[1];
5663
+ const args = /^args\s*=\s*(\[[^\n]*\])/m.exec(table2)?.[1];
5664
+ return command && args && same(c, { command: JSON.parse(command), args: JSON.parse(args) }) ? "same" : "different";
5665
+ } catch {
5666
+ return "different";
5184
5667
  }
5185
5668
  }
5186
- function handler(touch2) {
5187
- const send2 = (res, code, type, body) => {
5188
- res.writeHead(code, { ...HEADERS, "content-type": type });
5189
- res.end(body);
5190
- };
5191
- const json = (res, code, value) => send2(res, code, "application/json; charset=utf-8", JSON.stringify(value));
5192
- return (req, res) => {
5193
- touch2();
5194
- if (!LOCAL_HOST.test(req.headers.host ?? "")) return json(res, 403, { error: "forbidden host" });
5195
- if (req.method !== "GET" && req.method !== "HEAD") return json(res, 405, { error: "read-only" });
5196
- const url = new URL(req.url ?? "/", "http://127.0.0.1");
5197
- if (url.pathname === "/") return send2(res, 200, "text/html; charset=utf-8", readAsset("/assets/app.js") ? PAGE : MISSING);
5198
- const type = ASSETS[url.pathname];
5199
- if (type) {
5200
- const data = readAsset(url.pathname);
5201
- if (!data) return json(res, 404, { error: "dashboard files missing" });
5202
- res.writeHead(200, { ...HEADERS, "content-type": type });
5203
- return void res.end(req.method === "HEAD" ? void 0 : data);
5669
+ function clients() {
5670
+ return [
5671
+ cliClient("claude-code", "claude", "Claude Code", "user settings (~/.claude.json)", {
5672
+ state: (c) => jsonState(path24.join(home2(), ".claude.json"), c),
5673
+ add: (c) => ["mcp", "add", "--scope", "user", NAME, "--", c.command, ...c.args],
5674
+ remove: () => ["mcp", "remove", NAME, "--scope", "user"]
5675
+ }),
5676
+ cliClient("codex", "codex", "Codex", "~/.codex/config.toml", {
5677
+ state: (c) => codexState(c),
5678
+ add: (c) => ["mcp", "add", NAME, "--", c.command, ...c.args],
5679
+ remove: () => ["mcp", "remove", NAME]
5680
+ }),
5681
+ cliClient("gemini", "gemini", "Gemini CLI", "user settings (~/.gemini/settings.json)", {
5682
+ state: (c) => jsonState(path24.join(geminiDir(), "settings.json"), c),
5683
+ add: (c) => ["mcp", "add", "--scope", "user", NAME, c.command, ...c.args],
5684
+ remove: () => ["mcp", "remove", "--scope", "user", NAME]
5685
+ }),
5686
+ jsonClient("cursor", "Cursor", () => path24.join(home2(), ".cursor", "mcp.json"), () => path24.join(home2(), ".cursor")),
5687
+ jsonClient("claude-desktop", "Claude Desktop", desktopFile, () => path24.dirname(desktopFile()))
5688
+ ];
5689
+ }
5690
+ function attempt2(f) {
5691
+ try {
5692
+ return f();
5693
+ } catch (e) {
5694
+ return { ok: false, message: e.message.split("\n")[0] ?? "failed" };
5695
+ }
5696
+ }
5697
+ function installMcp(opts = {}) {
5698
+ const c = mcpCommand();
5699
+ const out = [];
5700
+ for (const client of clients()) {
5701
+ if (opts.only && !opts.only.includes(client.id)) continue;
5702
+ const r = (state, message) => out.push({ id: client.id, name: client.name, state, message });
5703
+ if (!client.found()) {
5704
+ r("not-found", client.id === "cursor" || client.id === "claude-desktop" ? "not found on this machine" : "not installed (its command does not run)");
5705
+ continue;
5204
5706
  }
5205
- if (url.pathname === "/api/state") {
5206
- const group = url.searchParams.get("group") ?? void 0;
5207
- const limit = Number(url.searchParams.get("limit") ?? 40);
5208
- return json(res, 200, dashState({ group: group && group.length <= 100 ? group : void 0, limit: Number.isFinite(limit) ? limit : 40 }));
5707
+ const now = client.state(c);
5708
+ if (now === "same" && !opts.force) {
5709
+ r("already", "already registered");
5710
+ continue;
5209
5711
  }
5210
- if (url.pathname === "/api/history") {
5211
- const q = url.searchParams;
5212
- const days = Number(q.get("days") ?? 0);
5213
- return json(res, 200, listHistory({
5214
- text: (q.get("q") ?? "").slice(0, 200),
5215
- model: q.get("model") || void 0,
5216
- backend: q.get("backend") || void 0,
5217
- state: q.get("state") || void 0,
5218
- group: q.get("group") || void 0,
5219
- sinceMs: days > 0 ? Date.now() - days * 864e5 : void 0,
5220
- beforeId: RUN_ID3.test(q.get("before") ?? "") ? q.get("before") : void 0,
5221
- limit: Number(q.get("limit") ?? 30) || 30
5222
- }));
5712
+ if (opts.dryRun) {
5713
+ r("would-add", `would register ${[c.command, ...c.args].join(" ")}`);
5714
+ continue;
5223
5715
  }
5224
- if (url.pathname === "/api/stats") {
5225
- const days = Number(url.searchParams.get("days") ?? 30);
5226
- return json(res, 200, { ...historyStats(days > 0 ? Date.now() - days * 864e5 : void 0), price: primaryPrice().name });
5716
+ if (now !== "absent") attempt2(() => client.remove());
5717
+ const done = attempt2(() => client.add(c));
5718
+ r(done.ok ? now === "absent" ? "added" : "updated" : "failed", done.ok ? `${now === "absent" ? "registered" : "updated"} in ${done.message}` : done.message);
5719
+ }
5720
+ return out;
5721
+ }
5722
+ function uninstallMcp() {
5723
+ const c = mcpCommand();
5724
+ const out = [];
5725
+ for (const client of clients()) {
5726
+ let has2 = false;
5727
+ try {
5728
+ has2 = client.state(c) !== "absent";
5729
+ } catch {
5730
+ has2 = false;
5227
5731
  }
5228
- const m = /^\/api\/run\/([^/]+)$/.exec(url.pathname);
5229
- if (m) {
5230
- const d = runDetail(m[1]);
5231
- return d ? json(res, 200, d) : json(res, 404, { error: "no such run" });
5732
+ if (!has2) continue;
5733
+ const done = client.found() ? attempt2(() => client.remove()) : { ok: false, message: `its command does not run: remove the pitroom entry from its config by hand` };
5734
+ out.push({ id: client.id, name: client.name, state: done.ok ? "removed" : "failed", message: done.ok ? `removed from ${done.message}` : done.message });
5735
+ }
5736
+ return out;
5737
+ }
5738
+ function mcpStatus() {
5739
+ const c = mcpCommand();
5740
+ return clients().map((client) => {
5741
+ try {
5742
+ return { id: client.id, name: client.name, state: client.state(c) };
5743
+ } catch {
5744
+ return { id: client.id, name: client.name, state: "absent" };
5232
5745
  }
5233
- return json(res, 404, { error: "not found" });
5234
- };
5746
+ });
5235
5747
  }
5236
- function startDash(opts) {
5237
- let lastRequest = Date.now();
5238
- const server = http.createServer(handler(() => lastRequest = Date.now()));
5239
- return new Promise((resolve2, reject) => {
5240
- server.once("error", reject);
5241
- server.listen(opts.port, "127.0.0.1", () => {
5242
- const port = server.address().port;
5243
- let done;
5244
- const closed = new Promise((r) => done = r);
5245
- let timer;
5246
- const close = () => {
5247
- if (timer) clearInterval(timer);
5248
- server.closeAllConnections?.();
5249
- return new Promise((r) => server.close(() => (done(), r())));
5250
- };
5251
- if (opts.idleMs && opts.idleMs > 0) {
5252
- timer = setInterval(() => Date.now() - lastRequest > opts.idleMs && void close(), Math.min(6e4, opts.idleMs));
5253
- timer.unref();
5254
- }
5255
- resolve2({ port, url: `http://127.0.0.1:${port}/`, closed, close });
5256
- });
5748
+ var parseClients = (value) => {
5749
+ if (!value) return void 0;
5750
+ const wanted = value.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
5751
+ const bad = wanted.filter((w) => !CLIENT_IDS.includes(w));
5752
+ if (bad.length) throw new Error(`unknown client ${bad.join(", ")} (one of: ${CLIENT_IDS.join(", ")})`);
5753
+ return wanted;
5754
+ };
5755
+
5756
+ // src/core/plan-status.ts
5757
+ import crypto8 from "node:crypto";
5758
+ import fs29 from "node:fs";
5759
+ import path25 from "node:path";
5760
+ function notesFile(plan) {
5761
+ const id = crypto8.createHash("sha1").update(plan.file).digest("hex").slice(0, 12);
5762
+ return path25.join(home(), "plans", id, "notes.md");
5763
+ }
5764
+ function readNotes(plan) {
5765
+ const f = notesFile(plan);
5766
+ if (!fs29.existsSync(f)) return [];
5767
+ return fs29.readFileSync(f, "utf8").split("\n").slice(1).filter(Boolean).map((l) => {
5768
+ const m = /^(\d{4}-\d\d-\d\d \d\d:\d\d) (.*)$/.exec(l);
5769
+ return m ? { at: m[1], text: m[2] } : { at: "", text: l };
5257
5770
  });
5258
5771
  }
5259
- var registryFile = () => path24.join(home(), "dash.json");
5260
- async function ping(port) {
5261
- try {
5262
- const r = await fetch(`http://127.0.0.1:${port}/api/state?limit=1`, { signal: AbortSignal.timeout(1500) });
5263
- return r.ok;
5264
- } catch {
5265
- return false;
5772
+ function addNote(planFile, text) {
5773
+ const plan = loadPlan(planFile);
5774
+ const f = notesFile(plan);
5775
+ fs29.mkdirSync(path25.dirname(f), { recursive: true });
5776
+ if (!fs29.existsSync(f)) fs29.writeFileSync(f, `# plan: ${plan.file}
5777
+ `);
5778
+ const at = (/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace("T", " ");
5779
+ fs29.appendFileSync(f, `${at} ${text.replace(/\s+/g, " ").trim()}
5780
+ `);
5781
+ return f;
5782
+ }
5783
+ function planStatus(planFile) {
5784
+ const plan = loadPlan(planFile);
5785
+ const runs = [];
5786
+ for (const id of listRunIds()) {
5787
+ try {
5788
+ const m = freshMeta(id);
5789
+ if (m.plan?.file === plan.file) runs.push(m);
5790
+ } catch {
5791
+ }
5792
+ }
5793
+ const notes = readNotes(plan);
5794
+ const tasks = plan.tasks.map((t) => {
5795
+ const impl = runs.filter((m) => m.plan.step === t.step && !m.reviewOf);
5796
+ const rev = runs.filter((m) => m.plan.step === t.step && m.reviewOf).at(-1);
5797
+ const last = impl.at(-1);
5798
+ return {
5799
+ step: t.step,
5800
+ title: t.title,
5801
+ runs: impl.length,
5802
+ last: last && { id: last.id, state: last.state, status: last.taskStatus },
5803
+ review: rev && { id: rev.id, state: rev.state, kind: rev.reviewKind ?? "task", verdict: rev.verdict },
5804
+ applied: impl.some((m) => m.applied || m.mode === "write" && m.state === "done" && !m.reverted && !!m.changes?.length),
5805
+ note: [...notes].reverse().find((n) => n.text.startsWith(`Task ${t.step}:`))?.text
5806
+ };
5807
+ });
5808
+ return { plan, tasks, rulings: notes.filter((n) => /\bRuling:/.test(n.text)), notesFile: notesFile(plan) };
5809
+ }
5810
+ var cell = (s, max) => {
5811
+ const t = s.replace(/\s+/g, " ").trim();
5812
+ return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
5813
+ };
5814
+ function formatPlanStatus(s) {
5815
+ const head = ["TASK", "STATE", "STATUS", "REVIEW", "ROUNDS", "APPLIED", "TITLE", "NOTE"];
5816
+ const rows = s.tasks.map((t) => {
5817
+ const v = t.review?.verdict;
5818
+ const review = v ? `${v.spec}/${v.quality} (c${v.critical} i${v.important} m${v.minor})` : t.review?.state ?? "-";
5819
+ return [
5820
+ String(t.step),
5821
+ t.last?.state ?? "-",
5822
+ t.last?.status ?? "-",
5823
+ review,
5824
+ t.runs ? String(t.runs - 1) : "-",
5825
+ t.runs ? t.applied ? "yes" : "no" : "-",
5826
+ cell(t.title, 32),
5827
+ cell(t.note ?? "", 60)
5828
+ ];
5829
+ });
5830
+ const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
5831
+ const fmt = (r) => r.map((c, i) => i === r.length - 1 ? c : c.padEnd(widths[i])).join(" ").trimEnd();
5832
+ const rulings = s.rulings.length ? s.rulings.map((n) => ` ${n.at} ${n.text}`).join("\n") : " none";
5833
+ return [
5834
+ `plan ${s.plan.file} \xB7 ${s.plan.title || planName(s.plan.file)} \xB7 ${s.tasks.length} tasks`,
5835
+ "",
5836
+ fmt(head),
5837
+ ...rows.map(fmt),
5838
+ "",
5839
+ `Rulings:
5840
+ ${rulings}`,
5841
+ `notes: ${s.notesFile}`
5842
+ ].join("\n");
5843
+ }
5844
+
5845
+ // src/core/review.ts
5846
+ import crypto9 from "node:crypto";
5847
+ import fs30 from "node:fs";
5848
+ import path26 from "node:path";
5849
+ var TEMPLATE2 = { task: "task-reviewer", fix: "re-review", range: "code-reviewer" };
5850
+ var section = (title, body) => `## ${title}
5851
+
5852
+ ${body.trim() || "(empty)"}
5853
+ `;
5854
+ function ancestors(m) {
5855
+ const out = [];
5856
+ for (let id = m.parent; id; ) {
5857
+ const a = readMeta(id);
5858
+ out.push(a);
5859
+ id = a.parent;
5266
5860
  }
5861
+ return out;
5862
+ }
5863
+ function briefOf(m) {
5864
+ const root = [m, ...ancestors(m)].at(-1);
5865
+ const f = runFile(root.id, "brief.md");
5866
+ return fs30.existsSync(f) ? fs30.readFileSync(f, "utf8") : root.task;
5267
5867
  }
5268
- async function runningDash() {
5269
- try {
5270
- const r = JSON.parse(fs28.readFileSync(registryFile(), "utf8"));
5271
- if (isAlive(r.pid) && await ping(r.port)) return { ...r, url: `http://127.0.0.1:${r.port}/` };
5272
- } catch {
5868
+ function latestReview(ids) {
5869
+ for (const id of listRunIds().reverse()) {
5870
+ try {
5871
+ const m = readMeta(id);
5872
+ if (m.reviewOf && ids.includes(m.reviewOf) && m.state === "done") return m;
5873
+ } catch {
5874
+ }
5273
5875
  }
5274
- fs28.rmSync(registryFile(), { force: true });
5275
5876
  return void 0;
5276
5877
  }
5277
- function openBrowser(url) {
5278
- const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
5279
- try {
5280
- spawn4(cmd, args, { stdio: "ignore", detached: true }).on("error", () => void 0).unref();
5281
- } catch {
5878
+ function diffOf(m, from = m.baseTree) {
5879
+ const where = m.mode === "isolate" ? m.worktree : m.repoRoot;
5880
+ if (where && fs30.existsSync(where) && from && m.afterTree) return reviewDiff(where, from, m.afterTree);
5881
+ if (from !== m.baseTree) {
5882
+ 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);
5282
5883
  }
5884
+ return readRunFile(m.id, "changes.patch") ?? archivedRun(m.id)?.patch ?? "";
5283
5885
  }
5284
- async function dashCommand(o) {
5285
- const existing = await runningDash();
5286
- if (o.stop) {
5287
- if (!existing) throw new UserError("pitroom dash is not running");
5288
- process.kill(existing.pid, "SIGTERM");
5289
- fs28.rmSync(registryFile(), { force: true });
5290
- o.log(`stopped pitroom dash (${existing.url})`);
5291
- return 0;
5292
- }
5293
- if (existing && !o.serve) {
5294
- o.log(existing.url);
5295
- if (o.open) openBrowser(existing.url);
5296
- return 0;
5886
+ function runReview(id) {
5887
+ const m = freshMeta(id);
5888
+ if (isActive(m.state)) throw new UserError(`run ${m.id} is still ${m.state}; pitroom wait ${m.id} first`, 3);
5889
+ if (m.reviewOf) throw new UserError(`run ${m.id} is itself a review`);
5890
+ if (m.mode === "read") throw new UserError(`run ${m.id} was read-only; there is no change to review`);
5891
+ if (!m.changes?.length) throw new UserError(`run ${m.id} made no changes; nothing to review`);
5892
+ const chain = ancestors(m);
5893
+ const previous = latestReview(chain.map((a) => a.id));
5894
+ const dir = m.mode === "isolate" && m.worktree && fs30.existsSync(m.worktree) ? m.cwd : m.dir;
5895
+ const job = { of: m.id, dir, implementer: m.ran ?? m.worker, group: m.group, plan: m.plan };
5896
+ const title = m.plan ? `Task ${m.plan.step}: ${m.plan.title}` : `run ${m.id}`;
5897
+ if (!previous) {
5898
+ return {
5899
+ ...job,
5900
+ kind: "task",
5901
+ package: [`# Review package \xB7 ${title}
5902
+ `, section("BRIEF", briefOf(m)), section("REPORT", readSummary(m)), section("DIFF", diffOf(m))].join("\n")
5903
+ };
5297
5904
  }
5298
- if (o.detach) {
5299
- const child = spawn4(process.execPath, [process.argv[1], "dash", "--serve", ...o.port !== void 0 ? ["--port", String(o.port)] : [], "--idle", "1h"], {
5300
- detached: true,
5301
- stdio: "ignore",
5302
- env: process.env
5303
- });
5304
- child.on("error", () => void 0);
5305
- child.unref();
5306
- for (let i = 0; i < 60; i++) {
5307
- await new Promise((r2) => setTimeout(r2, 100));
5308
- const r = await runningDash();
5309
- if (r && r.pid === child.pid) {
5310
- o.log(r.url);
5311
- if (o.open) openBrowser(r.url);
5312
- return 0;
5313
- }
5905
+ const reviewed = chain.find((a) => a.id === previous.reviewOf);
5906
+ const from = m.mode === "isolate" ? reviewed.afterTree : m.baseTree;
5907
+ return {
5908
+ ...job,
5909
+ kind: "fix",
5910
+ package: [
5911
+ `# Re-review package \xB7 ${title} \xB7 fix round after review ${previous.id}
5912
+ `,
5913
+ section("BRIEF", briefOf(m)),
5914
+ section("PREVIOUS FINDINGS", readSummary(previous)),
5915
+ section("FIX REPORT", readSummary(m)),
5916
+ section("FIX DIFF", diffOf(m, from))
5917
+ ].join("\n")
5918
+ };
5919
+ }
5920
+ function rangeReview(range, dir, planFile) {
5921
+ const root = repoRoot(dir);
5922
+ if (!root) throw new UserError("--range needs a git repository");
5923
+ const i = range.indexOf("..");
5924
+ const a = i > 0 ? range.slice(0, i) : "";
5925
+ const b = i > 0 ? range.slice(i + 2) || "HEAD" : "";
5926
+ if (!a || b.startsWith(".")) throw new UserError(`--range takes A..B (e.g. main..HEAD), not "${range}"`);
5927
+ for (const ref of [a, b]) if (!commitOf(root, ref)) throw new UserError(`not a commit: ${ref}`);
5928
+ const plan = planFile ? loadPlan(planFile) : void 0;
5929
+ const notes = plan ? readNotes(plan) : [];
5930
+ const requirements = plan ? [
5931
+ `Plan: ${plan.file}`,
5932
+ "### Global Constraints",
5933
+ plan.constraints || "(none stated in the plan)",
5934
+ "### Tasks",
5935
+ plan.tasks.map((t) => `- Task ${t.step}: ${t.title}`).join("\n")
5936
+ ].join("\n\n") : "(none given; judge the change on its own terms)";
5937
+ return {
5938
+ kind: "range",
5939
+ of: `${a}..${b}`,
5940
+ dir: root,
5941
+ group: plan ? planName(plan.file) : void 0,
5942
+ package: [
5943
+ `# Review package \xB7 ${a}..${b}
5944
+ `,
5945
+ section("WHAT WAS IMPLEMENTED", plan ? `${plan.title}
5946
+
5947
+ ${plan.header}` : "The commits below."),
5948
+ section("REQUIREMENTS", requirements),
5949
+ ...notes.length ? [section("NOTES FROM EXECUTION", notes.map((n) => `- ${n.text}`).join("\n"))] : [],
5950
+ rangeDiff(root, a, b)
5951
+ ].join("\n")
5952
+ };
5953
+ }
5954
+ function pickReviewer(job) {
5955
+ const eff = effective();
5956
+ const tiers = eff.tiers.value;
5957
+ if (job.kind === "range") return tiers.capable;
5958
+ if (tiers.review) return tiers.review;
5959
+ const implementer = job.implementer;
5960
+ if (!implementer) return void 0;
5961
+ const def = parseTarget(eff.worker.value, DEFAULT_BACKEND).backend;
5962
+ const candidates = [tiers.standard, tiers.capable, ...eff.fallback.value, eff.worker.value].filter((s) => !!s);
5963
+ return candidates.find((c) => parseTarget(c, def).backend !== implementer.backend);
5964
+ }
5965
+ function writePackage(job) {
5966
+ const g = gitDir(job.dir);
5967
+ if (!g) throw new UserError(`not a git repository: ${job.dir}`);
5968
+ const file2 = path26.join(g, "pitroom", `review-${crypto9.randomBytes(4).toString("hex")}.md`);
5969
+ fs30.mkdirSync(path26.dirname(file2), { recursive: true });
5970
+ fs30.writeFileSync(file2, job.package);
5971
+ return file2;
5972
+ }
5973
+
5974
+ // src/core/models.ts
5975
+ var base = (model) => (model ?? "").split("#")[0];
5976
+ function usage() {
5977
+ const eff = effective();
5978
+ const out = /* @__PURE__ */ new Map();
5979
+ const add = (spec, label) => {
5980
+ let t;
5981
+ try {
5982
+ t = parseTarget(spec, "opencode");
5983
+ } catch {
5984
+ return;
5314
5985
  }
5315
- throw new UserError("pitroom dash did not start (is the port taken? try --port 0)");
5986
+ const model = base(t.model) || base(eff.models.value[t.backend]);
5987
+ if (!model) return;
5988
+ const key = `${t.backend}:${model}`;
5989
+ out.set(key, [...out.get(key) ?? [], label]);
5990
+ };
5991
+ add(eff.worker.value, "default worker");
5992
+ for (const [name, spec] of Object.entries(eff.tiers.value)) add(spec, `tier ${name}`);
5993
+ eff.fallback.value.forEach((spec) => add(spec, "fallback"));
5994
+ for (const [backend, model] of Object.entries(eff.models.value)) add(`${backend}:${model}`, "models");
5995
+ return out;
5996
+ }
5997
+ function modelTable(opts = {}) {
5998
+ const costs = effective().costs.value;
5999
+ const used = usage();
6000
+ const seen = /* @__PURE__ */ new Map();
6001
+ for (const e of readLedger()) {
6002
+ if (!e.backend || !e.model) continue;
6003
+ const key = `${e.backend}:${base(e.model)}`;
6004
+ const a = seen.get(key) ?? { runs: 0, tokens: 0, usd: 0 };
6005
+ seen.set(key, { runs: a.runs + 1, tokens: a.tokens + e.tokens, usd: a.usd + e.workerCost });
5316
6006
  }
5317
- importRuns();
5318
- const explicit = o.port !== void 0;
5319
- let dash;
5320
- try {
5321
- dash = await startDash({ port: o.port ?? DEFAULT_PORT, idleMs: o.idleMs });
5322
- } catch (e) {
5323
- if (explicit || e.code !== "EADDRINUSE") throw new UserError(`pitroom dash: ${e.message}`);
5324
- dash = await startDash({ port: 0, idleMs: o.idleMs });
6007
+ const rows = [];
6008
+ const sources = [];
6009
+ let hiddenOpenCode = 0;
6010
+ const known = /* @__PURE__ */ new Set();
6011
+ for (const b of allBackends()) {
6012
+ if (opts.backend && b.id !== opts.backend) continue;
6013
+ const catalog5 = b.catalog?.() ?? { models: [], source: "no model list for this worker" };
6014
+ sources.push(`${b.id}: ${catalog5.source}`);
6015
+ for (const m of catalog5.models) {
6016
+ const key = `${b.id}:${m.id}`;
6017
+ known.add(key);
6018
+ const s = seen.get(key);
6019
+ const row = {
6020
+ worker: b.id,
6021
+ model: m.id,
6022
+ efforts: m.efforts?.length ? m.efforts : void 0,
6023
+ defaultEffort: m.defaultEffort,
6024
+ cost: costs[key],
6025
+ runs: s?.runs ?? 0,
6026
+ avgTokens: s ? Math.round(s.tokens / s.runs) : void 0,
6027
+ reportedUsdPerRun: s && s.usd > 0 ? s.usd / s.runs : void 0,
6028
+ inUse: used.get(key) ?? []
6029
+ };
6030
+ const relevant = row.inUse.length || row.runs || row.cost !== void 0;
6031
+ if (b.id === "opencode" && !opts.all && !relevant) hiddenOpenCode++;
6032
+ else rows.push(row);
6033
+ }
5325
6034
  }
5326
- fs28.mkdirSync(home(), { recursive: true });
5327
- fs28.writeFileSync(registryFile(), JSON.stringify({ pid: process.pid, port: dash.port }));
5328
- const stop = () => void dash.close();
5329
- process.once("SIGINT", stop);
5330
- process.once("SIGTERM", stop);
5331
- if (!o.serve) o.log(`${dash.url}
5332
- (read-only, this machine only; Ctrl-C stops it)`);
5333
- if (o.open) openBrowser(dash.url);
5334
- await dash.closed;
5335
- try {
5336
- if (JSON.parse(fs28.readFileSync(registryFile(), "utf8")).pid === process.pid) fs28.rmSync(registryFile(), { force: true });
5337
- } catch {
6035
+ for (const [key, labels] of used) {
6036
+ const [worker, ...rest] = key.split(":");
6037
+ if (known.has(key) || opts.backend && worker !== opts.backend) continue;
6038
+ const s = seen.get(key);
6039
+ 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 });
5338
6040
  }
5339
- return 0;
6041
+ rows.sort(
6042
+ (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)
6043
+ );
6044
+ return { rows, sources, hiddenOpenCode };
6045
+ }
6046
+ var compact2 = (n) => n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n);
6047
+ function formatModels(t) {
6048
+ const efforts = (r) => r.efforts ? r.efforts.map((e) => e === r.defaultEffort ? `${e}*` : e).join("/") : "-";
6049
+ const cells = t.rows.map((r) => [
6050
+ r.worker,
6051
+ r.model,
6052
+ efforts(r),
6053
+ r.cost !== void 0 ? String(r.cost) : "?",
6054
+ r.runs ? String(r.runs) : "-",
6055
+ r.avgTokens !== void 0 ? compact2(r.avgTokens) : "-",
6056
+ r.reportedUsdPerRun !== void 0 ? `$${r.reportedUsdPerRun.toFixed(3)}` : "-",
6057
+ r.inUse.join(", ") || "-"
6058
+ ]);
6059
+ const head = ["worker", "model", "effort (* default)", "cost", "runs", "avg tokens", "$/run", "in use"];
6060
+ const widths = head.map((h, i) => Math.max(h.length, ...cells.map((c) => c[i].length)));
6061
+ const line = (c) => c.map((x, i) => x.padEnd(widths[i])).join(" ").trimEnd();
6062
+ const out = [line(head), ...cells.map(line), ""];
6063
+ out.push('cost: your relative cost from the config ("costs": {"codex:gpt-6-sol": 1, \u2026}); ? = not set. Pitroom cannot know vendor prices.');
6064
+ 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.");
6065
+ if (t.hiddenOpenCode) out.push(`${t.hiddenOpenCode} more OpenCode models not shown (use --all).`);
6066
+ out.push(...t.sources.map((s) => `source ${s}`));
6067
+ return out.join("\n");
5340
6068
  }
5341
6069
 
5342
6070
  // src/cli/commands.ts
@@ -5386,10 +6114,10 @@ async function cmdReview(p) {
5386
6114
  writeMeta(meta);
5387
6115
  }
5388
6116
  } catch (e) {
5389
- fs29.rmSync(packageFile, { force: true });
6117
+ fs31.rmSync(packageFile, { force: true });
5390
6118
  throw e;
5391
6119
  }
5392
- fs29.writeFileSync(runFile(meta.id, "package.md"), job.package);
6120
+ fs31.writeFileSync(runFile(meta.id, "package.md"), job.package);
5393
6121
  return launch(p, meta);
5394
6122
  }
5395
6123
  async function cmdAudit(p) {
@@ -5457,7 +6185,7 @@ function cmdPlan(p) {
5457
6185
  }
5458
6186
  function cmdCrew(p) {
5459
6187
  const file2 = flag(p, "task-file");
5460
- const tasks = (file2 ? fs29.readFileSync(file2, "utf8").split(/^\s*---\s*$/m) : p.positional).map((t) => t.trim()).filter(Boolean);
6188
+ const tasks = (file2 ? fs31.readFileSync(file2, "utf8").split(/^\s*---\s*$/m) : p.positional).map((t) => t.trim()).filter(Boolean);
5461
6189
  if (!tasks.length) throw new UserError('crew needs tasks: pitroom crew -g NAME "task 1" "task 2" (or --task-file with --- separators)');
5462
6190
  if (has(p, "write") && tasks.length > 1) {
5463
6191
  throw new UserError("parallel --write runs would edit the same tree; use --isolate (each worker gets its own isolated copy)");
@@ -5560,9 +6288,9 @@ function cmdHistory(p) {
5560
6288
  return 0;
5561
6289
  }
5562
6290
  importRuns();
5563
- const since = sinceMs(flag(p, "since"));
6291
+ const since2 = sinceMs(flag(p, "since"));
5564
6292
  if (sub === "stats") {
5565
- const s = historyStats(since);
6293
+ const s = historyStats(since2);
5566
6294
  if (has(p, "json")) return console.log(JSON.stringify(s, null, 2)), 0;
5567
6295
  const t = s.totals;
5568
6296
  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`);
@@ -5575,7 +6303,7 @@ ${[fmt2(head2), ...rows2.map(fmt2)].join("\n")}`);
5575
6303
  return 0;
5576
6304
  }
5577
6305
  const limit = flag(p, "limit") ? Number(flag(p, "limit")) : 20;
5578
- const { rows, total } = listHistory({ text: p.positional.join(" "), model: flag(p, "model"), state: flag(p, "state"), group: flag(p, "group"), sinceMs: since, limit });
6306
+ const { rows, total } = listHistory({ text: p.positional.join(" "), model: flag(p, "model"), state: flag(p, "state"), group: flag(p, "group"), sinceMs: since2, limit });
5579
6307
  if (has(p, "json")) return console.log(JSON.stringify({ total, rows }, null, 2)), 0;
5580
6308
  if (!rows.length) return console.log(total ? "nothing on this page" : "no matching runs"), 0;
5581
6309
  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]);
@@ -5660,13 +6388,13 @@ function sinceMs(s) {
5660
6388
  if (!m) throw new UserError("--since takes 7d, 30d, \u2026 or all");
5661
6389
  return Date.now() - Number(m[1]) * 864e5;
5662
6390
  }
5663
- var readStdin = () => process.stdin.isTTY ? "" : fs29.readFileSync(0, "utf8");
6391
+ var readStdin = () => process.stdin.isTTY ? "" : fs31.readFileSync(0, "utf8");
5664
6392
  function cmdStatusline(p) {
5665
6393
  const input = readStdin();
5666
6394
  const lines = [];
5667
6395
  const then = flag(p, "then");
5668
6396
  if (then) {
5669
- const r = spawnSync7("/bin/sh", ["-c", then], { input, encoding: "utf8", timeout: 5e3 });
6397
+ const r = spawnSync8("/bin/sh", ["-c", then], { input, encoding: "utf8", timeout: 5e3 });
5670
6398
  if (r.stdout?.trimEnd()) lines.push(r.stdout.trimEnd());
5671
6399
  }
5672
6400
  try {
@@ -5700,8 +6428,8 @@ function cmdModels(p) {
5700
6428
  return 0;
5701
6429
  }
5702
6430
  function cmdHookStart() {
5703
- const script = path25.join(path25.dirname(path25.dirname(fileURLToPath4(import.meta.url))), "hooks", "session-start.mjs");
5704
- if (fs29.existsSync(script)) spawnSync7(process.execPath, [script], { stdio: ["ignore", "inherit", "ignore"], env: process.env });
6431
+ const script = path27.join(path27.dirname(path27.dirname(fileURLToPath4(import.meta.url))), "hooks", "session-start.mjs");
6432
+ if (fs31.existsSync(script)) spawnSync8(process.execPath, [script], { stdio: ["ignore", "inherit", "ignore"], env: process.env });
5705
6433
  return 0;
5706
6434
  }
5707
6435
  function cmdInit(p) {
@@ -5712,11 +6440,11 @@ function cmdInit(p) {
5712
6440
  return 0;
5713
6441
  }
5714
6442
  function cmdSavings(p) {
5715
- const since = flag(p, "since") ?? "all";
5716
- const t = totals(readLedger(sinceMs(since)));
6443
+ const since2 = flag(p, "since") ?? "all";
6444
+ const t = totals(readLedger(sinceMs(since2)));
5717
6445
  if (has(p, "models")) {
5718
6446
  const by = /* @__PURE__ */ new Map();
5719
- for (const e of readLedger(sinceMs(since))) {
6447
+ for (const e of readLedger(sinceMs(since2))) {
5720
6448
  const key = `${e.backend ?? "?"} ${e.model ?? "(default model)"}`;
5721
6449
  const a = by.get(key) ?? { runs: 0, tokens: 0, returned: 0, cost: 0, saved: 0 };
5722
6450
  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 });
@@ -5728,7 +6456,7 @@ function cmdSavings(p) {
5728
6456
  }
5729
6457
  return 0;
5730
6458
  }
5731
- const period = since === "all" ? "all time" : `last ${since.replace("d", " days")}`;
6459
+ const period = since2 === "all" ? "all time" : `last ${since2.replace("d", " days")}`;
5732
6460
  if (has(p, "json")) {
5733
6461
  console.log(JSON.stringify({ period, ...t, price: primaryPrice() }, null, 2));
5734
6462
  return 0;
@@ -5741,19 +6469,41 @@ function cmdSavings(p) {
5741
6469
  est. saved ${usd(t.saved)} (vs ${primaryPrice().name} list prices)`);
5742
6470
  const out = flag(p, "card");
5743
6471
  if (out) {
5744
- fs29.writeFileSync(out, card(t, period));
5745
- console.log(`card written to ${path25.resolve(out)}`);
6472
+ fs31.writeFileSync(out, card(t, period));
6473
+ console.log(`card written to ${path27.resolve(out)}`);
5746
6474
  }
5747
6475
  if (has(p, "badge")) console.log(`![pitroom](${badgeUrl(t)})`);
5748
6476
  return 0;
5749
6477
  }
6478
+ var MCP_ICON = { added: "\u2714", updated: "\u2714", already: "\xB7", removed: "\u2714", "not-found": "\xB7", failed: "\u2718", "would-add": "\u2192" };
5750
6479
  function cmdInstall(p) {
5751
- for (const line of install({ copy: has(p, "copy"), force: has(p, "force") })) console.log(line);
5752
- return 0;
6480
+ const mcp = has(p, "mcp") || has(p, "client");
6481
+ if (has(p, "dry-run") && !mcp) throw new UserError("--dry-run goes with --mcp");
6482
+ if (has(p, "no-skills") && !mcp) throw new UserError("--no-skills goes with --mcp: without it install links the skills");
6483
+ let only;
6484
+ try {
6485
+ only = parseClients(flag(p, "client"));
6486
+ } catch (e) {
6487
+ throw new UserError(e.message);
6488
+ }
6489
+ if (!has(p, "dry-run")) for (const line of install({ copy: has(p, "copy"), force: has(p, "force"), skills: !has(p, "no-skills") })) console.log(line);
6490
+ if (!mcp) return 0;
6491
+ const results = installMcp({ only, dryRun: has(p, "dry-run"), force: has(p, "force") });
6492
+ console.log(`
6493
+ MCP clients (pitroom mcp):`);
6494
+ for (const r of results) console.log(`${MCP_ICON[r.state] ?? "\u2022"} ${r.name}: ${r.message}`);
6495
+ if (results.every((r) => r.state === "not-found")) {
6496
+ console.log("no MCP client found: add the server by hand (see the README), or install a client first");
6497
+ } else if (results.some((r) => r.state === "added" || r.state === "updated")) {
6498
+ console.log("restart those clients (or start a new session) so they pick the server up");
6499
+ }
6500
+ return results.some((r) => r.state === "failed") ? 1 : 0;
5753
6501
  }
5754
6502
  function cmdUninstall() {
5755
6503
  for (const line of uninstall()) console.log(line);
5756
- return 0;
6504
+ const mcp = uninstallMcp();
6505
+ for (const r of mcp) console.log(`${MCP_ICON[r.state] ?? "\u2022"} ${r.name}: ${r.message}`);
6506
+ return mcp.some((r) => r.state === "failed") ? 1 : 0;
5757
6507
  }
5758
6508
  function cmdConfig(p) {
5759
6509
  const { warnings } = loadConfig();
@@ -5762,7 +6512,7 @@ function cmdConfig(p) {
5762
6512
  console.log(JSON.stringify({ path: configPath(), settings: eff, warnings }, null, 2));
5763
6513
  return 0;
5764
6514
  }
5765
- console.log(`config file: ${configPath()}${fs29.existsSync(configPath()) ? "" : " (not present)"}`);
6515
+ console.log(`config file: ${configPath()}${fs31.existsSync(configPath()) ? "" : " (not present)"}`);
5766
6516
  for (const [key, s] of Object.entries(eff)) {
5767
6517
  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);
5768
6518
  console.log(` ${key.padEnd(15)} ${v} (${s.source})`);
@@ -5786,7 +6536,7 @@ function cmdClean(p) {
5786
6536
  const m = readMeta(id);
5787
6537
  if (m.mode === "isolate" && !m.discarded && !m.applied) discardRun(m);
5788
6538
  recordRun(m);
5789
- fs29.rmSync(runDir(id), { recursive: true, force: true });
6539
+ fs31.rmSync(runDir(id), { recursive: true, force: true });
5790
6540
  }
5791
6541
  const snapshots = prune(Date.now(), 0);
5792
6542
  console.log(`removed ${old.length} run(s)${snapshots ? ` and ${snapshots} read snapshot${snapshots === 1 ? "" : "s"}` : ""}; the history and the savings ledger are kept`);
@@ -5796,10 +6546,10 @@ var cmdRevert = (p) => (console.log(revertRun(freshMeta(resolveRun(p.positional[
5796
6546
  var cmdDiscard = (p) => (console.log(discardRun(freshMeta(resolveRun(p.positional[0])))), 0);
5797
6547
 
5798
6548
  // src/core/doctor.ts
5799
- import { spawnSync as spawnSync8 } from "node:child_process";
5800
- import fs30 from "node:fs";
5801
- import os10 from "node:os";
5802
- import path26 from "node:path";
6549
+ import { spawnSync as spawnSync9 } from "node:child_process";
6550
+ import fs32 from "node:fs";
6551
+ import os11 from "node:os";
6552
+ import path28 from "node:path";
5803
6553
  var MARK = { ok: () => green("\u2714"), warn: () => yellow("!"), fail: () => red("\u2718") };
5804
6554
  var NEXT = [
5805
6555
  { when: /no default model|no fallback workers/, command: "pitroom init", why: "propose a starter config (fallback models from your catalogue)" },
@@ -5809,14 +6559,14 @@ var NEXT = [
5809
6559
  { when: /Gemini CLI is not signed in|IneligibleTierError/, command: "export GEMINI_API_KEY=\u2026", why: "a Google AI Studio key for the Gemini worker" }
5810
6560
  ];
5811
6561
  function firstNodeOnPath() {
5812
- for (const dir of (process.env.PATH ?? "").split(path26.delimiter).filter(Boolean)) {
5813
- const file2 = path26.join(dir, process.platform === "win32" ? "node.exe" : "node");
6562
+ for (const dir of (process.env.PATH ?? "").split(path28.delimiter).filter(Boolean)) {
6563
+ const file2 = path28.join(dir, process.platform === "win32" ? "node.exe" : "node");
5814
6564
  try {
5815
- if (!fs30.statSync(file2).isFile()) continue;
6565
+ if (!fs32.statSync(file2).isFile()) continue;
5816
6566
  } catch {
5817
6567
  continue;
5818
6568
  }
5819
- const r = spawnSync8(file2, ["-p", "process.versions.node"], { encoding: "utf8", timeout: 5e3 });
6569
+ const r = spawnSync9(file2, ["-p", "process.versions.node"], { encoding: "utf8", timeout: 5e3 });
5820
6570
  return r.status === 0 ? { path: file2, version: r.stdout.trim() } : { path: file2, version: "" };
5821
6571
  }
5822
6572
  return void 0;
@@ -5839,12 +6589,12 @@ function doctor5(probe) {
5839
6589
  const addAll = (list2) => list2.forEach((c) => add(c.level, c.message));
5840
6590
  add("ok", `pitroom ${VERSION2} \xB7 node ${process.versions.node} \xB7 state in ${home()}`);
5841
6591
  const onPath = firstNodeOnPath();
5842
- if (onPath && onPath.version && tooOld(onPath.version) && path26.resolve(onPath.path) !== path26.resolve(process.execPath)) {
6592
+ if (onPath && onPath.version && tooOld(onPath.version) && path28.resolve(onPath.path) !== path28.resolve(process.execPath)) {
5843
6593
  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)`);
5844
6594
  }
5845
6595
  add(gitAvailable() ? "ok" : "warn", gitAvailable() ? "git available" : "git not found: --write/--isolate tracking disabled");
5846
6596
  const cfg = loadConfig();
5847
- add(cfg.warnings.length ? "warn" : "ok", `config: ${configPath()}${fs30.existsSync(configPath()) ? "" : " (not present, defaults in use)"}`);
6597
+ add(cfg.warnings.length ? "warn" : "ok", `config: ${configPath()}${fs32.existsSync(configPath()) ? "" : " (not present, defaults in use)"}`);
5848
6598
  for (const w of cfg.warnings) add("warn", w);
5849
6599
  if (process.platform !== "win32") {
5850
6600
  const guarded = guardEnv(process.env).PATH?.startsWith(shimDir());
@@ -5916,6 +6666,11 @@ function doctor5(probe) {
5916
6666
  for (const c of found) add("ok", c.message);
5917
6667
  add("ok", `use it with -W ${backend.id}[:model], or name it in "fallback" or "tiers" in the config`);
5918
6668
  }
6669
+ section2("MCP");
6670
+ const have = mcpStatus().filter((m) => m.state !== "absent");
6671
+ const stale = have.filter((m) => m.state === "different");
6672
+ add("ok", have.length ? `pitroom mcp is registered in: ${have.map((m) => m.name).join(", ")}` : "pitroom mcp is not registered in any client: `pitroom install --mcp` does it for the ones found (Cursor, Claude Desktop, Claude Code, Codex, Gemini CLI)");
6673
+ for (const m of stale) add("warn", `${m.name} runs a different command for pitroom mcp than this install's: \`pitroom install --mcp --no-skills\` updates it`);
5919
6674
  section2("Skills and agents");
5920
6675
  addAll(skillChecks());
5921
6676
  if (probe && chain[0]) {
@@ -5962,7 +6717,7 @@ function liveProbe(target) {
5962
6717
  title: "pitroom doctor probe"
5963
6718
  });
5964
6719
  const t0 = Date.now();
5965
- const r = spawnSync8(inv.command, inv.args, {
6720
+ const r = spawnSync9(inv.command, inv.args, {
5966
6721
  encoding: "utf8",
5967
6722
  timeout: 3e5,
5968
6723
  stdio: ["ignore", "pipe", "pipe"],
@@ -5970,9 +6725,9 @@ function liveProbe(target) {
5970
6725
  env: guardEnv({ ...process.env, ...inv.env, PWD: process.cwd(), PITROOM_ACTIVE: "1" })
5971
6726
  });
5972
6727
  const secs2 = ((Date.now() - t0) / 1e3).toFixed(1);
5973
- const run = backend.parse(r.stdout ?? "");
5974
- if (run.finalText.includes("PONG")) return { level: "ok", message: `live probe (${describeTarget(target)}) answered in ${secs2}s` };
5975
- const why = backend.failure(run, r.stderr ?? "", r.status)?.message ?? run.finalText.slice(0, 200);
6728
+ const run2 = backend.parse(r.stdout ?? "");
6729
+ if (run2.finalText.includes("PONG")) return { level: "ok", message: `live probe (${describeTarget(target)}) answered in ${secs2}s` };
6730
+ const why = backend.failure(run2, r.stderr ?? "", r.status)?.message ?? run2.finalText.slice(0, 200);
5976
6731
  return { level: "fail", message: `live probe (${describeTarget(target)}) failed after ${secs2}s: ${why}` };
5977
6732
  }
5978
6733
  function skillChecks() {
@@ -5992,20 +6747,20 @@ function skillChecks() {
5992
6747
  else if (!viaPlugin) checks.push({ level: "warn", message: `no Pitroom skills in ${base2}; run \`pitroom install\`` });
5993
6748
  }
5994
6749
  if (geminiExtensionInstalled()) {
5995
- const linked = installedSkills().some((i) => i.base.includes(`${path26.sep}.agents${path26.sep}`) && i.names.length);
6750
+ const linked = installedSkills().some((i) => i.base.includes(`${path28.sep}.agents${path28.sep}`) && i.names.length);
5996
6751
  checks.push(
5997
6752
  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)" }
5998
6753
  );
5999
6754
  }
6000
6755
  if (viaPlugin) {
6001
- const linked = installedSkills().some((i) => i.base.includes(`${path26.sep}.claude${path26.sep}`) && i.names.length);
6756
+ const linked = installedSkills().some((i) => i.base.includes(`${path28.sep}.claude${path28.sep}`) && i.names.length);
6002
6757
  checks.push(
6003
6758
  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)" }
6004
6759
  );
6005
6760
  }
6006
6761
  const launcher = launcherPath();
6007
- if (fs30.existsSync(launcher)) {
6008
- const r = spawnSync8(launcher, ["--version"], { encoding: "utf8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"] });
6762
+ if (fs32.existsSync(launcher)) {
6763
+ const r = spawnSync9(launcher, ["--version"], { encoding: "utf8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"] });
6009
6764
  checks.push(
6010
6765
  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)}` }
6011
6766
  );
@@ -6015,13 +6770,13 @@ function skillChecks() {
6015
6770
  return checks;
6016
6771
  }
6017
6772
  function geminiExtensionInstalled() {
6018
- const home2 = process.env.GEMINI_CLI_HOME ?? os10.homedir();
6019
- return fs30.existsSync(path26.join(home2, ".gemini", "extensions", "pitroom"));
6773
+ const home3 = process.env.GEMINI_CLI_HOME ?? os11.homedir();
6774
+ return fs32.existsSync(path28.join(home3, ".gemini", "extensions", "pitroom"));
6020
6775
  }
6021
6776
  function pluginInstalled() {
6022
6777
  try {
6023
- const f = path26.join(os10.homedir(), ".claude", "plugins", "installed_plugins.json");
6024
- const plugins = JSON.parse(fs30.readFileSync(f, "utf8")).plugins ?? {};
6778
+ const f = path28.join(os11.homedir(), ".claude", "plugins", "installed_plugins.json");
6779
+ const plugins = JSON.parse(fs32.readFileSync(f, "utf8")).plugins ?? {};
6025
6780
  return Object.keys(plugins).some((k) => k.startsWith("pitroom@"));
6026
6781
  } catch {
6027
6782
  return false;
@@ -6038,24 +6793,24 @@ function superpowersActive() {
6038
6793
  for (const p of openCodePlugins()) {
6039
6794
  if (/superpowers/i.test(p)) found.push(`OpenCode plugin ${p}`);
6040
6795
  }
6041
- for (const base2 of [path26.join(os10.homedir(), ".agents", "skills"), path26.join(os10.homedir(), ".claude", "skills")]) {
6042
- const dir = path26.join(base2, "using-superpowers");
6043
- if (fs30.existsSync(path26.join(dir, "SKILL.md"))) found.push(dir);
6796
+ for (const base2 of [path28.join(os11.homedir(), ".agents", "skills"), path28.join(os11.homedir(), ".claude", "skills")]) {
6797
+ const dir = path28.join(base2, "using-superpowers");
6798
+ if (fs32.existsSync(path28.join(dir, "SKILL.md"))) found.push(dir);
6044
6799
  }
6045
6800
  return found;
6046
6801
  }
6047
6802
  function claudePlugins() {
6048
6803
  try {
6049
- const f = path26.join(os10.homedir(), ".claude", "plugins", "installed_plugins.json");
6050
- return Object.keys(JSON.parse(fs30.readFileSync(f, "utf8")).plugins ?? {});
6804
+ const f = path28.join(os11.homedir(), ".claude", "plugins", "installed_plugins.json");
6805
+ return Object.keys(JSON.parse(fs32.readFileSync(f, "utf8")).plugins ?? {});
6051
6806
  } catch {
6052
6807
  return [];
6053
6808
  }
6054
6809
  }
6055
6810
  function claudePluginEnabled(key) {
6056
6811
  try {
6057
- const f = path26.join(os10.homedir(), ".claude", "settings.json");
6058
- return JSON.parse(fs30.readFileSync(f, "utf8")).enabledPlugins?.[key] !== false;
6812
+ const f = path28.join(os11.homedir(), ".claude", "settings.json");
6813
+ return JSON.parse(fs32.readFileSync(f, "utf8")).enabledPlugins?.[key] !== false;
6059
6814
  } catch {
6060
6815
  return true;
6061
6816
  }
@@ -6064,7 +6819,7 @@ function codexPlugins() {
6064
6819
  const plugins = /* @__PURE__ */ new Map();
6065
6820
  let text;
6066
6821
  try {
6067
- text = fs30.readFileSync(path26.join(process.env.CODEX_HOME ?? path26.join(os10.homedir(), ".codex"), "config.toml"), "utf8");
6822
+ text = fs32.readFileSync(path28.join(process.env.CODEX_HOME ?? path28.join(os11.homedir(), ".codex"), "config.toml"), "utf8");
6068
6823
  } catch {
6069
6824
  return plugins;
6070
6825
  }
@@ -6082,11 +6837,11 @@ function codexPlugins() {
6082
6837
  return plugins;
6083
6838
  }
6084
6839
  function openCodePlugins() {
6085
- const dir = path26.join(process.env.XDG_CONFIG_HOME ?? path26.join(os10.homedir(), ".config"), "opencode");
6840
+ const dir = path28.join(process.env.XDG_CONFIG_HOME ?? path28.join(os11.homedir(), ".config"), "opencode");
6086
6841
  const plugins = [];
6087
6842
  for (const name of ["opencode.json", "opencode.jsonc"]) {
6088
6843
  try {
6089
- const text = fs30.readFileSync(path26.join(dir, name), "utf8").replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, "");
6844
+ const text = fs32.readFileSync(path28.join(dir, name), "utf8").replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, "");
6090
6845
  const list2 = JSON.parse(text).plugin;
6091
6846
  if (Array.isArray(list2)) plugins.push(...list2.filter((p) => typeof p === "string"));
6092
6847
  } catch {
@@ -6094,7 +6849,7 @@ function openCodePlugins() {
6094
6849
  }
6095
6850
  for (const folder of ["plugin", "plugins"]) {
6096
6851
  try {
6097
- for (const f of fs30.readdirSync(path26.join(dir, folder))) plugins.push(path26.join(dir, folder, f));
6852
+ for (const f of fs32.readdirSync(path28.join(dir, folder))) plugins.push(path28.join(dir, folder, f));
6098
6853
  } catch {
6099
6854
  }
6100
6855
  }
@@ -6146,7 +6901,9 @@ Usage
6146
6901
  pitroom hook-start SessionStart hook: introduces Pitroom to the agent (Codex; Claude Code uses its plugin)
6147
6902
  pitroom cooldown [--clear] models that said "rate limited" and are skipped for a while (a quota used up);
6148
6903
  --clear tries them again
6149
- pitroom mcp serve Pitroom as MCP tools on stdio (Cursor, Claude Desktop, Gemini CLI, \u2026)
6904
+ pitroom mcp [-d DIR] [--http [--port N]]
6905
+ serve Pitroom as MCP tools on stdio (Cursor, Claude Desktop, Gemini CLI, \u2026),
6906
+ or with --http at http://127.0.0.1:7117/mcp (bearer token, local only)
6150
6907
  pitroom doctor [--probe] check workers, models, permissions, skills
6151
6908
  pitroom config effective settings, where each comes from, config file path
6152
6909
  pitroom init [--model ID] [--fallback A,B] [--yes] [--force]
@@ -6154,7 +6911,10 @@ Usage
6154
6911
  writes it only with --yes (models are suggested, never chosen for you)
6155
6912
  pitroom install [--copy] [--force] link the skills into ~/.agents/skills + ~/.claude/skills,
6156
6913
  and the CLI into ~/.local/bin
6157
- pitroom uninstall remove what install linked
6914
+ pitroom install --mcp [--client A,B] [--no-skills] [--dry-run]
6915
+ also register "pitroom mcp" in the MCP clients found (Claude Code, Codex,
6916
+ Gemini CLI, Cursor, Claude Desktop); --dry-run only says what it would do
6917
+ pitroom uninstall remove what install linked (and the MCP registrations)
6158
6918
  pitroom clean [--days 14] [--yes]
6159
6919
 
6160
6920
  Run options
@@ -6199,7 +6959,18 @@ var COMMANDS = {
6199
6959
  review: cmdReview,
6200
6960
  audit: cmdAudit,
6201
6961
  cooldown: cmdCooldown,
6202
- mcp: () => serveMcp(),
6962
+ mcp: (p) => {
6963
+ const port = flag(p, "port");
6964
+ if (port !== void 0 && !has(p, "http")) throw new UserError("--port goes with --http (on stdio there is no port)");
6965
+ 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)");
6966
+ const dir = flag(p, "dir");
6967
+ if (dir !== void 0) {
6968
+ if (!fs33.statSync(dir, { throwIfNoEntry: false })?.isDirectory()) throw new UserError(`-d ${dir}: no such directory`);
6969
+ process.chdir(dir);
6970
+ process.env.PWD = process.cwd();
6971
+ }
6972
+ return has(p, "http") ? serveMcpHttp({ port: port === void 0 ? DEFAULT_PORT2 : Number(port) }) : serveMcp();
6973
+ },
6203
6974
  plan: cmdPlan,
6204
6975
  status: cmdStatus,
6205
6976
  wait: cmdWait,