pitroom 0.15.0 → 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) {
@@ -1578,6 +1581,7 @@ var BOOL_FLAGS = {
1578
1581
  "--stop": "stop",
1579
1582
  "--open": "open",
1580
1583
  "--serve": "serve",
1584
+ "--http": "http",
1581
1585
  "-h": "help",
1582
1586
  "--help": "help",
1583
1587
  "-v": "version",
@@ -1680,7 +1684,6 @@ function effortFlag(p) {
1680
1684
  }
1681
1685
 
1682
1686
  // src/cli/mcp.ts
1683
- import { spawn as spawn3 } from "node:child_process";
1684
1687
  import readline from "node:readline";
1685
1688
 
1686
1689
  // src/core/run.ts
@@ -3129,12 +3132,12 @@ function hookCards(input) {
3129
3132
  const output = typeof response === "string" ? response : [response?.stdout, response?.stderr].filter(Boolean).join("\n");
3130
3133
  const ids = [...new Set(`${command}
3131
3134
  ${output}`.match(RUN_ID) ?? [])].filter((id) => fs21.existsSync(`${runsDir()}/${id}`));
3132
- const since = Date.now() - RECENT_HOURS * 36e5;
3135
+ const since2 = Date.now() - RECENT_HOURS * 36e5;
3133
3136
  for (const id of listRunIds().slice(-RECENT)) {
3134
3137
  if (ids.includes(id) || fs21.existsSync(runFile(id, "card-ended"))) continue;
3135
3138
  try {
3136
3139
  const m = readMeta(id);
3137
- 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);
3138
3141
  } catch {
3139
3142
  }
3140
3143
  }
@@ -3408,17 +3411,17 @@ function listHistory(q = {}) {
3408
3411
  }
3409
3412
  }
3410
3413
  var NOT_AUDIT = "json_extract(meta_json, '$.auditOf') IS NULL";
3411
- function auditStats(db, since) {
3414
+ function auditStats(db, since2) {
3412
3415
  const total = { runs: 0, agree: 0, partial: 0, disagree: 0, unclear: 0, tokens: 0 };
3413
3416
  const byWorker = /* @__PURE__ */ new Map();
3414
3417
  try {
3415
- const t = db.prepare("SELECT COUNT(*) runs, COALESCE(SUM(tokens),0) tokens FROM runs WHERE started_at >= ? AND json_extract(meta_json, '$.auditOf') IS NOT NULL").get(since);
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);
3416
3419
  total.runs = t.runs;
3417
3420
  total.tokens = t.tokens;
3418
3421
  const rows = db.prepare(`SELECT r.backend, r.model, json_extract(a.meta_json, '$.auditVerdict') v, COUNT(*) n
3419
3422
  FROM runs a JOIN runs r ON r.id = json_extract(a.meta_json, '$.auditOf')
3420
3423
  WHERE a.started_at >= ? AND a.state = 'done' AND json_extract(a.meta_json, '$.auditOf') IS NOT NULL
3421
- GROUP BY r.backend, r.model, v`).all(since);
3424
+ GROUP BY r.backend, r.model, v`).all(since2);
3422
3425
  for (const r of rows) {
3423
3426
  const v = r.v ?? "unclear";
3424
3427
  if (v in total && v !== "runs" && v !== "tokens") total[v] += r.n;
@@ -3456,12 +3459,12 @@ function historyStats(sinceMs2) {
3456
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: [] };
3457
3460
  const db = openDb();
3458
3461
  if (!db) return empty;
3459
- const since = sinceMs2 ? new Date(sinceMs2).toISOString() : "";
3462
+ const since2 = sinceMs2 ? new Date(sinceMs2).toISOString() : "";
3460
3463
  try {
3461
- const t = db.prepare(`SELECT COUNT(*) runs, COALESCE(SUM(state='done'),0) ok, COALESCE(SUM(seconds),0) seconds, COALESCE(SUM(tokens),0) tokens, COALESCE(SUM(saved),0) saved FROM runs WHERE started_at >= ? AND ${NOT_AUDIT}`).get(since);
3462
- const w = db.prepare(`SELECT backend, model, COUNT(*) runs, SUM(state='done') ok, AVG(seconds) avg_s, AVG(tokens) avg_t, COALESCE(SUM(saved),0) saved FROM runs WHERE started_at >= ? AND ${NOT_AUDIT} GROUP BY backend, model ORDER BY runs DESC LIMIT 40`).all(since);
3463
- const d = db.prepare(`SELECT substr(started_at,1,10) day, COUNT(*) runs, SUM(state='done') ok, COALESCE(SUM(saved),0) saved FROM runs WHERE started_at >= ? AND ${NOT_AUDIT} GROUP BY day ORDER BY day DESC LIMIT 60`).all(since);
3464
- const audits = auditStats(db, since);
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);
3465
3468
  const ledger = readLedger(sinceMs2);
3466
3469
  const byDay = /* @__PURE__ */ new Map();
3467
3470
  for (const e of ledger) byDay.set(e.at.slice(0, 10), (byDay.get(e.at.slice(0, 10)) ?? 0) + e.saved);
@@ -3631,7 +3634,7 @@ function progress(meta) {
3631
3634
  }
3632
3635
 
3633
3636
  // src/core/run.ts
3634
- var VERSION2 = true ? "0.15.0" : "0.0.0-dev";
3637
+ var VERSION2 = true ? "0.16.0" : "0.0.0-dev";
3635
3638
  function planWork(o) {
3636
3639
  if (!o.plan) return { task: o.task.trim(), tier: o.tier };
3637
3640
  if (o.continueFrom) throw new UserError("a follow-up continues its parent's task; drop --plan/--step");
@@ -4059,17 +4062,124 @@ function cleanupWorktree(meta) {
4059
4062
  if (meta.worktree && fs24.existsSync(meta.worktree)) removeIsolatedCopy(meta.worktree, worktreesDir());
4060
4063
  }
4061
4064
 
4062
- // src/cli/mcp.ts
4063
- 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
4064
4178
  var MAX_OUTPUT = 12e4;
4065
4179
  var DEFAULT_WAIT = 50;
4066
4180
  var MAX_WAIT = 540;
4067
- var INSTRUCTIONS = [
4068
- "Pitroom hands bounded work to cheaper worker agents and returns a verified answer, the exact diff and a cost receipt. You decide, verify and answer.",
4069
- 'Use pitroom_run with mode "read" for research and locating code, mode "isolate" for code changes (the worker edits a copy; check it with pitroom_review, then pitroom_apply or pitroom_discard).',
4070
- 'A run can take minutes: pitroom_run waits up to waitSeconds, then returns the run id as "still running"; call pitroom_wait with it.',
4071
- "It is optional: for a small task you can do yourself, skip it."
4072
- ].join(" ");
4181
+ var MAX_TASKS = 20;
4182
+ var PROGRESS_EVERY_MS = 5e3;
4073
4183
  var ToolError = class extends Error {
4074
4184
  };
4075
4185
  var str = (a, key, required = false) => {
@@ -4105,6 +4215,12 @@ var oneOf = (a, key, allowed, fallback) => {
4105
4215
  if (!allowed.includes(v)) throw new ToolError(`"${key}" must be one of: ${allowed.join(", ")}`);
4106
4216
  return v;
4107
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
+ };
4108
4224
  function workerFlags(a, only) {
4109
4225
  const out = [];
4110
4226
  const on = (key) => !only || only.includes(key);
@@ -4122,7 +4238,7 @@ function workerFlags(a, only) {
4122
4238
  var strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
4123
4239
  var clip6 = (s) => s.length > MAX_OUTPUT ? `${s.slice(0, MAX_OUTPUT)}
4124
4240
  \u2026 (${s.length - MAX_OUTPUT} more characters)` : s;
4125
- function pit(args, timeoutMs = 60 * 6e4) {
4241
+ function pit(args, timeoutMs = 60 * 6e4, signal) {
4126
4242
  return new Promise((resolve2) => {
4127
4243
  const script = process.argv[1];
4128
4244
  if (!script) return resolve2({ code: 1, out: "", err: "cannot locate the pitroom executable" });
@@ -4131,10 +4247,14 @@ function pit(args, timeoutMs = 60 * 6e4) {
4131
4247
  let err = "";
4132
4248
  child.stdout.on("data", (d) => out += d);
4133
4249
  child.stderr.on("data", (d) => err += d);
4134
- 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();
4135
4254
  child.on("error", (e) => resolve2({ code: 1, out, err: err || String(e.message) }));
4136
4255
  child.on("close", (code) => {
4137
4256
  clearTimeout(timer);
4257
+ signal?.removeEventListener("abort", stop);
4138
4258
  resolve2({ code, out: strip(out).trim(), err: strip(err).trim() });
4139
4259
  });
4140
4260
  });
@@ -4146,7 +4266,42 @@ function asResult(r, still) {
4146
4266
  ${still}`) };
4147
4267
  return { text: clip6(text), isError: r.code === 2 || r.code === 3 || r.code === 1 && !r.out };
4148
4268
  }
4149
- 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) {
4150
4305
  const started = await pit([...start, "--bg", "--json", ...task === void 0 ? [] : ["--", task]]);
4151
4306
  if (started.code !== 0) return asResult(started);
4152
4307
  let id;
@@ -4155,14 +4310,117 @@ async function startAndWait(start, seconds, task) {
4155
4310
  } catch {
4156
4311
  return { text: `could not read the run id from: ${started.out.slice(0, 200)}`, isError: true };
4157
4312
  }
4158
- return waitFor([id], seconds);
4313
+ const result = await waitFor([id], seconds, ctx);
4314
+ if (cancelled(ctx)) await stopQuietly(["stop", id]);
4315
+ return result;
4159
4316
  }
4160
- async function waitFor(ids, seconds, group) {
4161
- const args = group ? ["wait", "-g", group, "--timeout", String(seconds)] : ["wait", ...ids, "--timeout", String(seconds)];
4162
- const r = await pit(args, (seconds + 60) * 1e3);
4163
- const again = group ? `{"group": "${group}"}` : `{"runs": ${JSON.stringify(ids)}}`;
4164
- return asResult(r, `Not finished yet: call pitroom_wait with ${again} (waitSeconds up to ${MAX_WAIT}); pitroom_stop ends it.`);
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) } }] };
4165
4421
  }
4422
+
4423
+ // src/cli/mcp-tools.ts
4166
4424
  var WORKER_PROPS = {
4167
4425
  worker: { type: "string", description: 'Worker target "backend[:model]", e.g. "opencode", "codex", "claude:haiku", "gemini:gemini-3.8-flash". Default: the configured worker.' },
4168
4426
  model: { type: "string", description: "Model for the preferred worker." },
@@ -4177,16 +4435,19 @@ var WORKER_PROPS = {
4177
4435
  };
4178
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}).` } };
4179
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 };
4180
4440
  var TOOLS2 = [
4181
4441
  {
4182
4442
  name: "pitroom_run",
4183
4443
  title: "Run a worker",
4184
- description: 'Hand a bounded task to a cheaper worker agent. mode "read" (default) is read-only research that returns an answer whose file:line references are verified; "isolate" lets the worker edit a private copy and returns the exact diff (review it, then pitroom_apply or pitroom_discard); "write" edits the working tree in place and is undoable with the CLI (`pitroom revert`). Returns the worker\'s answer and a receipt, or "still running" with the run id.',
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.`,
4185
4445
  inputSchema: {
4186
4446
  type: "object",
4187
4447
  properties: {
4188
4448
  task: { type: "string", description: "What the worker should do, with the context it needs." },
4189
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)." },
4190
4451
  inPlace: { type: "boolean", description: "Read mode only: read the directory itself instead of a clean snapshot without secret-looking files." },
4191
4452
  audit: { type: "boolean", description: "Read mode only: have another worker re-check the answer afterwards." },
4192
4453
  ...WORKER_PROPS,
@@ -4195,15 +4456,40 @@ var TOOLS2 = [
4195
4456
  required: ["task"]
4196
4457
  },
4197
4458
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
4198
- async call(a) {
4459
+ async call(a, ctx) {
4199
4460
  const task = str(a, "task", true);
4200
4461
  const mode = oneOf(a, "mode", ["read", "isolate", "write"], "read");
4201
4462
  const flags = mode === "isolate" ? ["-i"] : mode === "write" ? ["-w"] : [];
4463
+ const follow = str(a, "continue");
4464
+ if (follow) flags.push("--continue", follow);
4202
4465
  if (mode === "read") {
4203
4466
  if (bool(a, "inPlace")) flags.push("--in-place");
4204
4467
  if (bool(a, "audit")) flags.push("--audit");
4205
4468
  }
4206
- 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);
4207
4493
  }
4208
4494
  },
