pitroom 0.14.1 → 0.15.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pitroom",
3
3
  "description": "A free pit crew for your expensive coding agent: the superpowers development workflow, run by cheap workers. Delegate research, fixes, tests, reviews and whole plans from Claude Code, Codex or any agent to OpenCode, Codex or Claude Code workers, with receipts that prove the savings.",
4
- "version": "0.14.1",
4
+ "version": "0.15.0",
5
5
  "author": {
6
6
  "name": "TahaAtas"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pitroom",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "A free pit crew for your expensive coding agent: delegate research, fixes, tests, reviews and parallel investigations to cheap worker agents running your own models, with verified answers, exact diffs and cost receipts.",
5
5
  "author": {
6
6
  "name": "TahaAtas"
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.0
4
+
5
+ ### `pitroom install --mcp`
6
+ - Registers `pitroom mcp` in the MCP clients found on the machine, so setting it up is one command: Claude Code, Codex and Gemini CLI are changed with their own `mcp add` command (user scope where they have one), Cursor and Claude Desktop have their JSON config merged (other servers and settings kept, a `.bak-pitroom` backup the first time, a file that is not valid JSON left alone). `--dry-run` only says what it would do, `--client cursor,claude-desktop` limits it, `--no-skills` skips linking the skills, and `pitroom uninstall` removes the registrations again (a client whose command is gone cannot be asked to, which is reported and exits 1).
7
+ - The command registered is the launcher by its full path (apps such as Claude Desktop start without your `PATH`), and only when that file really is a Pitroom launcher. Whether a client already has the server is read from its config file, never by starting the server; a client that runs a different command (a Codex entry included) is updated, and `--force` registers again.
8
+ - `pitroom doctor` has an MCP section: where `pitroom mcp` is registered, and a warning for a client that runs another command.
9
+ - Checked with the real `claude`, `codex` and `gemini` commands in a throwaway home: right entries written, a second run "already registered", `uninstall` removed exactly those. The change was reviewed by a free Pitroom worker before merging; its findings (a foreign file at the launcher path, unguarded writes, the Codex command not compared, uninstall failures, a few messages) are fixed.
10
+
3
11
  ## 0.14.1
4
12
 
5
13
  ### Docs
package/README.md CHANGED
@@ -273,7 +273,15 @@ npm i -g pitroom
273
273
 
274
274
  **Any agent that speaks MCP (Cursor, Claude Desktop, and the three above too)**
275
275
 
276
- Pitroom is also an [MCP](https://modelcontextprotocol.io) server on stdio: nine tools (`pitroom_run`, `pitroom_wait`, `pitroom_status`, `pitroom_show`, `pitroom_review`, `pitroom_audit`, `pitroom_apply`, `pitroom_discard`, `pitroom_stop`) instead of shell commands and skills. After `npm i -g pitroom`:
276
+ Pitroom is also an [MCP](https://modelcontextprotocol.io) server on stdio: nine tools (`pitroom_run`, `pitroom_wait`, `pitroom_status`, `pitroom_show`, `pitroom_review`, `pitroom_audit`, `pitroom_apply`, `pitroom_discard`, `pitroom_stop`) instead of shell commands and skills. After `npm i -g pitroom`, let Pitroom register itself in the clients it finds (Claude Code, Codex, Gemini CLI, Cursor, Claude Desktop):
277
+
278
+ ```bash
279
+ pitroom install --mcp # also links the skills; add --no-skills to skip that
280
+ pitroom install --mcp --dry-run # only say what it would do
281
+ pitroom install --mcp --client cursor,claude-desktop
282
+ ```
283
+
284
+ It registers the launcher by its full path (apps like Claude Desktop start without your `PATH`), changes Claude Code, Codex and Gemini CLI with their own `mcp add` command (user scope where it has one), merges the JSON of Cursor and Claude Desktop without touching their other servers (keeping a `.bak-pitroom` backup, and leaving a file that is not valid JSON alone), skips what is already registered, and `pitroom uninstall` removes it again. `pitroom doctor` shows where it is registered. Restart the client afterwards. Or do it by hand:
277
285
 
278
286
  ```bash
279
287
  claude mcp add pitroom -- pitroom mcp
@@ -495,7 +503,7 @@ pitroom ls [--running] [-g NAME] · pitroom clean [--days 14] [--yes]
495
503
  pitroom savings [--since 7d|30d|all] [--models] [--card file.svg] [--badge]
496
504
  pitroom models [worker] [--all] [--json] models, effort levels, your costs, your usage
497
505
  pitroom statusline [--then CMD] · pitroom hook-card
498
- pitroom doctor [--probe] · pitroom config · pitroom init [--model ID] [--fallback A,B] [--yes] [--force] · pitroom install [--copy] [--force] · pitroom uninstall
506
+ pitroom doctor [--probe] · pitroom config · pitroom init [--model ID] [--fallback A,B] [--yes] [--force] · pitroom install [--copy] [--force] [--mcp [--client A,B] [--no-skills] [--dry-run]] · pitroom uninstall
499
507
  ```
500
508
 
501
509
  Exit codes: `0` ok · `1` worker failed · `2` usage · `3` refused/setup · `4` timeout · `5` read-only violation · `6` verify failed · `75` still running.
package/dist/pitroom.mjs CHANGED
@@ -262,10 +262,10 @@ function classify(message) {
262
262
  if (/model[^.]*(not found|does not exist|invalid|not available)|invalid model|not_found_error/i.test(message)) return "model-unavailable";
263
263
  return "other";
264
264
  }
265
- function failure(run, stderr, exitCode) {
266
- if (exitCode === 0 && !run.error) return void 0;
265
+ function failure(run2, stderr, exitCode) {
266
+ if (exitCode === 0 && !run2.error) return void 0;
267
267
  const detail = stderr.trim().split("\n").filter(Boolean).pop();
268
- const message = run.error ?? detail ?? `claude exited with code ${exitCode}`;
268
+ const message = run2.error ?? detail ?? `claude exited with code ${exitCode}`;
269
269
  return { kind: classify(message), message };
270
270
  }
271
271
  function defaultModel() {
@@ -476,10 +476,10 @@ function classify2(message) {
476
476
  if (/unauthori[sz]ed|\b40[13]\b|not logged in|log ?in|api.?key|credits?\b|billing|subscription/i.test(message)) return "auth";
477
477
  return "other";
478
478
  }
479
- function failure2(run, stderr, exitCode) {
480
- if (exitCode === 0 && !run.error) return void 0;
479
+ function failure2(run2, stderr, exitCode) {
480
+ if (exitCode === 0 && !run2.error) return void 0;
481
481
  const detail = stderr.split("\n").find((l) => /^Error[: ]|ERROR/.test(l) && !/rmcp|models cache/.test(l));
482
- let message = run.error ?? (detail ? detail.trim() : `codex exited with code ${exitCode}`);
482
+ let message = run2.error ?? (detail ? detail.trim() : `codex exited with code ${exitCode}`);
483
483
  if (/not supported when using Codex/i.test(message)) message += " (an outdated Codex CLI says this too: npm i -g @openai/codex@latest)";
484
484
  return { kind: classify2(message), message };
485
485
  }
@@ -838,11 +838,11 @@ function classify3(message) {
838
838
  return "other";
839
839
  }
840
840
  var UNTRUSTED = "Gemini CLI does not trust this folder: open `gemini` in it once and trust it, or set PITROOM_GEMINI_TRUST=1 (the folder's own Gemini settings, hooks included, then load)";
841
- function failure3(run, stderr, exitCode) {
842
- if (exitCode === 0 && !run.error) return void 0;
841
+ function failure3(run2, stderr, exitCode) {
842
+ if (exitCode === 0 && !run2.error) return void 0;
843
843
  const lines = stderr.trim().split("\n").map((l) => l.trim()).filter(Boolean);
844
844
  const detail = lines.find((l) => /error|failed|exceeded|quota|exhausted|unsupported/i.test(l) && !/^at /.test(l)) ?? lines.at(-1);
845
- const raw = run.error ?? detail ?? `gemini exited with code ${exitCode}`;
845
+ const raw = run2.error ?? detail ?? `gemini exited with code ${exitCode}`;
846
846
  const message = /not running in a trusted directory/i.test(`${stderr} ${raw}`) ? UNTRUSTED : raw;
847
847
  return { kind: classify3(message), message };
848
848
  }
@@ -1283,10 +1283,10 @@ function classify4(message) {
1283
1283
  }
1284
1284
  return "other";
1285
1285
  }
1286
- function failure4(run, stderr, exitCode) {
1287
- if (exitCode === 0 && !run.error) return void 0;
1286
+ function failure4(run2, stderr, exitCode) {
1287
+ if (exitCode === 0 && !run2.error) return void 0;
1288
1288
  const detail = stderr.match(/error="([^"]+)"/g)?.pop()?.slice(7, -1);
1289
- const raw = run.error ?? `opencode exited with code ${exitCode}`;
1289
+ const raw = run2.error ?? `opencode exited with code ${exitCode}`;
1290
1290
  const message = detail && !raw.includes(detail) ? `${raw}: ${detail}` : raw;
1291
1291
  return { kind: classify4(message), message };
1292
1292
  }
@@ -1538,7 +1538,8 @@ var VALUE_FLAGS = {
1538
1538
  "--limit": "limit",
1539
1539
  "--plan": "plan",
1540
1540
  "--step": "step",
1541
- "--fallback": "fallback"
1541
+ "--fallback": "fallback",
1542
+ "--client": "client"
1542
1543
  };
1543
1544
  var BOOL_FLAGS = {
1544
1545
  "-r": "read",
@@ -1548,6 +1549,9 @@ var BOOL_FLAGS = {
1548
1549
  "-i": "isolate",
1549
1550
  "--isolate": "isolate",
1550
1551
  "--bg": "bg",
1552
+ "--mcp": "mcp",
1553
+ "--no-skills": "no-skills",
1554
+ "--dry-run": "dry-run",
1551
1555
  "--in-place": "in-place",
1552
1556
  "--audit": "audit",
1553
1557
  "--no-audit": "no-audit",
@@ -2499,9 +2503,9 @@ function skillNames(root = packageRoot()) {
2499
2503
  return fs16.readdirSync(dir).filter((d) => fs16.existsSync(path14.join(dir, d, "SKILL.md"))).sort();
2500
2504
  }
2501
2505
  function skillTargets() {
2502
- const home2 = os9.homedir();
2503
- const targets = [path14.join(home2, ".agents", "skills")];
2504
- if (fs16.existsSync(path14.join(home2, ".claude"))) targets.push(path14.join(home2, ".claude", "skills"));
2506
+ const home3 = os9.homedir();
2507
+ const targets = [path14.join(home3, ".agents", "skills")];
2508
+ if (fs16.existsSync(path14.join(home3, ".claude"))) targets.push(path14.join(home3, ".claude", "skills"));
2505
2509
  return targets;
2506
2510
  }
2507
2511
  var launcherPath = () => path14.join(os9.homedir(), ".local", "bin", "pitroom");
@@ -2528,6 +2532,13 @@ function isOurLauncher(file2, root) {
2528
2532
  return false;
2529
2533
  }
2530
2534
  }
2535
+ function isPitroomLauncher(file2) {
2536
+ try {
2537
+ return fs16.readFileSync(file2, "utf8").includes(LAUNCHER_MARK);
2538
+ } catch {
2539
+ return false;
2540
+ }
2541
+ }
2531
2542
  function placeLauncher(bundle, root, force) {
2532
2543
  const dest = launcherPath();
2533
2544
  fs16.mkdirSync(path14.dirname(dest), { recursive: true });
@@ -2563,10 +2574,10 @@ function place(src, dest, opts) {
2563
2574
  }
2564
2575
  function install(opts) {
2565
2576
  const root = packageRoot();
2566
- const skills = skillNames(root);
2567
- if (!skills.length) throw new Error(`no skills found under ${path14.join(root, "skills")}`);
2577
+ const skills = opts.skills === false ? [] : skillNames(root);
2578
+ if (opts.skills !== false && !skills.length) throw new Error(`no skills found under ${path14.join(root, "skills")}`);
2568
2579
  const out = [];
2569
- for (const base2 of skillTargets()) {
2580
+ for (const base2 of opts.skills === false ? [] : skillTargets()) {
2570
2581
  for (const legacy of LEGACY) {
2571
2582
  const l = path14.join(base2, legacy);
2572
2583
  if (!skills.includes(legacy) && linksInto(l, root)) {
@@ -3620,7 +3631,7 @@ function progress(meta) {
3620
3631
  }
3621
3632
 
3622
3633
  // src/core/run.ts
3623
- var VERSION2 = true ? "0.14.1" : "0.0.0-dev";
3634
+ var VERSION2 = true ? "0.15.0" : "0.0.0-dev";
3624
3635
  function planWork(o) {
3625
3636
  if (!o.plan) return { task: o.task.trim(), tier: o.tier };
3626
3637
  if (o.continueFrom) throw new UserError("a follow-up continues its parent's task; drop --plan/--step");
@@ -3916,9 +3927,9 @@ function attempt(meta, backend, target) {
3916
3927
  }
3917
3928
  var read2 = (f) => fs24.existsSync(f) ? fs24.readFileSync(f, "utf8") : "";
3918
3929
  function retryableFailure(meta, backend, res) {
3919
- const run = backend.parse(read2(runFile(meta.id, "events.jsonl")));
3920
- if (run.usage.steps > 0) return void 0;
3921
- const f = backend.failure(run, read2(runFile(meta.id, "stderr.log")), res.code);
3930
+ const run2 = backend.parse(read2(runFile(meta.id, "events.jsonl")));
3931
+ if (run2.usage.steps > 0) return void 0;
3932
+ const f = backend.failure(run2, read2(runFile(meta.id, "stderr.log")), res.code);
3922
3933
  return f && f.kind !== "other" ? f : void 0;
3923
3934
  }
3924
3935
  var HINTS = {
@@ -3930,19 +3941,19 @@ var HINTS = {
3930
3941
  function finalize(meta, res) {
3931
3942
  const ran = meta.ran ?? meta.worker;
3932
3943
  const backend = getBackend(ran.backend);
3933
- const run = backend.parse(read2(runFile(meta.id, "events.jsonl")));
3934
- if (meta.snapshot?.dir && meta.repoRoot) run.finalText = backToProject(run.finalText, meta.snapshot.dir, meta.repoRoot);
3935
- meta.sessionId = run.sessionId ?? meta.sessionId;
3936
- meta.usage = run.usage;
3937
- fs24.writeFileSync(runFile(meta.id, "summary.md"), `${run.finalText}
3944
+ const run2 = backend.parse(read2(runFile(meta.id, "events.jsonl")));
3945
+ if (meta.snapshot?.dir && meta.repoRoot) run2.finalText = backToProject(run2.finalText, meta.snapshot.dir, meta.repoRoot);
3946
+ meta.sessionId = run2.sessionId ?? meta.sessionId;
3947
+ meta.usage = run2.usage;
3948
+ fs24.writeFileSync(runFile(meta.id, "summary.md"), `${run2.finalText}
3938
3949
  `);
3939
- if (meta.plan && !meta.reviewOf) meta.taskStatus = parseStatus(run.finalText);
3950
+ if (meta.plan && !meta.reviewOf) meta.taskStatus = parseStatus(run2.finalText);
3940
3951
  if (meta.reviewOf) {
3941
- meta.verdict = parseVerdict(run.finalText);
3952
+ meta.verdict = parseVerdict(run2.finalText);
3942
3953
  if (meta.packageFile) fs24.rmSync(meta.packageFile, { force: true });
3943
3954
  }
3944
3955
  if (!res.timedOut && !res.stopped && !meta.error) {
3945
- const f = backend.failure(run, read2(runFile(meta.id, "stderr.log")), res.code);
3956
+ const f = backend.failure(run2, read2(runFile(meta.id, "stderr.log")), res.code);
3946
3957
  if (f) meta.error = HINTS[f.kind] ? `${f.message} (${HINTS[f.kind]})` : f.message;
3947
3958
  }
3948
3959
  try {
@@ -3950,12 +3961,12 @@ function finalize(meta, res) {
3950
3961
  } catch (e) {
3951
3962
  meta.warnings.push(`could not compute changes: ${e.message}`);
3952
3963
  }
3953
- if (meta.mode === "read" && run.edits.length) {
3954
- meta.warnings.push(`READ-ONLY VIOLATION: worker modified ${[...new Set(run.edits)].join(", ")}`);
3964
+ if (meta.mode === "read" && run2.edits.length) {
3965
+ meta.warnings.push(`READ-ONLY VIOLATION: worker modified ${[...new Set(run2.edits)].join(", ")}`);
3955
3966
  }
3956
3967
  if (meta.sessionId && backend.resolveModel) meta.resolvedModel = backend.resolveModel(meta.sessionId);
3957
- meta.resolvedModel ??= run.model ?? ran.model;
3958
- const refs = extractRefs(run.finalText);
3968
+ meta.resolvedModel ??= run2.model ?? ran.model;
3969
+ const refs = extractRefs(run2.finalText);
3959
3970
  if (refs.length) {
3960
3971
  meta.refs = verifyRefs(refs, [meta.cwd, meta.repoRoot ?? "", meta.dir]);
3961
3972
  if (meta.refs.invalid.length) {
@@ -3964,14 +3975,14 @@ function finalize(meta, res) {
3964
3975
  }
3965
3976
  meta.state = res.timedOut ? "timeout" : res.stopped ? "stopped" : meta.error ? "failed" : "done";
3966
3977
  if (meta.auditOf && meta.state === "done") {
3967
- const found = parseAudit(run.finalText);
3978
+ const found = parseAudit(run2.finalText);
3968
3979
  meta.auditVerdict = found.verdict;
3969
3980
  meta.auditDisputed = found.disputed;
3970
3981
  }
3971
- if (meta.state === "done" && !run.finalText) meta.warnings.push("worker finished without a written answer");
3982
+ if (meta.state === "done" && !run2.finalText) meta.warnings.push("worker finished without a written answer");
3972
3983
  if (meta.state === "done" && meta.verify) meta.verifyResult = runVerify(meta);
3973
3984
  meta.endedAt = (/* @__PURE__ */ new Date()).toISOString();
3974
- meta.returnedTokens = estimateTokens(formatReport(meta, run.finalText));
3985
+ meta.returnedTokens = estimateTokens(formatReport(meta, run2.finalText));
3975
3986
  meta.savedUsd = meta.auditOf ? 0 : savedUsd(meta.usage, meta.returnedTokens);
3976
3987
  writeMeta(meta);
3977
3988
  if (meta.mode === "write" && meta.repoRoot) releaseWriteLock(meta.repoRoot, meta.id);
@@ -4219,8 +4230,8 @@ var TOOLS2 = [
4219
4230
  annotations: { readOnlyHint: true, openWorldHint: false },
4220
4231
  async call(a) {
4221
4232
  const group = str(a, "group");
4222
- const run = str(a, "run");
4223
- return asResult(await pit(["status", ...group ? ["-g", group] : run ? [run] : []], 6e4));
4233
+ const run2 = str(a, "run");
4234
+ return asResult(await pit(["status", ...group ? ["-g", group] : run2 ? [run2] : []], 6e4));
4224
4235
  }
4225
4236
  },
4226
4237
  {
@@ -4230,8 +4241,8 @@ var TOOLS2 = [
4230
4241
  inputSchema: { type: "object", properties: { run: RUN_ID2, patch: { type: "boolean" }, full: { type: "boolean" } }, required: ["run"] },
4231
4242
  annotations: { readOnlyHint: true, openWorldHint: false },
4232
4243
  async call(a) {
4233
- const run = str(a, "run", true);
4234
- return asResult(await pit(["show", run, ...bool(a, "patch") ? ["--patch"] : bool(a, "full") ? ["--full"] : []], 6e4));
4244
+ const run2 = str(a, "run", true);
4245
+ return asResult(await pit(["show", run2, ...bool(a, "patch") ? ["--patch"] : bool(a, "full") ? ["--full"] : []], 6e4));
4235
4246
  }
4236
4247
  },
4237
4248
  {
@@ -4253,12 +4264,12 @@ var TOOLS2 = [
4253
4264
  },
4254
4265
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
4255
4266
  async call(a) {
4256
- const run = str(a, "run");
4267
+ const run2 = str(a, "run");
4257
4268
  const range = str(a, "range");
4258
- if (!run === !range) throw new ToolError('give exactly one of "run" or "range"');
4269
+ if (!run2 === !range) throw new ToolError('give exactly one of "run" or "range"');
4259
4270
  const plan = str(a, "plan");
4260
4271
  const flags = workerFlags(a, ["worker", "tier", "dir", "group"]);
4261
- return startAndWait(["review", ...range ? ["--range", range, ...plan ? ["--plan", plan] : []] : [run], ...flags], waitSeconds(a));
4272
+ return startAndWait(["review", ...range ? ["--range", range, ...plan ? ["--plan", plan] : []] : [run2], ...flags], waitSeconds(a));
4262
4273
  }
4263
4274
  },
4264
4275
  {
@@ -4268,9 +4279,9 @@ var TOOLS2 = [
4268
4279
  inputSchema: { type: "object", properties: { run: RUN_ID2, worker: WORKER_PROPS.worker, ...WAIT_PROP }, required: ["run"] },
4269
4280
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
4270
4281
  async call(a) {
4271
- const run = str(a, "run", true);
4282
+ const run2 = str(a, "run", true);
4272
4283
  const worker = str(a, "worker");
4273
- return startAndWait(["audit", run, ...worker ? ["-W", worker] : []], waitSeconds(a));
4284
+ return startAndWait(["audit", run2, ...worker ? ["-W", worker] : []], waitSeconds(a));
4274
4285
  }
4275
4286
  },
4276
4287
  {
@@ -4281,9 +4292,9 @@ var TOOLS2 = [
4281
4292
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
4282
4293
  async call(a) {
4283
4294
  const group = str(a, "group");
4284
- const run = str(a, "run");
4285
- if (!run && !group) throw new ToolError('give "run" or "group"');
4286
- return asResult(await pit(["apply", ...group && !run ? ["-g", group] : [run], ...bool(a, "allowDelete") ? ["--allow-delete"] : []], 12e4));
4295
+ const run2 = str(a, "run");
4296
+ 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));
4287
4298
  }
4288
4299
  },
4289
4300
  {
@@ -4303,10 +4314,10 @@ var TOOLS2 = [
4303
4314
  inputSchema: { type: "object", properties: { run: RUN_ID2, group: { type: "string" } } },
4304
4315
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
4305
4316
  async call(a) {
4306
- const run = str(a, "run");
4317
+ const run2 = str(a, "run");
4307
4318
  const group = str(a, "group");
4308
- if (!run && !group) throw new ToolError('give "run" or "group"');
4309
- return asResult(await pit(["stop", ...run ? [run] : ["-g", group]], 6e4));
4319
+ if (!run2 && !group) throw new ToolError('give "run" or "group"');
4320
+ return asResult(await pit(["stop", ...run2 ? [run2] : ["-g", group]], 6e4));
4310
4321
  }
4311
4322
  }
4312
4323
  ];
@@ -4397,9 +4408,9 @@ async function serveMcp() {
4397
4408
  }
4398
4409
 
4399
4410
  // src/cli/commands.ts
4400
- import { spawnSync as spawnSync7 } from "node:child_process";
4401
- import fs29 from "node:fs";
4402
- import path25 from "node:path";
4411
+ import { spawnSync as spawnSync8 } from "node:child_process";
4412
+ import fs30 from "node:fs";
4413
+ import path26 from "node:path";
4403
4414
  import { fileURLToPath as fileURLToPath4 } from "node:url";
4404
4415
 
4405
4416
  // src/core/init.ts
@@ -4589,18 +4600,217 @@ ${(/* @__PURE__ */ new Date()).toLocaleTimeString()} \xB7 Ctrl-C to stop watchin
4589
4600
  }
4590
4601
  }
4591
4602
 
4592
- // src/core/plan-status.ts
4593
- import crypto6 from "node:crypto";
4603
+ // src/core/mcp-install.ts
4604
+ import { spawnSync as spawnSync7 } from "node:child_process";
4594
4605
  import fs26 from "node:fs";
4606
+ import os10 from "node:os";
4595
4607
  import path22 from "node:path";
4608
+ var CLIENT_IDS = ["claude-code", "codex", "gemini", "cursor", "claude-desktop"];
4609
+ var NAME = "pitroom";
4610
+ function mcpCommand() {
4611
+ const launcher = launcherPath();
4612
+ if (isPitroomLauncher(launcher)) return { command: launcher, args: ["mcp"] };
4613
+ for (const dir of (process.env.PATH ?? "").split(path22.delimiter).filter(Boolean)) {
4614
+ const p = path22.join(dir, process.platform === "win32" ? "pitroom.cmd" : "pitroom");
4615
+ if (fs26.existsSync(p)) return { command: path22.resolve(p), args: ["mcp"] };
4616
+ }
4617
+ return { command: process.execPath, args: [process.argv[1] ?? "pitroom", "mcp"] };
4618
+ }
4619
+ var same = (a, b) => {
4620
+ const o = b;
4621
+ return !!o && o.command === a.command && Array.isArray(o.args) && o.args.join("\0") === a.args.join("\0");
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() };
4627
+ }
4628
+ var runnable = (backendId) => run(backendId, ["--version"]).ok;
4629
+ var readJson = (file2) => {
4630
+ try {
4631
+ return JSON.parse(fs26.readFileSync(file2, "utf8"));
4632
+ } catch {
4633
+ return void 0;
4634
+ }
4635
+ };
4636
+ var jsonState = (file2, c) => {
4637
+ const entry = readJson(file2)?.mcpServers?.[NAME];
4638
+ return entry === void 0 ? "absent" : same(c, entry) ? "same" : "different";
4639
+ };
4640
+ var home2 = () => os10.homedir();
4641
+ var geminiDir = () => path22.join(process.env.GEMINI_CLI_HOME ?? home2(), ".gemini");
4642
+ function cliClient(id, backend, name, where, o) {
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(), "~");
4661
+ return {
4662
+ id,
4663
+ name,
4664
+ found: () => fs26.existsSync(dir()),
4665
+ state: (c) => jsonState(file2(), c),
4666
+ add: (c) => {
4667
+ let config = {};
4668
+ if (fs26.existsSync(file2())) {
4669
+ config = readJson(file2());
4670
+ if (!config || typeof config !== "object" || Array.isArray(config)) return { ok: false, message: `${rel()} is not valid JSON: left alone` };
4671
+ const backup = `${file2()}.bak-pitroom`;
4672
+ if (!fs26.existsSync(backup)) fs26.copyFileSync(file2(), backup);
4673
+ }
4674
+ config.mcpServers = { ...config.mcpServers ?? {}, [NAME]: { command: c.command, args: c.args } };
4675
+ fs26.mkdirSync(path22.dirname(file2()), { recursive: true });
4676
+ const tmp = `${file2()}.${process.pid}.tmp`;
4677
+ fs26.writeFileSync(tmp, `${JSON.stringify(config, null, 2)}
4678
+ `);
4679
+ fs26.renameSync(tmp, file2());
4680
+ return { ok: true, message: rel() };
4681
+ },
4682
+ remove: () => {
4683
+ const config = readJson(file2());
4684
+ if (!config?.mcpServers?.[NAME]) return { ok: true, message: rel() };
4685
+ delete config.mcpServers[NAME];
4686
+ fs26.writeFileSync(file2(), `${JSON.stringify(config, null, 2)}
4687
+ `);
4688
+ return { ok: true, message: rel() };
4689
+ }
4690
+ };
4691
+ }
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] ?? "";
4707
+ try {
4708
+ const command = /^command\s*=\s*("(?:[^"\\]|\\.)*")/m.exec(table2)?.[1];
4709
+ const args = /^args\s*=\s*(\[[^\n]*\])/m.exec(table2)?.[1];
4710
+ return command && args && same(c, { command: JSON.parse(command), args: JSON.parse(args) }) ? "same" : "different";
4711
+ } catch {
4712
+ return "different";
4713
+ }
4714
+ }
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;
4752
+ }
4753
+ const now = client.state(c);
4754
+ if (now === "same" && !opts.force) {
4755
+ r("already", "already registered");
4756
+ continue;
4757
+ }
4758
+ if (opts.dryRun) {
4759
+ r("would-add", `would register ${[c.command, ...c.args].join(" ")}`);
4760
+ continue;
4761
+ }
4762
+ if (now !== "absent") attempt2(() => client.remove());
4763
+ const done = attempt2(() => client.add(c));
4764
+ r(done.ok ? now === "absent" ? "added" : "updated" : "failed", done.ok ? `${now === "absent" ? "registered" : "updated"} in ${done.message}` : done.message);
4765
+ }
4766
+ return out;
4767
+ }
4768
+ function uninstallMcp() {
4769
+ const c = mcpCommand();
4770
+ const out = [];
4771
+ for (const client of clients()) {
4772
+ let has2 = false;
4773
+ try {
4774
+ has2 = client.state(c) !== "absent";
4775
+ } catch {
4776
+ has2 = false;
4777
+ }
4778
+ if (!has2) continue;
4779
+ const done = client.found() ? attempt2(() => client.remove()) : { ok: false, message: `its command does not run: remove the pitroom entry from its config by hand` };
4780
+ out.push({ id: client.id, name: client.name, state: done.ok ? "removed" : "failed", message: done.ok ? `removed from ${done.message}` : done.message });
4781
+ }
4782
+ return out;
4783
+ }
4784
+ function mcpStatus() {
4785
+ const c = mcpCommand();
4786
+ return clients().map((client) => {
4787
+ try {
4788
+ return { id: client.id, name: client.name, state: client.state(c) };
4789
+ } catch {
4790
+ return { id: client.id, name: client.name, state: "absent" };
4791
+ }
4792
+ });
4793
+ }
4794
+ var parseClients = (value) => {
4795
+ if (!value) return void 0;
4796
+ const wanted = value.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
4797
+ const bad = wanted.filter((w) => !CLIENT_IDS.includes(w));
4798
+ if (bad.length) throw new Error(`unknown client ${bad.join(", ")} (one of: ${CLIENT_IDS.join(", ")})`);
4799
+ return wanted;
4800
+ };
4801
+
4802
+ // src/core/plan-status.ts
4803
+ import crypto6 from "node:crypto";
4804
+ import fs27 from "node:fs";
4805
+ import path23 from "node:path";
4596
4806
  function notesFile(plan) {
4597
4807
  const id = crypto6.createHash("sha1").update(plan.file).digest("hex").slice(0, 12);
4598
- return path22.join(home(), "plans", id, "notes.md");
4808
+ return path23.join(home(), "plans", id, "notes.md");
4599
4809
  }
4600
4810
  function readNotes(plan) {
4601
4811
  const f = notesFile(plan);
4602
- if (!fs26.existsSync(f)) return [];
4603
- return fs26.readFileSync(f, "utf8").split("\n").slice(1).filter(Boolean).map((l) => {
4812
+ if (!fs27.existsSync(f)) return [];
4813
+ return fs27.readFileSync(f, "utf8").split("\n").slice(1).filter(Boolean).map((l) => {
4604
4814
  const m = /^(\d{4}-\d\d-\d\d \d\d:\d\d) (.*)$/.exec(l);
4605
4815
  return m ? { at: m[1], text: m[2] } : { at: "", text: l };
4606
4816
  });
@@ -4608,11 +4818,11 @@ function readNotes(plan) {
4608
4818
  function addNote(planFile, text) {
4609
4819
  const plan = loadPlan(planFile);
4610
4820
  const f = notesFile(plan);
4611
- fs26.mkdirSync(path22.dirname(f), { recursive: true });
4612
- if (!fs26.existsSync(f)) fs26.writeFileSync(f, `# plan: ${plan.file}
4821
+ fs27.mkdirSync(path23.dirname(f), { recursive: true });
4822
+ if (!fs27.existsSync(f)) fs27.writeFileSync(f, `# plan: ${plan.file}
4613
4823
  `);
4614
4824
  const at = (/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace("T", " ");
4615
- fs26.appendFileSync(f, `${at} ${text.replace(/\s+/g, " ").trim()}
4825
+ fs27.appendFileSync(f, `${at} ${text.replace(/\s+/g, " ").trim()}
4616
4826
  `);
4617
4827
  return f;
4618
4828
  }
@@ -4680,8 +4890,8 @@ ${rulings}`,
4680
4890
 
4681
4891
  // src/core/review.ts
4682
4892
  import crypto7 from "node:crypto";
4683
- import fs27 from "node:fs";
4684
- import path23 from "node:path";
4893
+ import fs28 from "node:fs";
4894
+ import path24 from "node:path";
4685
4895
  var TEMPLATE2 = { task: "task-reviewer", fix: "re-review", range: "code-reviewer" };
4686
4896
  var section = (title, body) => `## ${title}
4687
4897
 
@@ -4699,7 +4909,7 @@ function ancestors(m) {
4699
4909
  function briefOf(m) {
4700
4910
  const root = [m, ...ancestors(m)].at(-1);
4701
4911
  const f = runFile(root.id, "brief.md");
4702
- return fs27.existsSync(f) ? fs27.readFileSync(f, "utf8") : root.task;
4912
+ return fs28.existsSync(f) ? fs28.readFileSync(f, "utf8") : root.task;
4703
4913
  }
4704
4914
  function latestReview(ids) {
4705
4915
  for (const id of listRunIds().reverse()) {
@@ -4713,7 +4923,7 @@ function latestReview(ids) {
4713
4923
  }
4714
4924
  function diffOf(m, from = m.baseTree) {
4715
4925
  const where = m.mode === "isolate" ? m.worktree : m.repoRoot;
4716
- if (where && fs27.existsSync(where) && from && m.afterTree) return reviewDiff(where, from, m.afterTree);
4926
+ if (where && fs28.existsSync(where) && from && m.afterTree) return reviewDiff(where, from, m.afterTree);
4717
4927
  if (from !== m.baseTree) {
4718
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);
4719
4929
  }
@@ -4727,7 +4937,7 @@ function runReview(id) {
4727
4937
  if (!m.changes?.length) throw new UserError(`run ${m.id} made no changes; nothing to review`);
4728
4938
  const chain = ancestors(m);
4729
4939
  const previous = latestReview(chain.map((a) => a.id));
4730
- const dir = m.mode === "isolate" && m.worktree && fs27.existsSync(m.worktree) ? m.cwd : m.dir;
4940
+ const dir = m.mode === "isolate" && m.worktree && fs28.existsSync(m.worktree) ? m.cwd : m.dir;
4731
4941
  const job = { of: m.id, dir, implementer: m.ran ?? m.worker, group: m.group, plan: m.plan };
4732
4942
  const title = m.plan ? `Task ${m.plan.step}: ${m.plan.title}` : `run ${m.id}`;
4733
4943
  if (!previous) {
@@ -4801,9 +5011,9 @@ function pickReviewer(job) {
4801
5011
  function writePackage(job) {
4802
5012
  const g = gitDir(job.dir);
4803
5013
  if (!g) throw new UserError(`not a git repository: ${job.dir}`);
4804
- const file2 = path23.join(g, "pitroom", `review-${crypto7.randomBytes(4).toString("hex")}.md`);
4805
- fs27.mkdirSync(path23.dirname(file2), { recursive: true });
4806
- fs27.writeFileSync(file2, job.package);
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);
4807
5017
  return file2;
4808
5018
  }
4809
5019
 
@@ -4906,15 +5116,15 @@ function formatModels(t) {
4906
5116
  // src/core/dash.ts
4907
5117
  import { spawn as spawn4 } from "node:child_process";
4908
5118
  import crypto8 from "node:crypto";
4909
- import fs28 from "node:fs";
5119
+ import fs29 from "node:fs";
4910
5120
  import http from "node:http";
4911
- import path24 from "node:path";
5121
+ import path25 from "node:path";
4912
5122
  import { fileURLToPath as fileURLToPath3 } from "node:url";
4913
5123
 
4914
5124
  // src/core/file-diff.ts
4915
5125
  var PREVIEW_LINES = 300;
4916
5126
  function gitPath(value, prefix = true) {
4917
- let path27 = value;
5127
+ let path28 = value;
4918
5128
  if (value.startsWith('"') && value.endsWith('"')) {
4919
5129
  const bytes = [];
4920
5130
  const escapes = { t: " ", n: "\n", r: "\r", b: "\b", f: "\f", v: "\v" };
@@ -4922,9 +5132,9 @@ function gitPath(value, prefix = true) {
4922
5132
  if (match[1] && /^[0-7]+$/.test(match[1])) bytes.push(parseInt(match[1], 8));
4923
5133
  else bytes.push(...new TextEncoder().encode(match[2] ?? escapes[match[1]] ?? match[1]));
4924
5134
  }
4925
- path27 = new TextDecoder().decode(new Uint8Array(bytes));
5135
+ path28 = new TextDecoder().decode(new Uint8Array(bytes));
4926
5136
  }
4927
- return prefix ? path27.replace(/^[ab]\//, "") : path27;
5137
+ return prefix ? path28.replace(/^[ab]\//, "") : path28;
4928
5138
  }
4929
5139
  function headerPath(header) {
4930
5140
  const quoted = header.match(/^diff --git ("(?:[^"\\]|\\.)*"|\S+) ("(?:[^"\\]|\\.)*"|\S+)$/);
@@ -5172,11 +5382,11 @@ var assetsDir = () => fileURLToPath3(new URL("./ui/", import.meta.url));
5172
5382
  var assetCache = /* @__PURE__ */ new Map();
5173
5383
  function readAsset(route) {
5174
5384
  try {
5175
- const file2 = path24.join(assetsDir(), path24.basename(route));
5176
- const { mtimeMs } = fs28.statSync(file2);
5385
+ const file2 = path25.join(assetsDir(), path25.basename(route));
5386
+ const { mtimeMs } = fs29.statSync(file2);
5177
5387
  const hit = assetCache.get(route);
5178
5388
  if (hit && hit.mtimeMs === mtimeMs) return hit.data;
5179
- const data = fs28.readFileSync(file2);
5389
+ const data = fs29.readFileSync(file2);
5180
5390
  assetCache.set(route, { mtimeMs, data });
5181
5391
  return data;
5182
5392
  } catch {
@@ -5256,7 +5466,7 @@ function startDash(opts) {
5256
5466
  });
5257
5467
  });
5258
5468
  }
5259
- var registryFile = () => path24.join(home(), "dash.json");
5469
+ var registryFile = () => path25.join(home(), "dash.json");
5260
5470
  async function ping(port) {
5261
5471
  try {
5262
5472
  const r = await fetch(`http://127.0.0.1:${port}/api/state?limit=1`, { signal: AbortSignal.timeout(1500) });
@@ -5267,11 +5477,11 @@ async function ping(port) {
5267
5477
  }
5268
5478
  async function runningDash() {
5269
5479
  try {
5270
- const r = JSON.parse(fs28.readFileSync(registryFile(), "utf8"));
5480
+ const r = JSON.parse(fs29.readFileSync(registryFile(), "utf8"));
5271
5481
  if (isAlive(r.pid) && await ping(r.port)) return { ...r, url: `http://127.0.0.1:${r.port}/` };
5272
5482
  } catch {
5273
5483
  }
5274
- fs28.rmSync(registryFile(), { force: true });
5484
+ fs29.rmSync(registryFile(), { force: true });
5275
5485
  return void 0;
5276
5486
  }
5277
5487
  function openBrowser(url) {
@@ -5286,7 +5496,7 @@ async function dashCommand(o) {
5286
5496
  if (o.stop) {
5287
5497
  if (!existing) throw new UserError("pitroom dash is not running");
5288
5498
  process.kill(existing.pid, "SIGTERM");
5289
- fs28.rmSync(registryFile(), { force: true });
5499
+ fs29.rmSync(registryFile(), { force: true });
5290
5500
  o.log(`stopped pitroom dash (${existing.url})`);
5291
5501
  return 0;
5292
5502
  }
@@ -5323,8 +5533,8 @@ async function dashCommand(o) {
5323
5533
  if (explicit || e.code !== "EADDRINUSE") throw new UserError(`pitroom dash: ${e.message}`);
5324
5534
  dash = await startDash({ port: 0, idleMs: o.idleMs });
5325
5535
  }
5326
- fs28.mkdirSync(home(), { recursive: true });
5327
- fs28.writeFileSync(registryFile(), JSON.stringify({ pid: process.pid, port: dash.port }));
5536
+ fs29.mkdirSync(home(), { recursive: true });
5537
+ fs29.writeFileSync(registryFile(), JSON.stringify({ pid: process.pid, port: dash.port }));
5328
5538
  const stop = () => void dash.close();
5329
5539
  process.once("SIGINT", stop);
5330
5540
  process.once("SIGTERM", stop);
@@ -5333,7 +5543,7 @@ async function dashCommand(o) {
5333
5543
  if (o.open) openBrowser(dash.url);
5334
5544
  await dash.closed;
5335
5545
  try {
5336
- if (JSON.parse(fs28.readFileSync(registryFile(), "utf8")).pid === process.pid) fs28.rmSync(registryFile(), { force: true });
5546
+ if (JSON.parse(fs29.readFileSync(registryFile(), "utf8")).pid === process.pid) fs29.rmSync(registryFile(), { force: true });
5337
5547
  } catch {
5338
5548
  }
5339
5549
  return 0;
@@ -5386,10 +5596,10 @@ async function cmdReview(p) {
5386
5596
  writeMeta(meta);
5387
5597
  }
5388
5598
  } catch (e) {
5389
- fs29.rmSync(packageFile, { force: true });
5599
+ fs30.rmSync(packageFile, { force: true });
5390
5600
  throw e;
5391
5601
  }
5392
- fs29.writeFileSync(runFile(meta.id, "package.md"), job.package);
5602
+ fs30.writeFileSync(runFile(meta.id, "package.md"), job.package);
5393
5603
  return launch(p, meta);
5394
5604
  }
5395
5605
  async function cmdAudit(p) {
@@ -5457,7 +5667,7 @@ function cmdPlan(p) {
5457
5667
  }
5458
5668
  function cmdCrew(p) {
5459
5669
  const file2 = flag(p, "task-file");
5460
- const tasks = (file2 ? fs29.readFileSync(file2, "utf8").split(/^\s*---\s*$/m) : p.positional).map((t) => t.trim()).filter(Boolean);
5670
+ const tasks = (file2 ? fs30.readFileSync(file2, "utf8").split(/^\s*---\s*$/m) : p.positional).map((t) => t.trim()).filter(Boolean);
5461
5671
  if (!tasks.length) throw new UserError('crew needs tasks: pitroom crew -g NAME "task 1" "task 2" (or --task-file with --- separators)');
5462
5672
  if (has(p, "write") && tasks.length > 1) {
5463
5673
  throw new UserError("parallel --write runs would edit the same tree; use --isolate (each worker gets its own isolated copy)");
@@ -5660,13 +5870,13 @@ function sinceMs(s) {
5660
5870
  if (!m) throw new UserError("--since takes 7d, 30d, \u2026 or all");
5661
5871
  return Date.now() - Number(m[1]) * 864e5;
5662
5872
  }
5663
- var readStdin = () => process.stdin.isTTY ? "" : fs29.readFileSync(0, "utf8");
5873
+ var readStdin = () => process.stdin.isTTY ? "" : fs30.readFileSync(0, "utf8");
5664
5874
  function cmdStatusline(p) {
5665
5875
  const input = readStdin();
5666
5876
  const lines = [];
5667
5877
  const then = flag(p, "then");
5668
5878
  if (then) {
5669
- const r = spawnSync7("/bin/sh", ["-c", then], { input, encoding: "utf8", timeout: 5e3 });
5879
+ const r = spawnSync8("/bin/sh", ["-c", then], { input, encoding: "utf8", timeout: 5e3 });
5670
5880
  if (r.stdout?.trimEnd()) lines.push(r.stdout.trimEnd());
5671
5881
  }
5672
5882
  try {
@@ -5700,8 +5910,8 @@ function cmdModels(p) {
5700
5910
  return 0;
5701
5911
  }
5702
5912
  function cmdHookStart() {
5703
- const script = path25.join(path25.dirname(path25.dirname(fileURLToPath4(import.meta.url))), "hooks", "session-start.mjs");
5704
- if (fs29.existsSync(script)) spawnSync7(process.execPath, [script], { stdio: ["ignore", "inherit", "ignore"], env: process.env });
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 });
5705
5915
  return 0;
5706
5916
  }
5707
5917
  function cmdInit(p) {
@@ -5741,19 +5951,41 @@ function cmdSavings(p) {
5741
5951
  est. saved ${usd(t.saved)} (vs ${primaryPrice().name} list prices)`);
5742
5952
  const out = flag(p, "card");
5743
5953
  if (out) {
5744
- fs29.writeFileSync(out, card(t, period));
5745
- console.log(`card written to ${path25.resolve(out)}`);
5954
+ fs30.writeFileSync(out, card(t, period));
5955
+ console.log(`card written to ${path26.resolve(out)}`);
5746
5956
  }
5747
5957
  if (has(p, "badge")) console.log(`![pitroom](${badgeUrl(t)})`);
5748
5958
  return 0;
5749
5959
  }
5960
+ var MCP_ICON = { added: "\u2714", updated: "\u2714", already: "\xB7", removed: "\u2714", "not-found": "\xB7", failed: "\u2718", "would-add": "\u2192" };
5750
5961
  function cmdInstall(p) {
5751
- for (const line of install({ copy: has(p, "copy"), force: has(p, "force") })) console.log(line);
5752
- return 0;
5962
+ const mcp = has(p, "mcp") || has(p, "client");
5963
+ if (has(p, "dry-run") && !mcp) throw new UserError("--dry-run goes with --mcp");
5964
+ if (has(p, "no-skills") && !mcp) throw new UserError("--no-skills goes with --mcp: without it install links the skills");
5965
+ let only;
5966
+ try {
5967
+ only = parseClients(flag(p, "client"));
5968
+ } catch (e) {
5969
+ throw new UserError(e.message);
5970
+ }
5971
+ if (!has(p, "dry-run")) for (const line of install({ copy: has(p, "copy"), force: has(p, "force"), skills: !has(p, "no-skills") })) console.log(line);
5972
+ if (!mcp) return 0;
5973
+ const results = installMcp({ only, dryRun: has(p, "dry-run"), force: has(p, "force") });
5974
+ console.log(`
5975
+ MCP clients (pitroom mcp):`);
5976
+ for (const r of results) console.log(`${MCP_ICON[r.state] ?? "\u2022"} ${r.name}: ${r.message}`);
5977
+ if (results.every((r) => r.state === "not-found")) {
5978
+ console.log("no MCP client found: add the server by hand (see the README), or install a client first");
5979
+ } else if (results.some((r) => r.state === "added" || r.state === "updated")) {
5980
+ console.log("restart those clients (or start a new session) so they pick the server up");
5981
+ }
5982
+ return results.some((r) => r.state === "failed") ? 1 : 0;
5753
5983
  }
5754
5984
  function cmdUninstall() {
5755
5985
  for (const line of uninstall()) console.log(line);
5756
- return 0;
5986
+ const mcp = uninstallMcp();
5987
+ for (const r of mcp) console.log(`${MCP_ICON[r.state] ?? "\u2022"} ${r.name}: ${r.message}`);
5988
+ return mcp.some((r) => r.state === "failed") ? 1 : 0;
5757
5989
  }
5758
5990
  function cmdConfig(p) {
5759
5991
  const { warnings } = loadConfig();
@@ -5762,7 +5994,7 @@ function cmdConfig(p) {
5762
5994
  console.log(JSON.stringify({ path: configPath(), settings: eff, warnings }, null, 2));
5763
5995
  return 0;
5764
5996
  }
5765
- console.log(`config file: ${configPath()}${fs29.existsSync(configPath()) ? "" : " (not present)"}`);
5997
+ console.log(`config file: ${configPath()}${fs30.existsSync(configPath()) ? "" : " (not present)"}`);
5766
5998
  for (const [key, s] of Object.entries(eff)) {
5767
5999
  const v = Array.isArray(s.value) ? s.value.join(", ") || "\u2014" : s.value && typeof s.value === "object" ? Object.entries(s.value).map(([k, m]) => `${k}=${m}`).join(", ") || "\u2014" : s.value === void 0 ? key === "model" ? "the worker's default" : "\u2014" : String(s.value);
5768
6000
  console.log(` ${key.padEnd(15)} ${v} (${s.source})`);
@@ -5786,7 +6018,7 @@ function cmdClean(p) {
5786
6018
  const m = readMeta(id);
5787
6019
  if (m.mode === "isolate" && !m.discarded && !m.applied) discardRun(m);
5788
6020
  recordRun(m);
5789
- fs29.rmSync(runDir(id), { recursive: true, force: true });
6021
+ fs30.rmSync(runDir(id), { recursive: true, force: true });
5790
6022
  }
5791
6023
  const snapshots = prune(Date.now(), 0);
5792
6024
  console.log(`removed ${old.length} run(s)${snapshots ? ` and ${snapshots} read snapshot${snapshots === 1 ? "" : "s"}` : ""}; the history and the savings ledger are kept`);
@@ -5796,10 +6028,10 @@ var cmdRevert = (p) => (console.log(revertRun(freshMeta(resolveRun(p.positional[
5796
6028
  var cmdDiscard = (p) => (console.log(discardRun(freshMeta(resolveRun(p.positional[0])))), 0);
5797
6029
 
5798
6030
  // src/core/doctor.ts
5799
- import { spawnSync as spawnSync8 } from "node:child_process";
5800
- import fs30 from "node:fs";
5801
- import os10 from "node:os";
5802
- import path26 from "node:path";
6031
+ import { spawnSync as spawnSync9 } from "node:child_process";
6032
+ import fs31 from "node:fs";
6033
+ import os11 from "node:os";
6034
+ import path27 from "node:path";
5803
6035
  var MARK = { ok: () => green("\u2714"), warn: () => yellow("!"), fail: () => red("\u2718") };
5804
6036
  var NEXT = [
5805
6037
  { when: /no default model|no fallback workers/, command: "pitroom init", why: "propose a starter config (fallback models from your catalogue)" },
@@ -5809,14 +6041,14 @@ var NEXT = [
5809
6041
  { when: /Gemini CLI is not signed in|IneligibleTierError/, command: "export GEMINI_API_KEY=\u2026", why: "a Google AI Studio key for the Gemini worker" }
5810
6042
  ];
5811
6043
  function firstNodeOnPath() {
5812
- for (const dir of (process.env.PATH ?? "").split(path26.delimiter).filter(Boolean)) {
5813
- const file2 = path26.join(dir, process.platform === "win32" ? "node.exe" : "node");
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");
5814
6046
  try {
5815
- if (!fs30.statSync(file2).isFile()) continue;
6047
+ if (!fs31.statSync(file2).isFile()) continue;
5816
6048
  } catch {
5817
6049
  continue;
5818
6050
  }
5819
- const r = spawnSync8(file2, ["-p", "process.versions.node"], { encoding: "utf8", timeout: 5e3 });
6051
+ const r = spawnSync9(file2, ["-p", "process.versions.node"], { encoding: "utf8", timeout: 5e3 });
5820
6052
  return r.status === 0 ? { path: file2, version: r.stdout.trim() } : { path: file2, version: "" };
5821
6053
  }
5822
6054
  return void 0;
@@ -5839,12 +6071,12 @@ function doctor5(probe) {
5839
6071
  const addAll = (list2) => list2.forEach((c) => add(c.level, c.message));
5840
6072
  add("ok", `pitroom ${VERSION2} \xB7 node ${process.versions.node} \xB7 state in ${home()}`);
5841
6073
  const onPath = firstNodeOnPath();
5842
- if (onPath && onPath.version && tooOld(onPath.version) && path26.resolve(onPath.path) !== path26.resolve(process.execPath)) {
6074
+ if (onPath && onPath.version && tooOld(onPath.version) && path27.resolve(onPath.path) !== path27.resolve(process.execPath)) {
5843
6075
  add("warn", `the first \`node\` on PATH is v${onPath.version} (${onPath.path}), older than the 22.13 Pitroom needs: shells that agent apps start may use it (the pitroom command finds a newer Node itself; other tools may not)`);
5844
6076
  }
5845
6077
  add(gitAvailable() ? "ok" : "warn", gitAvailable() ? "git available" : "git not found: --write/--isolate tracking disabled");
5846
6078
  const cfg = loadConfig();
5847
- add(cfg.warnings.length ? "warn" : "ok", `config: ${configPath()}${fs30.existsSync(configPath()) ? "" : " (not present, defaults in use)"}`);
6079
+ add(cfg.warnings.length ? "warn" : "ok", `config: ${configPath()}${fs31.existsSync(configPath()) ? "" : " (not present, defaults in use)"}`);
5848
6080
  for (const w of cfg.warnings) add("warn", w);
5849
6081
  if (process.platform !== "win32") {
5850
6082
  const guarded = guardEnv(process.env).PATH?.startsWith(shimDir());
@@ -5916,6 +6148,11 @@ function doctor5(probe) {
5916
6148
  for (const c of found) add("ok", c.message);
5917
6149
  add("ok", `use it with -W ${backend.id}[:model], or name it in "fallback" or "tiers" in the config`);
5918
6150
  }
6151
+ section2("MCP");
6152
+ const have = mcpStatus().filter((m) => m.state !== "absent");
6153
+ const stale = have.filter((m) => m.state === "different");
6154
+ add("ok", have.length ? `pitroom mcp is registered in: ${have.map((m) => m.name).join(", ")}` : "pitroom mcp is not registered in any client: `pitroom install --mcp` does it for the ones found (Cursor, Claude Desktop, Claude Code, Codex, Gemini CLI)");
6155
+ for (const m of stale) add("warn", `${m.name} runs a different command for pitroom mcp than this install's: \`pitroom install --mcp --no-skills\` updates it`);
5919
6156
  section2("Skills and agents");
5920
6157
  addAll(skillChecks());
5921
6158
  if (probe && chain[0]) {
@@ -5962,7 +6199,7 @@ function liveProbe(target) {
5962
6199
  title: "pitroom doctor probe"
5963
6200
  });
5964
6201
  const t0 = Date.now();
5965
- const r = spawnSync8(inv.command, inv.args, {
6202
+ const r = spawnSync9(inv.command, inv.args, {
5966
6203
  encoding: "utf8",
5967
6204
  timeout: 3e5,
5968
6205
  stdio: ["ignore", "pipe", "pipe"],
@@ -5970,9 +6207,9 @@ function liveProbe(target) {
5970
6207
  env: guardEnv({ ...process.env, ...inv.env, PWD: process.cwd(), PITROOM_ACTIVE: "1" })
5971
6208
  });
5972
6209
  const secs2 = ((Date.now() - t0) / 1e3).toFixed(1);
5973
- const run = backend.parse(r.stdout ?? "");
5974
- if (run.finalText.includes("PONG")) return { level: "ok", message: `live probe (${describeTarget(target)}) answered in ${secs2}s` };
5975
- const why = backend.failure(run, r.stderr ?? "", r.status)?.message ?? run.finalText.slice(0, 200);
6210
+ const run2 = backend.parse(r.stdout ?? "");
6211
+ if (run2.finalText.includes("PONG")) return { level: "ok", message: `live probe (${describeTarget(target)}) answered in ${secs2}s` };
6212
+ const why = backend.failure(run2, r.stderr ?? "", r.status)?.message ?? run2.finalText.slice(0, 200);
5976
6213
  return { level: "fail", message: `live probe (${describeTarget(target)}) failed after ${secs2}s: ${why}` };
5977
6214
  }
5978
6215
  function skillChecks() {
@@ -5992,20 +6229,20 @@ function skillChecks() {
5992
6229
  else if (!viaPlugin) checks.push({ level: "warn", message: `no Pitroom skills in ${base2}; run \`pitroom install\`` });
5993
6230
  }
5994
6231
  if (geminiExtensionInstalled()) {
5995
- const linked = installedSkills().some((i) => i.base.includes(`${path26.sep}.agents${path26.sep}`) && i.names.length);
6232
+ const linked = installedSkills().some((i) => i.base.includes(`${path27.sep}.agents${path27.sep}`) && i.names.length);
5996
6233
  checks.push(
5997
6234
  linked ? { level: "warn", message: "Pitroom is installed as a Gemini CLI extension and linked into ~/.agents/skills: Gemini loads the skills twice; run `pitroom uninstall` or `gemini extensions uninstall pitroom`" } : { level: "ok", message: "Gemini CLI extension installed (skills + context)" }
5998
6235
  );
5999
6236
  }
6000
6237
  if (viaPlugin) {
6001
- const linked = installedSkills().some((i) => i.base.includes(`${path26.sep}.claude${path26.sep}`) && i.names.length);
6238
+ const linked = installedSkills().some((i) => i.base.includes(`${path27.sep}.claude${path27.sep}`) && i.names.length);
6002
6239
  checks.push(
6003
6240
  linked ? { level: "warn", message: "Pitroom is installed as a Claude Code plugin and linked into ~/.claude/skills: skills load twice; run `pitroom uninstall` or remove the plugin" } : { level: "ok", message: "Claude Code plugin installed (skills + session-start hook)" }
6004
6241
  );
6005
6242
  }
6006
6243
  const launcher = launcherPath();
6007
- if (fs30.existsSync(launcher)) {
6008
- const r = spawnSync8(launcher, ["--version"], { encoding: "utf8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"] });
6244
+ if (fs31.existsSync(launcher)) {
6245
+ const r = spawnSync9(launcher, ["--version"], { encoding: "utf8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"] });
6009
6246
  checks.push(
6010
6247
  r.status === 0 ? { level: "ok", message: `launcher ${launcher} \u2192 pitroom ${r.stdout.trim()}` } : { level: "fail", message: `launcher ${launcher} does not start: ${(r.stderr || r.stdout).trim().slice(0, 200)}` }
6011
6248
  );
@@ -6015,13 +6252,13 @@ function skillChecks() {
6015
6252
  return checks;
6016
6253
  }
6017
6254
  function geminiExtensionInstalled() {
6018
- const home2 = process.env.GEMINI_CLI_HOME ?? os10.homedir();
6019
- return fs30.existsSync(path26.join(home2, ".gemini", "extensions", "pitroom"));
6255
+ const home3 = process.env.GEMINI_CLI_HOME ?? os11.homedir();
6256
+ return fs31.existsSync(path27.join(home3, ".gemini", "extensions", "pitroom"));
6020
6257
  }
6021
6258
  function pluginInstalled() {
6022
6259
  try {
6023
- const f = path26.join(os10.homedir(), ".claude", "plugins", "installed_plugins.json");
6024
- const plugins = JSON.parse(fs30.readFileSync(f, "utf8")).plugins ?? {};
6260
+ const f = path27.join(os11.homedir(), ".claude", "plugins", "installed_plugins.json");
6261
+ const plugins = JSON.parse(fs31.readFileSync(f, "utf8")).plugins ?? {};
6025
6262
  return Object.keys(plugins).some((k) => k.startsWith("pitroom@"));
6026
6263
  } catch {
6027
6264
  return false;
@@ -6038,24 +6275,24 @@ function superpowersActive() {
6038
6275
  for (const p of openCodePlugins()) {
6039
6276
  if (/superpowers/i.test(p)) found.push(`OpenCode plugin ${p}`);
6040
6277
  }
6041
- for (const base2 of [path26.join(os10.homedir(), ".agents", "skills"), path26.join(os10.homedir(), ".claude", "skills")]) {
6042
- const dir = path26.join(base2, "using-superpowers");
6043
- if (fs30.existsSync(path26.join(dir, "SKILL.md"))) found.push(dir);
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);
6044
6281
  }
6045
6282
  return found;
6046
6283
  }
6047
6284
  function claudePlugins() {
6048
6285
  try {
6049
- const f = path26.join(os10.homedir(), ".claude", "plugins", "installed_plugins.json");
6050
- return Object.keys(JSON.parse(fs30.readFileSync(f, "utf8")).plugins ?? {});
6286
+ const f = path27.join(os11.homedir(), ".claude", "plugins", "installed_plugins.json");
6287
+ return Object.keys(JSON.parse(fs31.readFileSync(f, "utf8")).plugins ?? {});
6051
6288
  } catch {
6052
6289
  return [];
6053
6290
  }
6054
6291
  }
6055
6292
  function claudePluginEnabled(key) {
6056
6293
  try {
6057
- const f = path26.join(os10.homedir(), ".claude", "settings.json");
6058
- return JSON.parse(fs30.readFileSync(f, "utf8")).enabledPlugins?.[key] !== false;
6294
+ const f = path27.join(os11.homedir(), ".claude", "settings.json");
6295
+ return JSON.parse(fs31.readFileSync(f, "utf8")).enabledPlugins?.[key] !== false;
6059
6296
  } catch {
6060
6297
  return true;
6061
6298
  }
@@ -6064,7 +6301,7 @@ function codexPlugins() {
6064
6301
  const plugins = /* @__PURE__ */ new Map();
6065
6302
  let text;
6066
6303
  try {
6067
- text = fs30.readFileSync(path26.join(process.env.CODEX_HOME ?? path26.join(os10.homedir(), ".codex"), "config.toml"), "utf8");
6304
+ text = fs31.readFileSync(path27.join(process.env.CODEX_HOME ?? path27.join(os11.homedir(), ".codex"), "config.toml"), "utf8");
6068
6305
  } catch {
6069
6306
  return plugins;
6070
6307
  }
@@ -6082,11 +6319,11 @@ function codexPlugins() {
6082
6319
  return plugins;
6083
6320
  }
6084
6321
  function openCodePlugins() {
6085
- const dir = path26.join(process.env.XDG_CONFIG_HOME ?? path26.join(os10.homedir(), ".config"), "opencode");
6322
+ const dir = path27.join(process.env.XDG_CONFIG_HOME ?? path27.join(os11.homedir(), ".config"), "opencode");
6086
6323
  const plugins = [];
6087
6324
  for (const name of ["opencode.json", "opencode.jsonc"]) {
6088
6325
  try {
6089
- const text = fs30.readFileSync(path26.join(dir, name), "utf8").replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, "");
6326
+ const text = fs31.readFileSync(path27.join(dir, name), "utf8").replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, "");
6090
6327
  const list2 = JSON.parse(text).plugin;
6091
6328
  if (Array.isArray(list2)) plugins.push(...list2.filter((p) => typeof p === "string"));
6092
6329
  } catch {
@@ -6094,7 +6331,7 @@ function openCodePlugins() {
6094
6331
  }
6095
6332
  for (const folder of ["plugin", "plugins"]) {
6096
6333
  try {
6097
- for (const f of fs30.readdirSync(path26.join(dir, folder))) plugins.push(path26.join(dir, folder, f));
6334
+ for (const f of fs31.readdirSync(path27.join(dir, folder))) plugins.push(path27.join(dir, folder, f));
6098
6335
  } catch {
6099
6336
  }
6100
6337
  }
@@ -6154,7 +6391,10 @@ Usage
6154
6391
  writes it only with --yes (models are suggested, never chosen for you)
6155
6392
  pitroom install [--copy] [--force] link the skills into ~/.agents/skills + ~/.claude/skills,
6156
6393
  and the CLI into ~/.local/bin
6157
- pitroom uninstall remove what install linked
6394
+ pitroom install --mcp [--client A,B] [--no-skills] [--dry-run]
6395
+ also register "pitroom mcp" in the MCP clients found (Claude Code, Codex,
6396
+ Gemini CLI, Cursor, Claude Desktop); --dry-run only says what it would do
6397
+ pitroom uninstall remove what install linked (and the MCP registrations)
6158
6398
  pitroom clean [--days 14] [--yes]
6159
6399
 
6160
6400
  Run options
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pitroom",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "A free pit crew for your expensive coding agent: hand reading, fixes, tests and reviews to cheap worker agents and get back the answer, the exact diff and a receipt. Fourteen skills adapt a full development workflow. Needs the pitroom command (npm i -g pitroom).",
5
5
  "contextFileName": "gemini-context.md"
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pitroom",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "A free pit crew for your expensive coding agent: the superpowers development workflow, run by cheap workers. Delegate research, fixes, tests, reviews and whole plans from Claude Code, Codex or any agent to OpenCode, Codex, Claude Code or Gemini CLI workers, with receipts that prove the savings.",
5
5
  "type": "module",
6
6
  "author": "TahaAtas",