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