4209
4495
  {
@@ -4214,12 +4500,12 @@ var TOOLS2 = [
4214
4500
  type: "object",
4215
4501
  properties: { runs: { type: "array", items: { type: "string" }, description: "Run ids." }, group: { type: "string", description: "Or a group name." }, ...WAIT_PROP }
4216
4502
  },
4217
- annotations: { readOnlyHint: true, openWorldHint: false },
4218
- async call(a) {
4503
+ annotations: READ_ONLY,
4504
+ async call(a, ctx) {
4219
4505
  const runs = strs(a, "runs");
4220
4506
  const group = str(a, "group");
4221
4507
  if (!runs.length && !group) throw new ToolError('give "runs" or "group"');
4222
- return waitFor(runs, waitSeconds(a), runs.length ? void 0 : group);
4508
+ return waitFor(runs, waitSeconds(a), ctx, runs.length ? void 0 : group);
4223
4509
  }
4224
4510
  },
4225
4511
  {
@@ -4227,11 +4513,23 @@ var TOOLS2 = [
4227
4513
  title: "Run status",
4228
4514
  description: "The state and live progress of a run (default: the latest), or of a group.",
4229
4515
  inputSchema: { type: "object", properties: { run: RUN_ID2, group: { type: "string", description: "A group name." } } },
4230
- annotations: { readOnlyHint: true, openWorldHint: false },
4231
- async call(a) {
4516
+ annotations: READ_ONLY,
4517
+ async call(a, ctx) {
4232
4518
  const group = str(a, "group");
4233
4519
  const run2 = str(a, "run");
4234
- return asResult(await pit(["status", ...group ? ["-g", group] : run2 ? [run2] : []], 6e4));
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) {
4531
+ const group = str(a, "group");
4532
+ return plain(["ls", ...bool(a, "running") ? ["--running"] : [], ...group ? ["-g", group] : []], ctx);
4235
4533
  }
4236
4534
  },
4237
4535
  {
@@ -4239,10 +4537,85 @@ var TOOLS2 = [
4239
4537
  title: "Show a run",
4240
4538
  description: "A finished run's report again; with patch the exact diff of an isolated change, with full the untruncated answer.",
4241
4539
  inputSchema: { type: "object", properties: { run: RUN_ID2, patch: { type: "boolean" }, full: { type: "boolean" } }, required: ["run"] },
4242
- annotations: { readOnlyHint: true, openWorldHint: false },
4243
- async call(a) {
4540
+ annotations: READ_ONLY,
4541
+ async call(a, ctx) {
4244
4542
  const run2 = str(a, "run", true);
4245
- return asResult(await pit(["show", run2, ...bool(a, "patch") ? ["--patch"] : bool(a, "full") ? ["--full"] : []], 6e4));
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);
4246
4619
  }
4247
4620
  },
4248
4621
  {
@@ -4263,13 +4636,13 @@ var TOOLS2 = [
4263
4636
  }
4264
4637
  },
4265
4638
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
4266
- async call(a) {
4639
+ async call(a, ctx) {
4267
4640
  const run2 = str(a, "run");
4268
4641
  const range = str(a, "range");
4269
4642
  if (!run2 === !range) throw new ToolError('give exactly one of "run" or "range"');
4270
4643
  const plan = str(a, "plan");
4271
4644
  const flags = workerFlags(a, ["worker", "tier", "dir", "group"]);
4272
- return startAndWait(["review", ...range ? ["--range", range, ...plan ? ["--plan", plan] : []] : [run2], ...flags], waitSeconds(a));
4645
+ return startAndWait(["review", ...range ? ["--range", range, ...plan ? ["--plan", plan] : []] : [run2], ...flags], waitSeconds(a), ctx);
4273
4646
  }
4274
4647
  },
4275
4648
  {
@@ -4278,23 +4651,24 @@ var TOOLS2 = [
4278
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.",
4279
4652
  inputSchema: { type: "object", properties: { run: RUN_ID2, worker: WORKER_PROPS.worker, ...WAIT_PROP }, required: ["run"] },
4280
4653
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
4281
- async call(a) {
4654
+ async call(a, ctx) {
4282
4655
  const run2 = str(a, "run", true);
4283
4656
  const worker = str(a, "worker");
4284
- return startAndWait(["audit", run2, ...worker ? ["-W", worker] : []], waitSeconds(a));
4657
+ return startAndWait(["audit", run2, ...worker ? ["-W", worker] : []], waitSeconds(a), ctx);
4285
4658
  }
4286
4659
  },
4287
4660
  {
4288
4661
  name: "pitroom_apply",
4289
4662
  title: "Apply an isolated change",
4290
- description: "Land an isolated run's patch on the user's working tree (checked first; refused if it no longer applies cleanly). A patch that deletes files is refused unless allowDelete is true: only pass it after checking the deletions are what the user asked for.",
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.",
4291
4664
  inputSchema: { type: "object", properties: { run: RUN_ID2, group: { type: "string" }, allowDelete: { type: "boolean" } } },
4292
4665
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
4293
- async call(a) {
4666
+ async call(a, ctx) {
4294
4667
  const group = str(a, "group");
4295
4668
  const run2 = str(a, "run");
4296
4669
  if (!run2 && !group) throw new ToolError('give "run" or "group"');
4297
- return asResult(await pit(["apply", ...group && !run2 ? ["-g", group] : [run2], ...bool(a, "allowDelete") ? ["--allow-delete"] : []], 12e4));
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);
4298
4672
  }
4299
4673
  },
4300
4674
  {
@@ -4303,8 +4677,18 @@ var TOOLS2 = [
4303
4677
  description: "Drop an isolated run's private copy (its patch is kept).",
4304
4678
  inputSchema: { type: "object", properties: { run: RUN_ID2 }, required: ["run"] },
4305
4679
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
4306
- async call(a) {
4307
- 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);
4308
4692
  }
4309
4693
  },
4310
4694
  {
@@ -4313,31 +4697,53 @@ var TOOLS2 = [
4313
4697
  description: "Stop a running or queued run, or every run of a group.",
4314
4698
  inputSchema: { type: "object", properties: { run: RUN_ID2, group: { type: "string" } } },
4315
4699
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
4316
- async call(a) {
4700
+ async call(a, ctx) {
4317
4701
  const run2 = str(a, "run");
4318
4702
  const group = str(a, "group");
4319
4703
  if (!run2 && !group) throw new ToolError('give "run" or "group"');
4320
- return asResult(await pit(["stop", ...run2 ? [run2] : ["-g", group]], 6e4));
4704
+ if (run2 && group) throw new ToolError('give "run" or "group", not both');
4705
+ return plain(["stop", ...run2 ? [run2] : ["-g", group]], ctx);
4321
4706
  }
4322
4707
  }
4323
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(" ");
4324
4718
  var log = (msg) => void process.stderr.write(`pitroom mcp: ${msg}
4325
4719
  `);
4326
- var send = (msg) => void process.stdout.write(`${JSON.stringify(msg)}
4327
- `);
4328
- var RpcError = class extends Error {
4329
- constructor(code, message) {
4330
- super(message);
4331
- this.code = code;
4332
- }
4333
- };
4334
- 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) {
4335
4741
  switch (method) {
4336
4742
  case "initialize": {
4337
4743
  const asked = typeof params.protocolVersion === "string" ? params.protocolVersion : "";
4338
4744
  return {
4339
4745
  protocolVersion: PROTOCOLS.includes(asked) ? asked : PROTOCOLS[0],
4340
- capabilities: { tools: { listChanged: false } },
4746
+ capabilities: { tools: { listChanged: false }, resources: { listChanged: false, subscribe: false }, prompts: { listChanged: false } },
4341
4747
  serverInfo: { name: "pitroom", title: "Pitroom", version: VERSION2 },
4342
4748
  instructions: INSTRUCTIONS
4343
4749
  };
@@ -4351,7 +4757,7 @@ async function dispatch(method, params) {
4351
4757
  if (!tool) throw new RpcError(-32602, `unknown tool: ${String(params.name)}`);
4352
4758
  const args = params.arguments && typeof params.arguments === "object" && !Array.isArray(params.arguments) ? params.arguments : {};
4353
4759
  try {
4354
- const r = await tool.call(args);
4760
+ const r = await tool.call(args, ctx);
4355
4761
  return { content: [{ type: "text", text: r.text }], isError: r.isError === true };
4356
4762
  } catch (e) {
4357
4763
  if (e instanceof ToolError) return { content: [{ type: "text", text: e.message }], isError: true };
@@ -4359,1194 +4765,1306 @@ async function dispatch(method, params) {
4359
4765
  return { content: [{ type: "text", text: `pitroom: ${e.message}` }], isError: true };
4360
4766
  }
4361
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);
4362
4779
  default:
4363
4780
  throw new RpcError(-32601, `method not found: ${method}`);
4364
4781
  }
4365
4782
  }
4366
- 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) {
4367
4789
  if (!msg || typeof msg !== "object" || Array.isArray(msg)) return { jsonrpc: "2.0", id: null, error: { code: -32600, message: "invalid request" } };
4368
4790
  const m = msg;
4369
4791
  if (typeof m.method !== "string") return void 0;
4370
- 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);
4371
4801
  try {
4372
- 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;
4373
4804
  return isRequest ? { jsonrpc: "2.0", id: m.id, result } : void 0;
4374
4805
  } catch (e) {
4375
- if (!isRequest) return void 0;
4806
+ if (!isRequest || abort.signal.aborted && abort.signal.reason === "cancelled") return void 0;
4376
4807
  const code = e instanceof RpcError ? e.code : -32603;
4377
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);
4378
4811
  }
4379
4812
  }
4380
- async function handleLine(line) {
4381
- let parsed;
4382
- try {
4383
- parsed = JSON.parse(line);
4384
- } catch {
4385
- send({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
4386
- return;
4387
- }
4813
+ async function handlePayload(session, parsed, notify) {
4388
4814
  if (Array.isArray(parsed)) {
4389
- const replies = (await Promise.all(parsed.map(handle))).filter((r) => !!r);
4390
- if (replies.length) send(replies);
4391
- return;
4815
+ const replies = (await Promise.all(parsed.map((m) => handle(session, m, notify)))).filter((r) => !!r);
4816
+ return replies.length ? replies : void 0;
4392
4817
  }
4393
- const reply = await handle(parsed);
4394
- if (reply) send(reply);
4818
+ return handle(session, parsed, notify);
4395
4819
  }
4820
+ var parseError = () => ({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
4396
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();
4397
4827
  const rl = readline.createInterface({ input: process.stdin });
4398
- const inflight = /* @__PURE__ */ new Set();
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
+ };
4399
4840
  rl.on("line", (line) => {
4400
4841
  if (!line.trim()) return;
4401
4842
  const p = handleLine(line).catch((e) => log(String(e.stack ?? e)));
4402
- inflight.add(p);
4403
- void p.finally(() => inflight.delete(p));
4843
+ pending.add(p);
4844
+ void p.finally(() => pending.delete(p));
4404
4845
  });
4405
4846
  await new Promise((resolve2) => rl.once("close", resolve2));
4406
- await Promise.allSettled([...inflight]);
4847
+ await Promise.allSettled([...pending]);
4407
4848
  return 0;
4408
4849
  }
4409
4850
 
4410
- // src/cli/commands.ts
4411
- import { spawnSync as spawnSync8 } from "node:child_process";
4412
- import fs30 from "node:fs";
4413
- import path26 from "node:path";
4414
- import { fileURLToPath as fileURLToPath4 } from "node:url";
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";
4415
4856
 
4416
- // src/core/init.ts
4857
+ // src/core/dash.ts
4858
+ import { spawn as spawn4 } from "node:child_process";
4859
+ import crypto6 from "node:crypto";
4417
4860
  import fs25 from "node:fs";
4861
+ import http from "node:http";
4418
4862
  import path21 from "node:path";
4419
- var FREE = /-free$/;
4420
- var TIER_ORDER = [["cheap", "opencode"], ["standard", "codex"], ["capable", "claude"]];
4421
- var isFound = (binary5) => path21.isAbsolute(binary5) && fs25.existsSync(binary5);
4422
- function planInit(opts = {}) {
4423
- const file2 = configPath();
4424
- const workers = backendIds().map((id) => {
4425
- const b = getBackend(id);
4426
- const binary5 = b.binary();
4427
- return { id, name: b.name, found: isFound(binary5), binary: binary5 };
4428
- });
4429
- const notes = [];
4430
- const config = {};
4431
- let blocked;
4432
- let opencode2;
4433
- if (workers.find((w) => w.id === "opencode")?.found) {
4434
- const b = getBackend("opencode");
4435
- let models = [];
4436
- try {
4437
- models = b.catalog ? b.catalog().models.map((m) => m.id) : b.listModels?.() ?? [];
4438
- } catch {
4439
- notes.push("could not list OpenCode models (is it logged in?)");
4440
- }
4441
- const defaultModel4 = b.defaultModel?.();
4442
- const chosen = opts.model ?? defaultModel4;
4443
- const free = models.filter((m) => FREE.test(m) && m !== chosen);
4444
- opencode2 = { defaultModel: defaultModel4, models: models.length, free: free.slice(0, 6) };
4445
- if (opts.model) {
4446
- if (models.length && !models.includes(opts.model)) throw new UserError(`"${opts.model}" is not in \`opencode models\``);
4447
- if (!models.length) notes.push(`could not check "${opts.model}": \`opencode models\` listed nothing`);
4448
- config.models = { opencode: opts.model };
4449
- } else if (!defaultModel4) {
4450
- blocked = `OpenCode has no default model: choose one with --model <id>${free.length ? ` (free models you have: ${free.slice(0, 4).join(", ")})` : ""}`;
4451
- }
4452
- const fallback = opts.fallback ?? free.slice(0, 2);
4453
- for (const m of fallback) {
4454
- if (models.length && !models.includes(m)) throw new UserError(`"${m}" is not in \`opencode models\``);
4455
- }
4456
- if (fallback.length) {
4457
- config.fallback = fallback.map((m) => `opencode:${m}`);
4458
- if (!opts.fallback) notes.push(`fallback suggested from the free models in your catalogue: ${fallback.join(", ")} (change it with --fallback a,b)`);
4459
- } else {
4460
- notes.push("no fallback: no free OpenCode model found to suggest (pass --fallback a,b to name some)");
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]));
4461
4875
  }
4876
+ path29 = new TextDecoder().decode(new Uint8Array(bytes));
4462
4877
  }
4463
- const tiers = Object.fromEntries(TIER_ORDER.filter(([, id]) => workers.find((w) => w.id === id)?.found));
4464
- if (Object.keys(tiers).length > 1) config.tiers = tiers;
4465
- if (!workers.some((w) => w.found)) blocked = "no worker CLI found: install OpenCode (https://opencode.ai), Codex CLI, Claude Code or Gemini CLI first";
4466
- else if (!blocked && !Object.keys(config).length) notes.push("nothing to propose: the default model and the single worker need no config");
4467
- return { path: file2, exists: fs25.existsSync(file2), workers, opencode: opencode2, config, blocked, notes };
4468
- }
4469
- function writeInit(plan, force) {
4470
- if (plan.blocked) throw new UserError(plan.blocked);
4471
- if (!Object.keys(plan.config).length) throw new UserError("nothing to write: the proposal is empty");
4472
- if (plan.exists && !force) throw new UserError(`${plan.path} already exists: pass --force to replace it (the old file is kept as ${path21.basename(plan.path)}.bak)`);
4473
- fs25.mkdirSync(path21.dirname(plan.path), { recursive: true });
4474
- if (plan.exists) fs25.copyFileSync(plan.path, `${plan.path}.bak`);
4475
- fs25.writeFileSync(plan.path, `${JSON.stringify(plan.config, null, 2)}
4476
- `);
4477
- return plan.path;
4878
+ return prefix ? path29.replace(/^[ab]\//, "") : path29;
4478
4879
  }
4479
- function formatInit(plan, written) {
4480
- const out = [bold("pitroom init") + dim(": worker CLIs on this machine")];
4481
- for (const w of plan.workers) out.push(` ${w.found ? green("\u2714") : dim("\xB7")} ${w.name.padEnd(12)} ${w.found ? w.binary : dim("not found")}`);
4482
- if (plan.opencode) {
4483
- const o = plan.opencode;
4484
- out.push(` OpenCode: ${o.models} models, default ${o.defaultModel ?? "none"}${o.free.length ? `, free: ${o.free.slice(0, 3).join(", ")}${o.free.length > 3 ? ", \u2026" : ""}` : ""}`);
4485
- }
4486
- out.push("", `config file: ${plan.path}${plan.exists ? " (exists)" : " (not present)"}`, JSON.stringify(plan.config, null, 2));
4487
- for (const n of plan.notes) out.push(` note: ${n}`);
4488
- if (written) out.push("", green(`\u2714 written to ${written}`), dim(" next: pitroom doctor, then pitroom install"));
4489
- else if (plan.blocked) out.push("", yellow(`! ${plan.blocked}`));
4490
- else out.push("", `nothing written yet: pass --yes to write it${plan.exists ? " (with --force, since the file exists)" : ""}`);
4491
- return out.join("\n");
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));
4492
4884
  }
4493
-
4494
- // src/core/group.ts
4495
- function groupIds(group) {
4496
- return listRunIds().filter((id) => {
4497
- try {
4498
- return readMeta(id).group === group;
4499
- } catch {
4500
- return false;
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
+ }
4501
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
+ };
4502
4973
  });
4974
+ return [...result, ...byPath.values()];
4503
4975
  }
4504
- 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) => {
4505
4994
  const t = s.replace(/\s+/g, " ").trim();
4506
4995
  return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
4507
4996
  };
4508
- function headline(meta, max = 160) {
4509
- const text = readSummary(meta);
4510
- const line = text.split("\n").find((l) => /^SUMMARY:/i.test(l.trim())) ?? text.split("\n").find((l) => l.trim()) ?? "";
4511
- return oneLine5(line.replace(/^SUMMARY:\s*/i, ""), max);
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";
4512
5002
  }
4513
- function table(metas) {
4514
- if (!metas.length) return "no runs";
4515
- const rows = metas.map((m) => {
4516
- const l = m.state === "running" ? live(m) : void 0;
4517
- const steps = l ? String(l.steps) : m.usage ? String(m.usage.steps) : "-";
4518
- const note = l?.last ? oneLine5(l.last, 48) : isActive(m.state) ? oneLine5(m.task, 48) : headline(m, 48) || oneLine5(m.error ?? m.task, 48);
4519
- return [m.id, m.state, m.mode, duration(m), steps, describeTarget(m.ran ?? m.worker), note];
4520
- });
4521
- const head = ["RUN", "STATE", "MODE", "TIME", "STEPS", "WORKER", "NOW / RESULT"];
4522
- const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
4523
- const fmt = (r, header = false) => r.map((c, i) => {
4524
- const cell2 = i === r.length - 1 ? c : c.padEnd(widths[i]);
4525
- return header ? dim(cell2) : i === 1 ? stateColour(r[1], cell2) : cell2;
4526
- }).join(" ");
4527
- return [fmt(head, true), ...rows.map((r) => fmt(r))].join("\n");
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
+ };
4528
5024
  }
4529
- var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
4530
- async function waitMany(ids, opts) {
4531
- const deadline = Date.now() + opts.timeoutMs;
4532
- for (; ; ) {
4533
- const metas = ids.map((id) => freshMeta(id));
4534
- const done = metas.filter((m) => TERMINAL.includes(m.state)).length;
4535
- if (opts.any ? done > 0 : done === metas.length) return { metas, timedOut: false };
4536
- if (Date.now() >= deadline) return { metas, timedOut: true };
4537
- await sleep2(1500);
4538
- }
4539
- }
4540
- async function watch(select, opts) {
4541
- const seen = /* @__PURE__ */ new Map();
4542
- const line = (s) => opts.write(`${s}
4543
- `);
4544
- const emit = (e, m) => {
4545
- if (!opts.brief) return opts.write(`${JSON.stringify(e)}
4546
- `);
4547
- if (e.event === "started" && m) line(startedCard(m));
4548
- else if (e.event === "fallback" && m) line(`\u{1F3C1} Pitroom \u21BB ${m.id} fell back from ${String(e.failed)}: ${String(e.reason)}`);
4549
- else if (m && TERMINAL.includes(m.state)) line(endedCard(m));
4550
- else if (e.event === "all-done") line(`\u{1F3C1} Pitroom: ${String(e.runs)} run${e.runs === 1 ? "" : "s"} finished (${String(e.ok)} ok${e.failed ? `, ${String(e.failed)} not ok` : ""})`);
4551
- };
4552
- const deadline = opts.timeoutMs > 0 ? Date.now() + opts.timeoutMs : Infinity;
4553
- for (; ; ) {
4554
- const metas = select().map((id) => freshMeta(id));
4555
- if (opts.json || opts.brief) {
4556
- for (const m of metas) {
4557
- const prev = seen.get(m.id);
4558
- const l = m.state === "running" ? live(m) : { steps: m.usage?.steps ?? 0, toolCalls: m.usage?.toolCalls ?? 0 };
4559
- const attempts = m.attempts?.length ?? 0;
4560
- const base2 = { run: m.id, mode: m.mode, task: oneLine5(m.task, 60) };
4561
- if (!prev) emit({ event: m.state === "queued" ? "queued" : "started", ...base2 }, m);
4562
- else if (prev.state === "queued" && m.state === "running") emit({ event: "started", ...base2 }, m);
4563
- if (attempts > (prev?.attempts ?? 0)) {
4564
- const a = m.attempts[attempts - 1];
4565
- emit({ event: "fallback", run: m.id, failed: a.target, reason: oneLine5(a.error, 120) }, m);
4566
- }
4567
- if (m.state === "running" && l.steps > (prev?.steps ?? 0)) {
4568
- emit({ event: "progress", run: m.id, steps: l.steps, tools: l.toolCalls, last: "last" in l && l.last ? oneLine5(String(l.last), 80) : void 0 });
4569
- }
4570
- if (TERMINAL.includes(m.state) && prev?.state !== m.state) {
4571
- emit({
4572
- event: m.state,
4573
- run: m.id,
4574
- time: duration(m),
4575
- worker: describeTarget(m.ran ?? m.worker),
4576
- summary: headline(m) || void 0,
4577
- error: m.error ? oneLine5(m.error, 160) : void 0,
4578
- refs: m.refs ? `${m.refs.valid}/${m.refs.total}` : void 0,
4579
- changes: m.changes?.length || void 0
4580
- }, m);
4581
- }
4582
- seen.set(m.id, { state: m.state, steps: l.steps, attempts });
4583
- }
4584
- } else {
4585
- opts.write(`\x1B[H\x1B[2J${table(metas)}
4586
-
4587
- ${(/* @__PURE__ */ new Date()).toLocaleTimeString()} \xB7 Ctrl-C to stop watching (workers keep running)
4588
- `);
4589
- }
4590
- const allDone = metas.length > 0 && metas.every((m) => TERMINAL.includes(m.state));
4591
- if (allDone || metas.length === 0 && seen.size === 0) {
4592
- if (opts.json || opts.brief) {
4593
- const ok = metas.filter((m) => m.state === "done").length;
4594
- emit({ event: "all-done", runs: metas.length, ok, failed: metas.length - ok });
4595
- }
4596
- return { metas, timedOut: false };
5025
+ function dashState(opts = {}) {
5026
+ const limit = Math.min(Math.max(opts.limit ?? 40, 1), 200);
5027
+ const runs = [];
5028
+ const groups = /* @__PURE__ */ new Set();
5029
+ for (const id of listRunIds().slice(-SCAN).reverse()) {
5030
+ let m;
5031
+ try {
5032
+ m = freshMeta(id);
5033
+ } catch {
5034
+ continue;
4597
5035
  }
4598
- if (Date.now() >= deadline) return { metas, timedOut: true };
4599
- await sleep2(opts.intervalMs);
4600
- }
4601
- }
4602
-
4603
- // src/core/mcp-install.ts
4604
- import { spawnSync as spawnSync7 } from "node:child_process";
4605
- import fs26 from "node:fs";
4606
- import os10 from "node:os";
4607
- import path22 from "node:path";
4608
- var CLIENT_IDS = ["claude-code", "codex", "gemini", "cursor", "claude-desktop"];
4609
- var NAME = "pitroom";
4610
- function mcpCommand() {
4611
- const launcher = launcherPath();
4612
- if (isPitroomLauncher(launcher)) return { command: launcher, args: ["mcp"] };
4613
- for (const dir of (process.env.PATH ?? "").split(path22.delimiter).filter(Boolean)) {
4614
- const p = path22.join(dir, process.platform === "win32" ? "pitroom.cmd" : "pitroom");
4615
- if (fs26.existsSync(p)) return { command: path22.resolve(p), args: ["mcp"] };
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));
4616
5039
  }
4617
- return { command: process.execPath, args: [process.argv[1] ?? "pitroom", "mcp"] };
4618
- }
4619
- var same = (a, b) => {
4620
- const o = b;
4621
- return !!o && o.command === a.command && Array.isArray(o.args) && o.args.join("\0") === a.args.join("\0");
4622
- };
4623
- function run(backendId, args) {
4624
- const { command, prefix } = resolveCommand(getBackend(backendId).binary());
4625
- const r = spawnSync7(command, [...prefix, ...args], { encoding: "utf8", timeout: 6e4, stdio: ["ignore", "pipe", "pipe"] });
4626
- return { ok: r.status === 0 && !r.error, out: `${r.stdout ?? ""}${r.stderr ?? ""}`.trim() };
5040
+ return {
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
5046
+ };
4627
5047
  }
4628
- var runnable = (backendId) => run(backendId, ["--version"]).ok;
4629
- var readJson = (file2) => {
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;
4630
5053
  try {
4631
- return JSON.parse(fs26.readFileSync(file2, "utf8"));
5054
+ m = freshMeta(id);
4632
5055
  } catch {
4633
5056
  return void 0;
4634
5057
  }
4635
- };
4636
- var jsonState = (file2, c) => {
4637
- const entry = readJson(file2)?.mcpServers?.[NAME];
4638
- return entry === void 0 ? "absent" : same(c, entry) ? "same" : "different";
4639
- };
4640
- var home2 = () => os10.homedir();
4641
- var geminiDir = () => path22.join(process.env.GEMINI_CLI_HOME ?? home2(), ".gemini");
4642
- function cliClient(id, backend, name, where, o) {
4643
- return {
4644
- id,
4645
- name,
4646
- where,
4647
- found: () => runnable(backend),
4648
- state: o.state,
4649
- add: (c) => {
4650
- const r = run(backend, o.add(c));
4651
- return { ok: r.ok, message: r.ok ? where : r.out.split("\n")[0]?.trim() || `${name}'s command failed` };
4652
- },
4653
- remove: () => {
4654
- const r = run(backend, o.remove());
4655
- return { ok: r.ok, message: r.ok ? where : r.out.split("\n")[0]?.trim() || `${name}'s command failed` };
4656
- }
4657
- };
4658
- }
4659
- function jsonClient(id, name, file2, dir) {
4660
- const rel = () => file2().replace(home2(), "~");
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;
4661
5078
  return {
4662
5079
  id,
4663
- name,
4664
- found: () => fs26.existsSync(dir()),
4665
- state: (c) => jsonState(file2(), c),
4666
- add: (c) => {
4667
- let config = {};
4668
- if (fs26.existsSync(file2())) {
4669
- config = readJson(file2());
4670
- if (!config || typeof config !== "object" || Array.isArray(config)) return { ok: false, message: `${rel()} is not valid JSON: left alone` };
4671
- const backup = `${file2()}.bak-pitroom`;
4672
- if (!fs26.existsSync(backup)) fs26.copyFileSync(file2(), backup);
4673
- }
4674
- config.mcpServers = { ...config.mcpServers ?? {}, [NAME]: { command: c.command, args: c.args } };
4675
- fs26.mkdirSync(path22.dirname(file2()), { recursive: true });
4676
- const tmp = `${file2()}.${process.pid}.tmp`;
4677
- fs26.writeFileSync(tmp, `${JSON.stringify(config, null, 2)}
4678
- `);
4679
- fs26.renameSync(tmp, file2());
4680
- return { ok: true, message: rel() };
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
4681
5104
  },
4682
- remove: () => {
4683
- const config = readJson(file2());
4684
- if (!config?.mcpServers?.[NAME]) return { ok: true, message: rel() };
4685
- delete config.mcpServers[NAME];
4686
- fs26.writeFileSync(file2(), `${JSON.stringify(config, null, 2)}
4687
- `);
4688
- return { ok: true, message: rel() };
4689
- }
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)
4690
5112
  };
4691
5113
  }
4692
- function desktopFile() {
4693
- if (process.platform === "darwin") return path22.join(home2(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
4694
- if (process.platform === "win32") return path22.join(process.env.APPDATA ?? path22.join(home2(), "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
4695
- return path22.join(process.env.XDG_CONFIG_HOME ?? path22.join(home2(), ".config"), "Claude", "claude_desktop_config.json");
4696
- }
4697
- function codexState(c) {
4698
- let text;
4699
- try {
4700
- text = fs26.readFileSync(path22.join(process.env.CODEX_HOME ?? path22.join(home2(), ".codex"), "config.toml"), "utf8");
4701
- } catch {
4702
- return "absent";
4703
- }
4704
- const at = text.search(/^\[mcp_servers\.pitroom\]\s*$/m);
4705
- if (at < 0) return "absent";
4706
- const table2 = text.slice(at).split(/\n(?=\[)/)[0] ?? "";
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) {
4707
5125
  try {
4708
- const command = /^command\s*=\s*("(?:[^"\\]|\\.)*")/m.exec(table2)?.[1];
4709
- const args = /^args\s*=\s*(\[[^\n]*\])/m.exec(table2)?.[1];
4710
- return command && args && same(c, { command: JSON.parse(command), args: JSON.parse(args) }) ? "same" : "different";
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;
4711
5133
  } catch {
4712
- return "different";
5134
+ return void 0;
4713
5135
  }
4714
5136
  }
4715
- function clients() {
4716
- return [
4717
- cliClient("claude-code", "claude", "Claude Code", "user settings (~/.claude.json)", {
4718
- state: (c) => jsonState(path22.join(home2(), ".claude.json"), c),
4719
- add: (c) => ["mcp", "add", "--scope", "user", NAME, "--", c.command, ...c.args],
4720
- remove: () => ["mcp", "remove", NAME, "--scope", "user"]
4721
- }),
4722
- cliClient("codex", "codex", "Codex", "~/.codex/config.toml", {
4723
- state: (c) => codexState(c),
4724
- add: (c) => ["mcp", "add", NAME, "--", c.command, ...c.args],
4725
- remove: () => ["mcp", "remove", NAME]
4726
- }),
4727
- cliClient("gemini", "gemini", "Gemini CLI", "user settings (~/.gemini/settings.json)", {
4728
- state: (c) => jsonState(path22.join(geminiDir(), "settings.json"), c),
4729
- add: (c) => ["mcp", "add", "--scope", "user", NAME, c.command, ...c.args],
4730
- remove: () => ["mcp", "remove", "--scope", "user", NAME]
4731
- }),
4732
- jsonClient("cursor", "Cursor", () => path22.join(home2(), ".cursor", "mcp.json"), () => path22.join(home2(), ".cursor")),
4733
- jsonClient("claude-desktop", "Claude Desktop", desktopFile, () => path22.dirname(desktopFile()))
4734
- ];
4735
- }
4736
- function attempt2(f) {
4737
- try {
4738
- return f();
4739
- } catch (e) {
4740
- return { ok: false, message: e.message.split("\n")[0] ?? "failed" };
4741
- }
4742
- }
4743
- function installMcp(opts = {}) {
4744
- const c = mcpCommand();
4745
- const out = [];
4746
- for (const client of clients()) {
4747
- if (opts.only && !opts.only.includes(client.id)) continue;
4748
- const r = (state, message) => out.push({ id: client.id, name: client.name, state, message });
4749
- if (!client.found()) {
4750
- r("not-found", client.id === "cursor" || client.id === "claude-desktop" ? "not found on this machine" : "not installed (its command does not run)");
4751
- continue;
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);
4752
5155
  }
4753
- const now = client.state(c);
4754
- if (now === "same" && !opts.force) {
4755
- r("already", "already registered");
4756
- continue;
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 }));
4757
5160
  }
4758
- if (opts.dryRun) {
4759
- r("would-add", `would register ${[c.command, ...c.args].join(" ")}`);
4760
- continue;
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
+ }));
4761
5174
  }
4762
- if (now !== "absent") attempt2(() => client.remove());
4763
- const done = attempt2(() => client.add(c));
4764
- r(done.ok ? now === "absent" ? "added" : "updated" : "failed", done.ok ? `${now === "absent" ? "registered" : "updated"} in ${done.message}` : done.message);
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" });
5185
+ };
5186
+ }
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())));
5201
+ };
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
+ });
5209
+ }
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;
4765
5217
  }
