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