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