4766
- return out;
4767
5218
  }
4768
- function uninstallMcp() {
4769
- const c = mcpCommand();
4770
- const out = [];
4771
- for (const client of clients()) {
4772
- let has2 = false;
4773
- try {
4774
- has2 = client.state(c) !== "absent";
4775
- } catch {
4776
- has2 = false;
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 {
5224
+ }
5225
+ fs25.rmSync(registryFile(), { force: true });
5226
+ return void 0;
5227
+ }
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
+ }
5234
+ }
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;
5264
+ }
4777
5265
  }
4778
- if (!has2) continue;
4779
- const done = client.found() ? attempt2(() => client.remove()) : { ok: false, message: `its command does not run: remove the pitroom entry from its config by hand` };
4780
- out.push({ id: client.id, name: client.name, state: done.ok ? "removed" : "failed", message: done.ok ? `removed from ${done.message}` : done.message });
5266
+ throw new UserError("pitroom dash did not start (is the port taken? try --port 0)");
4781
5267
  }
4782
- return out;
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 });
5276
+ }
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;
4783
5291
  }
4784
- function mcpStatus() {
4785
- const c = mcpCommand();
4786
- return clients().map((client) => {
5292
+
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
+ }
5307
+ };
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");
4787
5319
  try {
4788
- return { id: client.id, name: client.name, state: client.state(c) };
4789
- } catch {
4790
- return { id: client.id, name: client.name, state: "absent" };
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);
4791
5326
  }
4792
- });
5327
+ }
5328
+ try {
5329
+ fs26.chmodSync(file2, 384);
5330
+ } catch {
5331
+ }
5332
+ return { token, from: file2 };
4793
5333
  }
4794
- var parseClients = (value) => {
4795
- if (!value) return void 0;
4796
- const wanted = value.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
4797
- const bad = wanted.filter((w) => !CLIENT_IDS.includes(w));
4798
- if (bad.length) throw new Error(`unknown client ${bad.join(", ")} (one of: ${CLIENT_IDS.join(", ")})`);
4799
- return wanted;
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);
4800
5339
  };
4801
-
4802
- // src/core/plan-status.ts
4803
- import crypto6 from "node:crypto";
4804
- import fs27 from "node:fs";
4805
- import path23 from "node:path";
4806
- function notesFile(plan) {
4807
- const id = crypto6.createHash("sha1").update(plan.file).digest("hex").slice(0, 12);
4808
- return path23.join(home(), "plans", id, "notes.md");
4809
- }
4810
- function readNotes(plan) {
4811
- const f = notesFile(plan);
4812
- if (!fs27.existsSync(f)) return [];
4813
- return fs27.readFileSync(f, "utf8").split("\n").slice(1).filter(Boolean).map((l) => {
4814
- const m = /^(\d{4}-\d\d-\d\d \d\d:\d\d) (.*)$/.exec(l);
4815
- return m ? { at: m[1], text: m[2] } : { at: "", text: l };
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);
4816
5357
  });
4817
5358
  }
4818
- function addNote(planFile, text) {
4819
- const plan = loadPlan(planFile);
4820
- const f = notesFile(plan);
4821
- fs27.mkdirSync(path23.dirname(f), { recursive: true });
4822
- if (!fs27.existsSync(f)) fs27.writeFileSync(f, `# plan: ${plan.file}
4823
- `);
4824
- const at = (/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace("T", " ");
4825
- fs27.appendFileSync(f, `${at} ${text.replace(/\s+/g, " ").trim()}
4826
- `);
4827
- return f;
4828
- }
4829
- function planStatus(planFile) {
4830
- const plan = loadPlan(planFile);
4831
- const runs = [];
4832
- for (const id of listRunIds()) {
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;
4833
5391
  try {
4834
- const m = freshMeta(id);
4835
- if (m.plan?.file === plan.file) runs.push(m);
5392
+ parsed = JSON.parse(body);
4836
5393
  } catch {
5394
+ return reply(res, 400, parseError());
4837
5395
  }
4838
- }
4839
- const notes = readNotes(plan);
4840
- const tasks = plan.tasks.map((t) => {
4841
- const impl = runs.filter((m) => m.plan.step === t.step && !m.reviewOf);
4842
- const rev = runs.filter((m) => m.plan.step === t.step && m.reviewOf).at(-1);
4843
- const last = impl.at(-1);
4844
- return {
4845
- step: t.step,
4846
- title: t.title,
4847
- runs: impl.length,
4848
- last: last && { id: last.id, state: last.state, status: last.taskStatus },
4849
- review: rev && { id: rev.id, state: rev.state, kind: rev.reviewKind ?? "task", verdict: rev.verdict },
4850
- applied: impl.some((m) => m.applied || m.mode === "write" && m.state === "done" && !m.reverted && !!m.changes?.length),
4851
- note: [...notes].reverse().find((n) => n.text.startsWith(`Task ${t.step}:`))?.text
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
+ `);
4852
5428
  };
4853
- });
4854
- return { plan, tasks, rulings: notes.filter((n) => /\bRuling:/.test(n.text)), notesFile: notesFile(plan) };
5429
+ const out = await handlePayload(kept.session, parsed, event);
5430
+ if (out !== void 0) event(out);
5431
+ kept.lastSeen = Date.now();
5432
+ res.end();
5433
+ };
4855
5434
  }
4856
- var cell = (s, max) => {
4857
- const t = s.replace(/\s+/g, " ").trim();
4858
- return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
4859
- };
4860
- function formatPlanStatus(s) {
4861
- const head = ["TASK", "STATE", "STATUS", "REVIEW", "ROUNDS", "APPLIED", "TITLE", "NOTE"];
4862
- const rows = s.tasks.map((t) => {
4863
- const v = t.review?.verdict;
4864
- const review = v ? `${v.spec}/${v.quality} (c${v.critical} i${v.important} m${v.minor})` : t.review?.state ?? "-";
4865
- return [
4866
- String(t.step),
4867
- t.last?.state ?? "-",
4868
- t.last?.status ?? "-",
4869
- review,
4870
- t.runs ? String(t.runs - 1) : "-",
4871
- t.runs ? t.applied ? "yes" : "no" : "-",
4872
- cell(t.title, 32),
4873
- cell(t.note ?? "", 60)
4874
- ];
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
+ });
4875
5445
  });
4876
- const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
4877
- const fmt = (r) => r.map((c, i) => i === r.length - 1 ? c : c.padEnd(widths[i])).join(" ").trimEnd();
4878
- const rulings = s.rulings.length ? s.rulings.map((n) => ` ${n.at} ${n.text}`).join("\n") : " none";
4879
- return [
4880
- `plan ${s.plan.file} \xB7 ${s.plan.title || planName(s.plan.file)} \xB7 ${s.tasks.length} tasks`,
4881
- "",
4882
- fmt(head),
4883
- ...rows.map(fmt),
4884
- "",
4885
- `Rulings:
4886
- ${rulings}`,
4887
- `notes: ${s.notesFile}`
4888
- ].join("\n");
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;
4889
5471
  }
4890
5472
 
4891
- // src/core/review.ts
4892
- import crypto7 from "node:crypto";
4893
- import fs28 from "node:fs";
4894
- import path24 from "node:path";
4895
- var TEMPLATE2 = { task: "task-reviewer", fix: "re-review", range: "code-reviewer" };
4896
- var section = (title, body) => `## ${title}
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";
4897
5478
 
4898
- ${body.trim() || "(empty)"}
4899
- `;
4900
- function ancestors(m) {
4901
- const out = [];
4902
- for (let id = m.parent; id; ) {
4903
- const a = readMeta(id);
4904
- out.push(a);
4905
- id = a.parent;
4906
- }
4907
- return out;
4908
- }
4909
- function briefOf(m) {
4910
- const root = [m, ...ancestors(m)].at(-1);
4911
- const f = runFile(root.id, "brief.md");
4912
- return fs28.existsSync(f) ? fs28.readFileSync(f, "utf8") : root.task;
4913
- }
4914
- function latestReview(ids) {
4915
- for (const id of listRunIds().reverse()) {
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 = [];
4916
5499
  try {
4917
- const m = readMeta(id);
4918
- if (m.reviewOf && ids.includes(m.reviewOf) && m.state === "done") return m;
5500
+ models = b.catalog ? b.catalog().models.map((m) => m.id) : b.listModels?.() ?? [];
4919
5501
  } catch {
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)");
4920
5524
  }
4921
5525
  }
4922
- return void 0;
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 };
4923
5531
  }
4924
- function diffOf(m, from = m.baseTree) {
4925
- const where = m.mode === "isolate" ? m.worktree : m.repoRoot;
4926
- if (where && fs28.existsSync(where) && from && m.afterTree) return reviewDiff(where, from, m.afterTree);
4927
- if (from !== m.baseTree) {
4928
- throw new UserError(`the isolated copy of ${m.id} is gone, so its fix round cannot be shown on its own; review a run that is not applied yet`, 3);
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" : ""}` : ""}`);
4929
5548
  }
4930
- return readRunFile(m.id, "changes.patch") ?? archivedRun(m.id)?.patch ?? "";
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");
4931
5555
  }
4932
- function runReview(id) {
4933
- const m = freshMeta(id);
4934
- if (isActive(m.state)) throw new UserError(`run ${m.id} is still ${m.state}; pitroom wait ${m.id} first`, 3);
4935
- if (m.reviewOf) throw new UserError(`run ${m.id} is itself a review`);
4936
- if (m.mode === "read") throw new UserError(`run ${m.id} was read-only; there is no change to review`);
4937
- if (!m.changes?.length) throw new UserError(`run ${m.id} made no changes; nothing to review`);
4938
- const chain = ancestors(m);
4939
- const previous = latestReview(chain.map((a) => a.id));
4940
- const dir = m.mode === "isolate" && m.worktree && fs28.existsSync(m.worktree) ? m.cwd : m.dir;
4941
- const job = { of: m.id, dir, implementer: m.ran ?? m.worker, group: m.group, plan: m.plan };
4942
- const title = m.plan ? `Task ${m.plan.step}: ${m.plan.title}` : `run ${m.id}`;
4943
- if (!previous) {
4944
- return {
4945
- ...job,
4946
- kind: "task",
4947
- package: [`# Review package \xB7 ${title}
4948
- `, section("BRIEF", briefOf(m)), section("REPORT", readSummary(m)), section("DIFF", diffOf(m))].join("\n")
4949
- };
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"] };
4950
5570
  }
4951
- const reviewed = chain.find((a) => a.id === previous.reviewOf);
4952
- const from = m.mode === "isolate" ? reviewed.afterTree : m.baseTree;
4953
- return {
4954
- ...job,
4955
- kind: "fix",
4956
- package: [
4957
- `# Re-review package \xB7 ${title} \xB7 fix round after review ${previous.id}
4958
- `,
4959
- section("BRIEF", briefOf(m)),
4960
- section("PREVIOUS FINDINGS", readSummary(previous)),
4961
- section("FIX REPORT", readSummary(m)),
4962
- section("FIX DIFF", diffOf(m, from))
4963
- ].join("\n")
4964
- };
5571
+ return { command: process.execPath, args: [process.argv[1] ?? "pitroom", "mcp"] };
4965
5572
  }
4966
- function rangeReview(range, dir, planFile) {
4967
- const root = repoRoot(dir);
4968
- if (!root) throw new UserError("--range needs a git repository");
4969
- const i = range.indexOf("..");
4970
- const a = i > 0 ? range.slice(0, i) : "";
4971
- const b = i > 0 ? range.slice(i + 2) || "HEAD" : "";
4972
- if (!a || b.startsWith(".")) throw new UserError(`--range takes A..B (e.g. main..HEAD), not "${range}"`);
4973
- for (const ref of [a, b]) if (!commitOf(root, ref)) throw new UserError(`not a commit: ${ref}`);
4974
- const plan = planFile ? loadPlan(planFile) : void 0;
4975
- const notes = plan ? readNotes(plan) : [];
4976
- const requirements = plan ? [
4977
- `Plan: ${plan.file}`,
4978
- "### Global Constraints",
4979
- plan.constraints || "(none stated in the plan)",
4980
- "### Tasks",
4981
- plan.tasks.map((t) => `- Task ${t.step}: ${t.title}`).join("\n")
4982
- ].join("\n\n") : "(none given; judge the change on its own terms)";
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) => {
5584
+ try {
5585
+ return JSON.parse(fs28.readFileSync(file2, "utf8"));
5586
+ } catch {
5587
+ return void 0;
5588
+ }
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) {
4983
5597
  return {
4984
- kind: "range",
4985
- of: `${a}..${b}`,
4986
- dir: root,
4987
- group: plan ? planName(plan.file) : void 0,
4988
- package: [
4989
- `# Review package \xB7 ${a}..${b}
4990
- `,
4991
- section("WHAT WAS IMPLEMENTED", plan ? `${plan.title}
4992
-
4993
- ${plan.header}` : "The commits below."),
4994
- section("REQUIREMENTS", requirements),
4995
- ...notes.length ? [section("NOTES FROM EXECUTION", notes.map((n) => `- ${n.text}`).join("\n"))] : [],
4996
- rangeDiff(root, a, b)
4997
- ].join("\n")
5598
+ id,
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` };
5606
+ },
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
+ }
4998
5611
  };
4999
5612
  }
5000
- function pickReviewer(job) {
5001
- const eff = effective();
5002
- const tiers = eff.tiers.value;
5003
- if (job.kind === "range") return tiers.capable;
5004
- if (tiers.review) return tiers.review;
5005
- const implementer = job.implementer;
5006
- if (!implementer) return void 0;
5007
- const def = parseTarget(eff.worker.value, DEFAULT_BACKEND).backend;
5008
- const candidates = [tiers.standard, tiers.capable, ...eff.fallback.value, eff.worker.value].filter((s) => !!s);
5009
- return candidates.find((c) => parseTarget(c, def).backend !== implementer.backend);
5010
- }
5011
- function writePackage(job) {
5012
- const g = gitDir(job.dir);
5013
- if (!g) throw new UserError(`not a git repository: ${job.dir}`);
5014
- const file2 = path24.join(g, "pitroom", `review-${crypto7.randomBytes(4).toString("hex")}.md`);
5015
- fs28.mkdirSync(path24.dirname(file2), { recursive: true });
5016
- fs28.writeFileSync(file2, job.package);
5017
- return file2;
5018
- }
5019
-
5020
- // src/core/models.ts
5021
- var base = (model) => (model ?? "").split("#")[0];
5022
- function usage() {
5023
- const eff = effective();
5024
- const out = /* @__PURE__ */ new Map();
5025
- const add = (spec, label) => {
5026
- let t;
5027
- try {
5028
- t = parseTarget(spec, "opencode");
5029
- } catch {
5030
- return;
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() };
5031
5643
  }
5032
- const model = base(t.model) || base(eff.models.value[t.backend]);
5033
- if (!model) return;
5034
- const key = `${t.backend}:${model}`;
5035
- out.set(key, [...out.get(key) ?? [], label]);
5036
5644
  };
5037
- add(eff.worker.value, "default worker");
5038
- for (const [name, spec] of Object.entries(eff.tiers.value)) add(spec, `tier ${name}`);
5039
- eff.fallback.value.forEach((spec) => add(spec, "fallback"));
5040
- for (const [backend, model] of Object.entries(eff.models.value)) add(`${backend}:${model}`, "models");
5041
- return out;
5042
5645
  }
5043
- function modelTable(opts = {}) {
5044
- const costs = effective().costs.value;
5045
- const used = usage();
5046
- const seen = /* @__PURE__ */ new Map();
5047
- for (const e of readLedger()) {
5048
- if (!e.backend || !e.model) continue;
5049
- const key = `${e.backend}:${base(e.model)}`;
5050
- const a = seen.get(key) ?? { runs: 0, tokens: 0, usd: 0 };
5051
- seen.set(key, { runs: a.runs + 1, tokens: a.tokens + e.tokens, usd: a.usd + e.workerCost });
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;
5653
+ try {
5654
+ text = fs28.readFileSync(path24.join(process.env.CODEX_HOME ?? path24.join(home2(), ".codex"), "config.toml"), "utf8");
5655
+ } catch {
5656
+ return "absent";
5052
5657
  }
5053
- const rows = [];
5054
- const sources = [];
5055
- let hiddenOpenCode = 0;
5056
- const known = /* @__PURE__ */ new Set();
5057
- for (const b of allBackends()) {
5058
- if (opts.backend && b.id !== opts.backend) continue;
5059
- const catalog5 = b.catalog?.() ?? { models: [], source: "no model list for this worker" };
5060
- sources.push(`${b.id}: ${catalog5.source}`);
5061
- for (const m of catalog5.models) {
5062
- const key = `${b.id}:${m.id}`;
5063
- known.add(key);
5064
- const s = seen.get(key);
5065
- const row = {
5066
- worker: b.id,
5067
- model: m.id,
5068
- efforts: m.efforts?.length ? m.efforts : void 0,
5069
- defaultEffort: m.defaultEffort,
5070
- cost: costs[key],
5071
- runs: s?.runs ?? 0,
5072
- avgTokens: s ? Math.round(s.tokens / s.runs) : void 0,
5073
- reportedUsdPerRun: s && s.usd > 0 ? s.usd / s.runs : void 0,
5074
- inUse: used.get(key) ?? []
5075
- };
5076
- const relevant = row.inUse.length || row.runs || row.cost !== void 0;
5077
- if (b.id === "opencode" && !opts.all && !relevant) hiddenOpenCode++;
5078
- else rows.push(row);
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";
5667
+ }
5668
+ }
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;
5706
+ }
5707
+ const now = client.state(c);
5708
+ if (now === "same" && !opts.force) {
5709
+ r("already", "already registered");
5710
+ continue;
5711
+ }
5712
+ if (opts.dryRun) {
5713
+ r("would-add", `would register ${[c.command, ...c.args].join(" ")}`);
5714
+ continue;
5079
5715
  }
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);
5080
5719
  }
5081
- for (const [key, labels] of used) {
5082
- const [worker, ...rest] = key.split(":");
5083
- if (known.has(key) || opts.backend && worker !== opts.backend) continue;
5084
- const s = seen.get(key);
5085
- rows.push({ worker, model: rest.join(":"), cost: costs[key], runs: s?.runs ?? 0, avgTokens: s ? Math.round(s.tokens / s.runs) : void 0, inUse: labels });
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;
5731
+ }
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 });
5086
5735
  }
5087
- rows.sort(
5088
- (a, b) => a.worker.localeCompare(b.worker) || Number(b.inUse.length > 0) - Number(a.inUse.length > 0) || (a.cost ?? Infinity) - (b.cost ?? Infinity) || b.runs - a.runs || a.model.localeCompare(b.model)
5089
- );
5090
- return { rows, sources, hiddenOpenCode };
5736
+ return out;
5091
5737
  }
5092
- var compact2 = (n) => n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n);
5093
- function formatModels(t) {
5094
- const efforts = (r) => r.efforts ? r.efforts.map((e) => e === r.defaultEffort ? `${e}*` : e).join("/") : "-";
5095
- const cells = t.rows.map((r) => [
5096
- r.worker,
5097
- r.model,
5098
- efforts(r),
5099
- r.cost !== void 0 ? String(r.cost) : "?",
5100
- r.runs ? String(r.runs) : "-",
5101
- r.avgTokens !== void 0 ? compact2(r.avgTokens) : "-",
5102
- r.reportedUsdPerRun !== void 0 ? `$${r.reportedUsdPerRun.toFixed(3)}` : "-",
5103
- r.inUse.join(", ") || "-"
5104
- ]);
5105
- const head = ["worker", "model", "effort (* default)", "cost", "runs", "avg tokens", "$/run", "in use"];
5106
- const widths = head.map((h, i) => Math.max(h.length, ...cells.map((c) => c[i].length)));
5107
- const line = (c) => c.map((x, i) => x.padEnd(widths[i])).join(" ").trimEnd();
5108
- const out = [line(head), ...cells.map(line), ""];
5109
- out.push('cost: your relative cost from the config ("costs": {"codex:gpt-6-sol": 1, \u2026}); ? = not set. Pitroom cannot know vendor prices.');
5110
- out.push("runs, avg tokens, $/run: from your own runs (only Claude Code and OpenCode report dollars). Choose one with -W worker:model --effort LEVEL.");
5111
- if (t.hiddenOpenCode) out.push(`${t.hiddenOpenCode} more OpenCode models not shown (use --all).`);
5112
- out.push(...t.sources.map((s) => `source ${s}`));
5113
- return out.join("\n");
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" };
5745
+ }
5746
+ });
5114
5747
  }
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
+ };
5115
5755
 
5116
- // src/core/dash.ts
5117
- import { spawn as spawn4 } from "node:child_process";
5756
+ // src/core/plan-status.ts
5118
5757
  import crypto8 from "node:crypto";
5119
5758
  import fs29 from "node:fs";
5120
- import http from "node:http";
5121
5759
  import path25 from "node:path";
5122
- import { fileURLToPath as fileURLToPath3 } from "node:url";
5123
-
5124
- // src/core/file-diff.ts
5125
- var PREVIEW_LINES = 300;
5126
- function gitPath(value, prefix = true) {
5127
- let path28 = value;
5128
- if (value.startsWith('"') && value.endsWith('"')) {
5129
- const bytes = [];
5130
- const escapes = { t: " ", n: "\n", r: "\r", b: "\b", f: "\f", v: "\v" };
5131
- for (const match of value.slice(1, -1).matchAll(/\\([0-7]{1,3}|.)|([^\\]+)/g)) {
5132
- if (match[1] && /^[0-7]+$/.test(match[1])) bytes.push(parseInt(match[1], 8));
5133
- else bytes.push(...new TextEncoder().encode(match[2] ?? escapes[match[1]] ?? match[1]));
5134
- }
5135
- path28 = new TextDecoder().decode(new Uint8Array(bytes));
5136
- }
5137
- return prefix ? path28.replace(/^[ab]\//, "") : path28;
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");
5138
5763
  }
5139
- function headerPath(header) {
5140
- const quoted = header.match(/^diff --git ("(?:[^"\\]|\\.)*"|\S+) ("(?:[^"\\]|\\.)*"|\S+)$/);
5141
- if (quoted) return gitPath(quoted[2]);
5142
- return gitPath(header.slice(header.lastIndexOf(" b/") + 1));
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 };
5770
+ });
5143
5771
  }
5144
- function fileDiffs(patch, changes, sourceTruncated = false) {
5145
- const files = [];
5146
- let file2;
5147
- let oldLine = 0;
5148
- let newLine = 0;
5149
- let oldLeft = 0;
5150
- let newLeft = 0;
5151
- let inHunk = false;
5152
- let row = 0;
5153
- const finishHunk = () => {
5154
- if (file2 && (oldLeft > 0 || newLeft > 0)) file2.incomplete = true;
5155
- oldLeft = newLeft = 0;
5156
- inHunk = false;
5157
- };
5158
- const add = (line) => {
5159
- if (!file2) return;
5160
- if (file2.lines.length < PREVIEW_LINES) file2.lines.push({ ...line, id: String(row) });
5161
- else file2.omittedLines++;
5162
- row++;
5163
- };
5164
- for (const line of patch.split("\n")) {
5165
- if (line.startsWith("diff --git ")) {
5166
- finishHunk();
5167
- file2 = { path: headerPath(line), status: "M", lines: [], additions: 0, deletions: 0, omittedLines: 0, binary: false, incomplete: false };
5168
- files.push(file2);
5169
- row = 0;
5170
- continue;
5171
- }
5172
- if (!file2) continue;
5173
- const hunk = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
5174
- if (hunk) {
5175
- finishHunk();
5176
- oldLine = Number(hunk[1]);
5177
- newLine = Number(hunk[3]);
5178
- oldLeft = Number(hunk[2] ?? 1);
5179
- newLeft = Number(hunk[4] ?? 1);
5180
- inHunk = true;
5181
- add({ type: "hunk", content: line });
5182
- } else if (inHunk && line.startsWith("\")) {
5183
- add({ type: "meta", content: line });
5184
- } else if (inHunk && (oldLeft > 0 || newLeft > 0)) {
5185
- if (line.startsWith("+") && newLeft > 0) {
5186
- file2.additions++;
5187
- newLeft--;
5188
- add({ type: "added", newLine: newLine++, content: line.slice(1) });
5189
- } else if (line.startsWith("-") && oldLeft > 0) {
5190
- file2.deletions++;
5191
- oldLeft--;
5192
- add({ type: "removed", oldLine: oldLine++, content: line.slice(1) });
5193
- } else if (line.startsWith(" ") && oldLeft > 0 && newLeft > 0) {
5194
- oldLeft--;
5195
- newLeft--;
5196
- add({ type: "context", oldLine: oldLine++, newLine: newLine++, content: line.slice(1) });
5197
- } else {
5198
- file2.incomplete = true;
5199
- }
5200
- } else if (!inHunk) {
5201
- if (line.startsWith("+++ ") && line !== "+++ /dev/null") file2.path = gitPath(line.slice(4).replace(/\t$/, ""));
5202
- else if (line.startsWith("--- ") && line !== "--- /dev/null") file2.path = gitPath(line.slice(4).replace(/\t$/, ""));
5203
- else if (/^(GIT binary patch|Binary files .* differ)$/.test(line)) file2.binary = true;
5204
- else if (/^(new file mode|deleted file mode|old mode|new mode|rename from|rename to|similarity index) /.test(line)) {
5205
- if (line.startsWith("new file mode ")) file2.status = "A";
5206
- if (line.startsWith("deleted file mode ")) file2.status = "D";
5207
- if (line.startsWith("rename to ")) {
5208
- file2.path = gitPath(line.slice(10), false);
5209
- file2.status = "R";
5210
- }
5211
- add({ type: "meta", content: line });
5212
- }
5213
- }
5214
- }
5215
- finishHunk();
5216
- if (sourceTruncated && file2) file2.incomplete = true;
5217
- for (const f of files) {
5218
- if (f.incomplete || f.binary) f.additions = f.deletions = void 0;
5219
- }
5220
- const byPath = new Map(files.map((f) => [f.path, f]));
5221
- const result = changes.map((change) => {
5222
- const diff = byPath.get(change.path);
5223
- byPath.delete(change.path);
5224
- return diff ? { ...diff, status: change.status } : {
5225
- ...change,
5226
- lines: [],
5227
- omittedLines: 0,
5228
- binary: false,
5229
- incomplete: sourceTruncated,
5230
- unavailable: true
5231
- };
5232
- });
5233
- return [...result, ...byPath.values()];
5234
- }
5235
-
5236
- // src/core/dash-page.ts
5237
- var THEME_SCRIPT = "(function(){try{var t=localStorage.getItem('pitroom-theme');var d=t?t==='dark':matchMedia('(prefers-color-scheme: dark)').matches;document.documentElement.classList.toggle('dark',d)}catch(e){}})()";
5238
- var ICON3 = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23ff6a2b'/%3E%3Cpath d='M9 8h4v4H9zm8 0h4v4h-4zm-4 4h4v4h-4zm8 0h4v4h-4zM9 16h4v4H9zm8 0h4v4h-4zm-4 4h4v4h-4zm8 0h4v4h-4z' fill='%23fff'/%3E%3C/svg%3E";
5239
- var PAGE = `<!doctype html>
5240
- <html lang="en" class="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
5241
- <meta name="color-scheme" content="dark light"><title>Pitroom</title><link rel="icon" href="${ICON3}">
5242
- <script>${THEME_SCRIPT}</script><link rel="stylesheet" href="/assets/app.css"></head>
5243
- <body><div id="root"></div><script type="module" src="/assets/app.js"></script></body></html>`;
5244
- var MISSING = `<!doctype html><meta charset="utf-8"><title>Pitroom</title><body style="font:15px system-ui;padding:3rem;max-width:40rem;margin:auto"><h1>Pitroom dash</h1><p>The dashboard files are missing (dist/ui). Run <code>npm run build</code> in the Pitroom repository, or reinstall the package.</p></body>`;
5245
-
5246
- // src/core/dash.ts
5247
- var DEFAULT_PORT = 7878;
5248
- var WEEK_MS2 = 7 * 24 * 3600 * 1e3;
5249
- var SCAN = 400;
5250
- var RUN_ID3 = /^\d{8}-\d{6}-[0-9a-f]{4}$/;
5251
- var LOCAL_HOST = /^(127\.0\.0\.1|localhost|\[::1\]):\d+$/i;
5252
- var oneLine6 = (s, max) => {
5253
- const t = s.replace(/\s+/g, " ").trim();
5254
- return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
5255
- };
5256
- function findings(m) {
5257
- const v = m.verdict;
5258
- if (!v) return "";
5259
- const parts = [v.critical && `${v.critical} critical`, v.important && `${v.important} important`, v.minor && `${v.minor} minor`].filter(Boolean);
5260
- return parts.length ? parts.join(" \xB7 ") : "no findings";
5261
- }
5262
- function toRun(m) {
5263
- const l = m.state === "running" ? live(m) : void 0;
5264
- return {
5265
- id: m.id,
5266
- state: m.state,
5267
- kind: kind(m),
5268
- // the model that really ran, once the CLI has said which; until then just the worker, never a guess
5269
- worker: workerName(ranTarget(m)),
5270
- task: what(m),
5271
- group: m.group,
5272
- startedAt: m.startedAt,
5273
- time: elapsed(m),
5274
- steps: l ? l.steps : m.usage?.steps ?? 0,
5275
- tokens: m.usage?.total,
5276
- saved: m.savedUsd || void 0,
5277
- verdict: m.verdict ? `SPEC ${m.verdict.spec.toUpperCase()} \xB7 QUALITY ${m.verdict.quality.toUpperCase()}` : m.auditVerdict ? `AUDIT ${m.auditVerdict.toUpperCase()}` : void 0,
5278
- audit: auditBadge(m),
5279
- changes: m.changes?.length || void 0,
5280
- applied: m.applied || void 0,
5281
- note: l?.last ? oneLine6(String(l.last), 140) : isActive(m.state) ? "" : m.verdict ? findings(m) : m.auditOf && m.state === "done" ? m.auditDisputed?.length ? `${m.auditDisputed.length} disputed` : "nothing disputed" : headline(m, 200) || oneLine6(m.error ?? "", 200)
5282
- };
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;
5283
5782
  }
5284
- function dashState(opts = {}) {
5285
- const limit = Math.min(Math.max(opts.limit ?? 40, 1), 200);
5783
+ function planStatus(planFile) {
5784
+ const plan = loadPlan(planFile);
5286
5785
  const runs = [];
5287
- const groups = /* @__PURE__ */ new Set();
5288
- for (const id of listRunIds().slice(-SCAN).reverse()) {
5289
- let m;
5786
+ for (const id of listRunIds()) {
5290
5787
  try {
5291
- m = freshMeta(id);
5788
+ const m = freshMeta(id);
5789
+ if (m.plan?.file === plan.file) runs.push(m);
5292
5790
  } catch {
5293
- continue;
5294
5791
  }
5295
- if (m.group) groups.add(m.group);
5296
- if (opts.group && m.group !== opts.group) continue;
5297
- if (runs.length < limit) runs.push(toRun(m));
5298
5792
  }
5299
- return {
5300
- price: primaryPrice().name,
5301
- running: runs.filter((r) => r.state === "running" || r.state === "queued").length,
5302
- saved: totals(readLedger(Date.now() - WEEK_MS2)).saved,
5303
- groups: [...groups].sort(),
5304
- runs
5305
- };
5306
- }
5307
- var TASK_MAX = 6e3;
5308
- var PATCH_LINES = 300;
5309
- function runDetail(id) {
5310
- if (!RUN_ID3.test(id)) return void 0;
5311
- let m;
5312
- try {
5313
- m = freshMeta(id);
5314
- } catch {
5315
- return void 0;
5316
- }
5317
- const start = Date.parse(m.startedAt);
5318
- const kept = isActive(m.state) ? void 0 : archivedRun(id);
5319
- let steps = kept?.steps ?? [];
5320
- if (!steps.length) try {
5321
- const events = readRunFile(id, "events.jsonl");
5322
- steps = (events ? getBackend((m.ran ?? m.worker).backend).parse(events).timeline ?? [] : []).map((s) => ({
5323
- ...s,
5324
- t: s.at && Number.isFinite(start) ? Math.max(0, Math.round((s.at - start) / 1e3)) : void 0
5325
- }));
5326
- } catch {
5327
- }
5328
- const rel = (t) => [m.cwd, m.dir].filter(Boolean).reduce((x, base2) => x.split(`${base2}/`).join(""), t);
5329
- steps = steps.map((x) => ({ ...x, text: rel(x.text) }));
5330
- const answer = isActive(m.state) ? "" : readSummary(m).replace(/^SUMMARY:\s*SPEC[^\n]*\n+/i, "").replace(/^DETAILS:[ \t]*\n+/i, "").trim();
5331
- const lastSay = steps.at(-1);
5332
- if (lastSay?.kind === "say" && answer && (answer.includes(lastSay.text.slice(0, 80)) || lastSay.text.includes(answer.slice(0, 80)))) steps.pop();
5333
- const fullPatch = readRunFile(id, "changes.patch");
5334
- const patch = fullPatch ?? kept?.patch ?? "";
5335
- const patchLines = patch.split("\n");
5336
- const u = m.usage;
5337
- return {
5338
- id,
5339
- state: m.state,
5340
- card: toRun(m),
5341
- task: (m.auditOf ? `Audit of ${m.auditOf}` : m.reviewOf ? `Review of ${m.reviewOf.replace(/\b([0-9a-f]{9})[0-9a-f]{31}\b/g, "$1")}` : m.task).slice(0, TASK_MAX),
5342
- steps,
5343
- answer,
5344
- changes: m.changes ?? [],
5345
- fileDiffs: fileDiffs(patch, m.changes ?? [], fullPatch === void 0 && kept?.patchTruncated),
5346
- patch: patch ? `${patchLines.slice(0, PATCH_LINES).join("\n")}${patchLines.length > PATCH_LINES ? `
5347
- \u2026 ${patchLines.length - PATCH_LINES} more lines (pitroom show ${id} --patch)` : ""}` : void 0,
5348
- info: {
5349
- worker: (m.ran ?? m.worker).backend,
5350
- // the model has its own line
5351
- model: m.resolvedModel,
5352
- mode: m.mode,
5353
- started: m.startedAt,
5354
- time: elapsed(m),
5355
- steps: u?.steps,
5356
- toolCalls: u?.toolCalls,
5357
- tokens: u?.total,
5358
- returnedTokens: m.returnedTokens,
5359
- cost: u?.cost,
5360
- saved: m.savedUsd,
5361
- group: m.group,
5362
- directory: m.dir
5363
- },
5364
- attempts: m.attempts ?? [],
5365
- warnings: m.warnings ?? [],
5366
- refs: m.refs ? { valid: m.refs.valid, total: m.refs.total, invalid: m.refs.invalid.map((r) => `${r.ref} (${r.reason})`) } : void 0,
5367
- verify: m.verifyResult && m.verify ? { command: m.verify, ok: m.verifyResult.ok, tail: m.verifyResult.tail } : void 0,
5368
- error: m.error,
5369
- audit: m.auditOf ? m.state === "done" ? { state: m.state, verdict: m.auditVerdict, disputed: m.auditDisputed ?? [] } : void 0 : m.audit && { id: m.audit.id, state: m.audit.state, verdict: m.audit.verdict, disputed: m.audit.disputed ?? [] },
5370
- report: isActive(m.state) ? progress(m) : formatReport(m, void 0, 120)
5371
- };
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) };
5372
5809
  }
5373
- var HEADERS = {
5374
- "cache-control": "no-store",
5375
- "x-content-type-options": "nosniff",
5376
- "referrer-policy": "no-referrer",
5377
- // Own files only, plus the hash of the one inline theme script; styles may be inline (the components position popups with them).
5378
- "content-security-policy": `default-src 'none'; script-src 'self' 'sha256-${crypto8.createHash("sha256").update(THEME_SCRIPT).digest("base64")}'; style-src 'self' 'unsafe-inline'; img-src data:; connect-src 'self'; base-uri 'none'; form-action 'none'`
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;
5379
5813
  };
5380
- var ASSETS = { "/assets/app.js": "text/javascript; charset=utf-8", "/assets/app.css": "text/css; charset=utf-8" };
5381
- var assetsDir = () => fileURLToPath3(new URL("./ui/", import.meta.url));
5382
- var assetCache = /* @__PURE__ */ new Map();
5383
- function readAsset(route) {
5384
- try {
5385
- const file2 = path25.join(assetsDir(), path25.basename(route));
5386
- const { mtimeMs } = fs29.statSync(file2);
5387
- const hit = assetCache.get(route);
5388
- if (hit && hit.mtimeMs === mtimeMs) return hit.data;
5389
- const data = fs29.readFileSync(file2);
5390
- assetCache.set(route, { mtimeMs, data });
5391
- return data;
5392
- } catch {
5393
- return void 0;
5394
- }
5395
- }
5396
- function handler(touch2) {
5397
- const send2 = (res, code, type, body) => {
5398
- res.writeHead(code, { ...HEADERS, "content-type": type });
5399
- res.end(body);
5400
- };
5401
- const json = (res, code, value) => send2(res, code, "application/json; charset=utf-8", JSON.stringify(value));
5402
- return (req, res) => {
5403
- touch2();
5404
- if (!LOCAL_HOST.test(req.headers.host ?? "")) return json(res, 403, { error: "forbidden host" });
5405
- if (req.method !== "GET" && req.method !== "HEAD") return json(res, 405, { error: "read-only" });
5406
- const url = new URL(req.url ?? "/", "http://127.0.0.1");
5407
- if (url.pathname === "/") return send2(res, 200, "text/html; charset=utf-8", readAsset("/assets/app.js") ? PAGE : MISSING);
5408
- const type = ASSETS[url.pathname];
5409
- if (type) {
5410
- const data = readAsset(url.pathname);
5411
- if (!data) return json(res, 404, { error: "dashboard files missing" });
5412
- res.writeHead(200, { ...HEADERS, "content-type": type });
5413
- return void res.end(req.method === "HEAD" ? void 0 : data);
5414
- }
5415
- if (url.pathname === "/api/state") {
5416
- const group = url.searchParams.get("group") ?? void 0;
5417
- const limit = Number(url.searchParams.get("limit") ?? 40);
5418
- return json(res, 200, dashState({ group: group && group.length <= 100 ? group : void 0, limit: Number.isFinite(limit) ? limit : 40 }));
5419
- }
5420
- if (url.pathname === "/api/history") {
5421
- const q = url.searchParams;
5422
- const days = Number(q.get("days") ?? 0);
5423
- return json(res, 200, listHistory({
5424
- text: (q.get("q") ?? "").slice(0, 200),
5425
- model: q.get("model") || void 0,
5426
- backend: q.get("backend") || void 0,
5427
- state: q.get("state") || void 0,
5428
- group: q.get("group") || void 0,
5429
- sinceMs: days > 0 ? Date.now() - days * 864e5 : void 0,
5430
- beforeId: RUN_ID3.test(q.get("before") ?? "") ? q.get("before") : void 0,
5431
- limit: Number(q.get("limit") ?? 30) || 30
5432
- }));
5433
- }
5434
- if (url.pathname === "/api/stats") {
5435
- const days = Number(url.searchParams.get("days") ?? 30);
5436
- return json(res, 200, { ...historyStats(days > 0 ? Date.now() - days * 864e5 : void 0), price: primaryPrice().name });
5437
- }
5438
- const m = /^\/api\/run\/([^/]+)$/.exec(url.pathname);
5439
- if (m) {
5440
- const d = runDetail(m[1]);
5441
- return d ? json(res, 200, d) : json(res, 404, { error: "no such run" });
5442
- }
5443
- return json(res, 404, { error: "not found" });
5444
- };
5445
- }
5446
- function startDash(opts) {
5447
- let lastRequest = Date.now();
5448
- const server = http.createServer(handler(() => lastRequest = Date.now()));
5449
- return new Promise((resolve2, reject) => {
5450
- server.once("error", reject);
5451
- server.listen(opts.port, "127.0.0.1", () => {
5452
- const port = server.address().port;
5453
- let done;
5454
- const closed = new Promise((r) => done = r);
5455
- let timer;
5456
- const close = () => {
5457
- if (timer) clearInterval(timer);
5458
- server.closeAllConnections?.();
5459
- return new Promise((r) => server.close(() => (done(), r())));
5460
- };
5461
- if (opts.idleMs && opts.idleMs > 0) {
5462
- timer = setInterval(() => Date.now() - lastRequest > opts.idleMs && void close(), Math.min(6e4, opts.idleMs));
5463
- timer.unref();
5464
- }
5465
- resolve2({ port, url: `http://127.0.0.1:${port}/`, closed, close });
5466
- });
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
+ ];
5467
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");
5468
5843
  }
5469
- var registryFile = () => path25.join(home(), "dash.json");
5470
- async function ping(port) {
5471
- try {
5472
- const r = await fetch(`http://127.0.0.1:${port}/api/state?limit=1`, { signal: AbortSignal.timeout(1500) });
5473
- return r.ok;
5474
- } catch {
5475
- return false;
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;
5476
5860
  }
5861
+ return out;
5477
5862
  }
5478
- async function runningDash() {
5479
- try {
5480
- const r = JSON.parse(fs29.readFileSync(registryFile(), "utf8"));
5481
- if (isAlive(r.pid) && await ping(r.port)) return { ...r, url: `http://127.0.0.1:${r.port}/` };
5482
- } catch {
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;
5867
+ }
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
+ }
5483
5875
  }
5484
- fs29.rmSync(registryFile(), { force: true });
5485
5876
  return void 0;
5486
5877
  }
5487
- function openBrowser(url) {
5488
- const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
5489
- try {
5490
- spawn4(cmd, args, { stdio: "ignore", detached: true }).on("error", () => void 0).unref();
5491
- } catch {
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);
5492
5883
  }
5884
+ return readRunFile(m.id, "changes.patch") ?? archivedRun(m.id)?.patch ?? "";
5493
5885
  }
5494
- async function dashCommand(o) {
5495
- const existing = await runningDash();
5496
- if (o.stop) {
5497
- if (!existing) throw new UserError("pitroom dash is not running");
5498
- process.kill(existing.pid, "SIGTERM");
5499
- fs29.rmSync(registryFile(), { force: true });
5500
- o.log(`stopped pitroom dash (${existing.url})`);
5501
- return 0;
5502
- }
5503
- if (existing && !o.serve) {
5504
- o.log(existing.url);
5505
- if (o.open) openBrowser(existing.url);
5506
- return 0;
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
+ };
5507
5904
  }
5508
- if (o.detach) {
5509
- const child = spawn4(process.execPath, [process.argv[1], "dash", "--serve", ...o.port !== void 0 ? ["--port", String(o.port)] : [], "--idle", "1h"], {
5510
- detached: true,
5511
- stdio: "ignore",
5512
- env: process.env
5513
- });
5514
- child.on("error", () => void 0);
5515
- child.unref();
5516
- for (let i = 0; i < 60; i++) {
5517
- await new Promise((r2) => setTimeout(r2, 100));
5518
- const r = await runningDash();
5519
- if (r && r.pid === child.pid) {
5520
- o.log(r.url);
5521
- if (o.open) openBrowser(r.url);
5522
- return 0;
5523
- }
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;
5524
5985
  }
5525
- 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 });
5526
6006
  }
5527
- importRuns();
5528
- const explicit = o.port !== void 0;
5529
- let dash;
5530
- try {
5531
- dash = await startDash({ port: o.port ?? DEFAULT_PORT, idleMs: o.idleMs });
5532
- } catch (e) {
5533
- if (explicit || e.code !== "EADDRINUSE") throw new UserError(`pitroom dash: ${e.message}`);
5534
- dash = await startDash({ port: 0, idleMs: o.idleMs });
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
+ }
5535
6034
  }
5536
- fs29.mkdirSync(home(), { recursive: true });
5537
- fs29.writeFileSync(registryFile(), JSON.stringify({ pid: process.pid, port: dash.port }));
5538
- const stop = () => void dash.close();
5539
- process.once("SIGINT", stop);
5540
- process.once("SIGTERM", stop);
5541
- if (!o.serve) o.log(`${dash.url}
5542
- (read-only, this machine only; Ctrl-C stops it)`);
5543
- if (o.open) openBrowser(dash.url);
5544
- await dash.closed;
5545
- try {
5546
- if (JSON.parse(fs29.readFileSync(registryFile(), "utf8")).pid === process.pid) fs29.rmSync(registryFile(), { force: true });
5547
- } catch {
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 });
5548
6040
  }
5549
- 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");
5550
6068
  }
5551
6069
 
5552
6070
  // src/cli/commands.ts
@@ -5596,10 +6114,10 @@ async function cmdReview(p) {
5596
6114
  writeMeta(meta);
5597
6115
  }
5598
6116
  } catch (e) {
5599
- fs30.rmSync(packageFile, { force: true });
6117
+ fs31.rmSync(packageFile, { force: true });
5600
6118
  throw e;
5601
6119
  }
5602
- fs30.writeFileSync(runFile(meta.id, "package.md"), job.package);
6120
+ fs31.writeFileSync(runFile(meta.id, "package.md"), job.package);
5603
6121
  return launch(p, meta);
5604
6122
  }
5605
6123
  async function cmdAudit(p) {
@@ -5667,7 +6185,7 @@ function cmdPlan(p) {
5667
6185
  }
5668
6186
  function cmdCrew(p) {
5669
6187
  const file2 = flag(p, "task-file");
5670
- const tasks = (file2 ? fs30.readFileSync(file2, "utf8").split(/^\s*---\s*$/m) : p.positional).map((t) => t.trim()).filter(Boolean);
6188
+ const tasks = (file2 ? fs31.readFileSync(file2, "utf8").split(/^\s*---\s*$/m) : p.positional).map((t) => t.trim()).filter(Boolean);
5671
6189
  if (!tasks.length) throw new UserError('crew needs tasks: pitroom crew -g NAME "task 1" "task 2" (or --task-file with --- separators)');
5672
6190
  if (has(p, "write") && tasks.length > 1) {
5673
6191
  throw new UserError("parallel --write runs would edit the same tree; use --isolate (each worker gets its own isolated copy)");
@@ -5770,9 +6288,9 @@ function cmdHistory(p) {
5770
6288
  return 0;
5771
6289
  }
5772
6290
  importRuns();
5773
- const since = sinceMs(flag(p, "since"));
6291
+ const since2 = sinceMs(flag(p, "since"));
5774
6292
  if (sub === "stats") {
5775
- const s = historyStats(since);
6293
+ const s = historyStats(since2);
5776
6294
  if (has(p, "json")) return console.log(JSON.stringify(s, null, 2)), 0;
5777
6295
  const t = s.totals;
5778
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`);
@@ -5785,7 +6303,7 @@ ${[fmt2(head2), ...rows2.map(fmt2)].join("\n")}`);
5785
6303
  return 0;
5786
6304
  }
5787
6305
  const limit = flag(p, "limit") ? Number(flag(p, "limit")) : 20;
5788
- const { rows, total } = listHistory({ text: p.positional.join(" "), model: flag(p, "model"), state: flag(p, "state"), group: flag(p, "group"), sinceMs: since, limit });
6306
+ const { rows, total } = listHistory({ text: p.positional.join(" "), model: flag(p, "model"), state: flag(p, "state"), group: flag(p, "group"), sinceMs: since2, limit });
5789
6307
  if (has(p, "json")) return console.log(JSON.stringify({ total, rows }, null, 2)), 0;
5790
6308
  if (!rows.length) return console.log(total ? "nothing on this page" : "no matching runs"), 0;
5791
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]);
@@ -5870,7 +6388,7 @@ function sinceMs(s) {
5870
6388
  if (!m) throw new UserError("--since takes 7d, 30d, \u2026 or all");
5871
6389
  return Date.now() - Number(m[1]) * 864e5;
5872
6390
  }
5873
- var readStdin = () => process.stdin.isTTY ? "" : fs30.readFileSync(0, "utf8");
6391
+ var readStdin = () => process.stdin.isTTY ? "" : fs31.readFileSync(0, "utf8");
5874
6392
  function cmdStatusline(p) {
5875
6393
  const input = readStdin();
5876
6394
  const lines = [];
@@ -5910,8 +6428,8 @@ function cmdModels(p) {
5910
6428
  return 0;
5911
6429
  }
5912
6430
  function cmdHookStart() {
5913
- const script = path26.join(path26.dirname(path26.dirname(fileURLToPath4(import.meta.url))), "hooks", "session-start.mjs");
5914
- if (fs30.existsSync(script)) spawnSync8(process.execPath, [script], { stdio: ["ignore", "inherit", "ignore"], env: process.env });
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 });
5915
6433
  return 0;
5916
6434
  }
5917
6435
  function cmdInit(p) {
@@ -5922,11 +6440,11 @@ function cmdInit(p) {
5922
6440
  return 0;
5923
6441
  }
5924
6442
  function cmdSavings(p) {
5925
- const since = flag(p, "since") ?? "all";
5926
- const t = totals(readLedger(sinceMs(since)));
6443
+ const since2 = flag(p, "since") ?? "all";
6444
+ const t = totals(readLedger(sinceMs(since2)));
5927
6445
  if (has(p, "models")) {
5928
6446
  const by = /* @__PURE__ */ new Map();
5929
- for (const e of readLedger(sinceMs(since))) {
6447
+ for (const e of readLedger(sinceMs(since2))) {
5930
6448
  const key = `${e.backend ?? "?"} ${e.model ?? "(default model)"}`;
5931
6449
  const a = by.get(key) ?? { runs: 0, tokens: 0, returned: 0, cost: 0, saved: 0 };
5932
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 });
@@ -5938,7 +6456,7 @@ function cmdSavings(p) {
5938
6456
  }
5939
6457
  return 0;
5940
6458
  }
5941
- const period = since === "all" ? "all time" : `last ${since.replace("d", " days")}`;
6459
+ const period = since2 === "all" ? "all time" : `last ${since2.replace("d", " days")}`;
5942
6460
  if (has(p, "json")) {
5943
6461
  console.log(JSON.stringify({ period, ...t, price: primaryPrice() }, null, 2));
5944
6462
  return 0;
@@ -5951,8 +6469,8 @@ function cmdSavings(p) {
5951
6469
  est. saved ${usd(t.saved)} (vs ${primaryPrice().name} list prices)`);
5952
6470
  const out = flag(p, "card");
5953
6471
  if (out) {
5954
- fs30.writeFileSync(out, card(t, period));
5955
- console.log(`card written to ${path26.resolve(out)}`);
6472
+ fs31.writeFileSync(out, card(t, period));
6473
+ console.log(`card written to ${path27.resolve(out)}`);
5956
6474
  }
5957
6475
  if (has(p, "badge")) console.log(`![pitroom](${badgeUrl(t)})`);
5958
6476
  return 0;
@@ -5994,7 +6512,7 @@ function cmdConfig(p) {
5994
6512
  console.log(JSON.stringify({ path: configPath(), settings: eff, warnings }, null, 2));
5995
6513
  return 0;
5996
6514
  }
5997
- console.log(`config file: ${configPath()}${fs30.existsSync(configPath()) ? "" : " (not present)"}`);
6515
+ console.log(`config file: ${configPath()}${fs31.existsSync(configPath()) ? "" : " (not present)"}`);
5998
6516
  for (const [key, s] of Object.entries(eff)) {
5999
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);
6000
6518
  console.log(` ${key.padEnd(15)} ${v} (${s.source})`);
@@ -6018,7 +6536,7 @@ function cmdClean(p) {
6018
6536
  const m = readMeta(id);
6019
6537
  if (m.mode === "isolate" && !m.discarded && !m.applied) discardRun(m);
6020
6538
  recordRun(m);
6021
- fs30.rmSync(runDir(id), { recursive: true, force: true });
6539
+ fs31.rmSync(runDir(id), { recursive: true, force: true });
6022
6540
  }
6023
6541
  const snapshots = prune(Date.now(), 0);
6024
6542
  console.log(`removed ${old.length} run(s)${snapshots ? ` and ${snapshots} read snapshot${snapshots === 1 ? "" : "s"}` : ""}; the history and the savings ledger are kept`);
@@ -6029,9 +6547,9 @@ var cmdDiscard = (p) => (console.log(discardRun(freshMeta(resolveRun(p.positiona
6029
6547
 
6030
6548
  // src/core/doctor.ts
6031
6549
  import { spawnSync as spawnSync9 } from "node:child_process";
6032
- import fs31 from "node:fs";
6550
+ import fs32 from "node:fs";
6033
6551
  import os11 from "node:os";
6034
- import path27 from "node:path";
6552
+ import path28 from "node:path";
6035
6553
  var MARK = { ok: () => green("\u2714"), warn: () => yellow("!"), fail: () => red("\u2718") };
6036
6554
  var NEXT = [
6037
6555
  { when: /no default model|no fallback workers/, command: "pitroom init", why: "propose a starter config (fallback models from your catalogue)" },
@@ -6041,10 +6559,10 @@ var NEXT = [
6041
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" }
6042
6560
  ];
6043
6561
  function firstNodeOnPath() {
6044
- for (const dir of (process.env.PATH ?? "").split(path27.delimiter).filter(Boolean)) {
6045
- const file2 = path27.join(dir, process.platform === "win32" ? "node.exe" : "node");
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");
6046
6564
  try {
6047
- if (!fs31.statSync(file2).isFile()) continue;
6565
+ if (!fs32.statSync(file2).isFile()) continue;
6048
6566
  } catch {
6049
6567
  continue;
6050
6568
  }
@@ -6071,12 +6589,12 @@ function doctor5(probe) {
6071
6589
  const addAll = (list2) => list2.forEach((c) => add(c.level, c.message));
6072
6590
  add("ok", `pitroom ${VERSION2} \xB7 node ${process.versions.node} \xB7 state in ${home()}`);
6073
6591
  const onPath = firstNodeOnPath();
6074
- if (onPath && onPath.version && tooOld(onPath.version) && path27.resolve(onPath.path) !== path27.resolve(process.execPath)) {
6592
+ if (onPath && onPath.version && tooOld(onPath.version) && path28.resolve(onPath.path) !== path28.resolve(process.execPath)) {
6075
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)`);
6076
6594
  }
6077
6595
  add(gitAvailable() ? "ok" : "warn", gitAvailable() ? "git available" : "git not found: --write/--isolate tracking disabled");
6078
6596
  const cfg = loadConfig();
6079
- add(cfg.warnings.length ? "warn" : "ok", `config: ${configPath()}${fs31.existsSync(configPath()) ? "" : " (not present, defaults in use)"}`);
6597
+ add(cfg.warnings.length ? "warn" : "ok", `config: ${configPath()}${fs32.existsSync(configPath()) ? "" : " (not present, defaults in use)"}`);
6080
6598
  for (const w of cfg.warnings) add("warn", w);
6081
6599
  if (process.platform !== "win32") {
6082
6600
  const guarded = guardEnv(process.env).PATH?.startsWith(shimDir());
@@ -6229,19 +6747,19 @@ function skillChecks() {
6229
6747
  else if (!viaPlugin) checks.push({ level: "warn", message: `no Pitroom skills in ${base2}; run \`pitroom install\`` });
6230
6748
  }
6231
6749
  if (geminiExtensionInstalled()) {
6232
- const linked = installedSkills().some((i) => i.base.includes(`${path27.sep}.agents${path27.sep}`) && i.names.length);
6750
+ const linked = installedSkills().some((i) => i.base.includes(`${path28.sep}.agents${path28.sep}`) && i.names.length);
6233
6751
  checks.push(
6234
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)" }
6235
6753
  );
6236
6754
  }
6237
6755
  if (viaPlugin) {
6238
- const linked = installedSkills().some((i) => i.base.includes(`${path27.sep}.claude${path27.sep}`) && i.names.length);
6756
+ const linked = installedSkills().some((i) => i.base.includes(`${path28.sep}.claude${path28.sep}`) && i.names.length);
6239
6757
  checks.push(
6240
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)" }
6241
6759
  );
6242
6760
  }
6243
6761
  const launcher = launcherPath();
6244
- if (fs31.existsSync(launcher)) {
6762
+ if (fs32.existsSync(launcher)) {
6245
6763
  const r = spawnSync9(launcher, ["--version"], { encoding: "utf8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"] });
6246
6764
  checks.push(
6247
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)}` }
@@ -6253,12 +6771,12 @@ function skillChecks() {
6253
6771
  }
6254
6772
  function geminiExtensionInstalled() {
6255
6773
  const home3 = process.env.GEMINI_CLI_HOME ?? os11.homedir();
6256
- return fs31.existsSync(path27.join(home3, ".gemini", "extensions", "pitroom"));
6774
+ return fs32.existsSync(path28.join(home3, ".gemini", "extensions", "pitroom"));
6257
6775
  }
6258
6776
  function pluginInstalled() {
6259
6777
  try {
6260
- const f = path27.join(os11.homedir(), ".claude", "plugins", "installed_plugins.json");
6261
- const plugins = JSON.parse(fs31.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 ?? {};
6262
6780
  return Object.keys(plugins).some((k) => k.startsWith("pitroom@"));
6263
6781
  } catch {
6264
6782
  return false;
@@ -6275,24 +6793,24 @@ function superpowersActive() {
6275
6793
  for (const p of openCodePlugins()) {
6276
6794
  if (/superpowers/i.test(p)) found.push(`OpenCode plugin ${p}`);
6277
6795
  }
6278
- for (const base2 of [path27.join(os11.homedir(), ".agents", "skills"), path27.join(os11.homedir(), ".claude", "skills")]) {
6279
- const dir = path27.join(base2, "using-superpowers");
6280
- if (fs31.existsSync(path27.join(dir, "SKILL.md"))) found.push(dir);
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);
6281
6799
  }
6282
6800
  return found;
6283
6801
  }
6284
6802
  function claudePlugins() {
6285
6803
  try {
6286
- const f = path27.join(os11.homedir(), ".claude", "plugins", "installed_plugins.json");
6287
- return Object.keys(JSON.parse(fs31.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 ?? {});
6288
6806
  } catch {
6289
6807
  return [];
6290
6808
  }
6291
6809
  }
6292
6810
  function claudePluginEnabled(key) {
6293
6811
  try {
6294
- const f = path27.join(os11.homedir(), ".claude", "settings.json");
6295
- return JSON.parse(fs31.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;
6296
6814
  } catch {
6297
6815
  return true;
6298
6816
  }
@@ -6301,7 +6819,7 @@ function codexPlugins() {
6301
6819
  const plugins = /* @__PURE__ */ new Map();
6302
6820
  let text;
6303
6821
  try {
6304
- text = fs31.readFileSync(path27.join(process.env.CODEX_HOME ?? path27.join(os11.homedir(), ".codex"), "config.toml"), "utf8");
6822
+ text = fs32.readFileSync(path28.join(process.env.CODEX_HOME ?? path28.join(os11.homedir(), ".codex"), "config.toml"), "utf8");
6305
6823
  } catch {
6306
6824
  return plugins;
6307
6825
  }
@@ -6319,11 +6837,11 @@ function codexPlugins() {
6319
6837
  return plugins;
6320
6838
  }
6321
6839
  function openCodePlugins() {
6322
- const dir = path27.join(process.env.XDG_CONFIG_HOME ?? path27.join(os11.homedir(), ".config"), "opencode");
6840
+ const dir = path28.join(process.env.XDG_CONFIG_HOME ?? path28.join(os11.homedir(), ".config"), "opencode");
6323
6841
  const plugins = [];
6324
6842
  for (const name of ["opencode.json", "opencode.jsonc"]) {
6325
6843
  try {
6326
- const text = fs31.readFileSync(path27.join(dir, name), "utf8").replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, "");
6844
+ const text = fs32.readFileSync(path28.join(dir, name), "utf8").replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, "");
6327
6845
  const list2 = JSON.parse(text).plugin;
6328
6846
  if (Array.isArray(list2)) plugins.push(...list2.filter((p) => typeof p === "string"));
6329
6847
  } catch {
@@ -6331,7 +6849,7 @@ function openCodePlugins() {
6331
6849
  }
6332
6850
  for (const folder of ["plugin", "plugins"]) {
6333
6851
  try {
6334
- for (const f of fs31.readdirSync(path27.join(dir, folder))) plugins.push(path27.join(dir, folder, f));
6852
+ for (const f of fs32.readdirSync(path28.join(dir, folder))) plugins.push(path28.join(dir, folder, f));
6335
6853
  } catch {
6336
6854
  }
6337
6855
  }
@@ -6383,7 +6901,9 @@ Usage
6383
6901
  pitroom hook-start SessionStart hook: introduces Pitroom to the agent (Codex; Claude Code uses its plugin)
6384
6902
  pitroom cooldown [--clear] models that said "rate limited" and are skipped for a while (a quota used up);
6385
6903
  --clear tries them again
6386
- 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)
6387
6907
  pitroom doctor [--probe] check workers, models, permissions, skills
6388
6908
  pitroom config effective settings, where each comes from, config file path
6389
6909
  pitroom init [--model ID] [--fallback A,B] [--yes] [--force]
@@ -6439,7 +6959,18 @@ var COMMANDS = {
6439
6959
  review: cmdReview,
6440
6960
  audit: cmdAudit,
6441
6961
  cooldown: cmdCooldown,
6442
- 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
+ },
6443
6974
  plan: cmdPlan,
6444
6975
  status: cmdStatus,
6445
6976
  wait: cmdWait,