pitroom 0.6.4 → 0.6.6
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 +48 -0
- package/PRIVACY.md +28 -12
- package/README.md +234 -79
- package/THIRD_PARTY_NOTICES.md +44 -0
- package/dist/lib.mjs +70 -6
- package/dist/pitroom.mjs +1421 -469
- package/dist/ui/app.css +2 -0
- package/dist/ui/app.js +20 -0
- package/docs/backends.md +1 -1
- package/hooks/session-start.mjs +3 -2
- package/package.json +20 -4
- package/skills/pitroom-crew/SKILL.md +4 -3
- package/skills/pitroom-writing-plans/SKILL.md +1 -1
- package/skills/using-pitroom/SKILL.md +5 -1
package/dist/pitroom.mjs
CHANGED
|
@@ -57,11 +57,14 @@ function resolveCommand(bin) {
|
|
|
57
57
|
return { command: bin, prefix: [] };
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
// src/backends/types.ts
|
|
61
|
+
var MAX_STEPS = 400;
|
|
62
|
+
|
|
60
63
|
// src/backends/claude/events.ts
|
|
61
64
|
var EDIT_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
62
65
|
var DENIED = /permission|not allowed|denied|blocked|disallowed/i;
|
|
63
66
|
function parseEvents(jsonl) {
|
|
64
|
-
const
|
|
67
|
+
const usage2 = {
|
|
65
68
|
input: 0,
|
|
66
69
|
output: 0,
|
|
67
70
|
reasoning: 0,
|
|
@@ -75,6 +78,8 @@ function parseEvents(jsonl) {
|
|
|
75
78
|
const tools = {};
|
|
76
79
|
const edits = [];
|
|
77
80
|
const pendingEdits = /* @__PURE__ */ new Map();
|
|
81
|
+
const timeline = [];
|
|
82
|
+
const stepOf = /* @__PURE__ */ new Map();
|
|
78
83
|
const steps = /* @__PURE__ */ new Set();
|
|
79
84
|
let sessionId;
|
|
80
85
|
let model;
|
|
@@ -105,34 +110,42 @@ function parseEvents(jsonl) {
|
|
|
105
110
|
if (c.type === "text" && String(c.text ?? "").trim()) {
|
|
106
111
|
lastText = String(c.text).trim();
|
|
107
112
|
lastActivity = `says: ${oneLine(lastText)}`;
|
|
113
|
+
if (timeline.length < MAX_STEPS) timeline.push({ kind: "say", text: clip(lastText, 600) });
|
|
108
114
|
} else if (c.type === "tool_use") {
|
|
109
|
-
|
|
115
|
+
usage2.toolCalls++;
|
|
110
116
|
tools[c.name] = (tools[c.name] ?? 0) + 1;
|
|
111
117
|
const target = c.input?.file_path ?? c.input?.path ?? c.input?.pattern ?? c.input?.command ?? c.input?.url;
|
|
112
118
|
lastActivity = `${c.name} ${target ? oneLine(String(target), 60) : ""}`.trim();
|
|
113
119
|
if (EDIT_TOOLS.has(c.name) && c.input?.file_path) pendingEdits.set(c.id, String(c.input.file_path));
|
|
120
|
+
if (timeline.length < MAX_STEPS) {
|
|
121
|
+
const step = { kind: EDIT_TOOLS.has(c.name) ? "edit" : c.name === "Bash" ? "shell" : "tool", name: c.name, text: clip(String(target ?? ""), 240) };
|
|
122
|
+
timeline.push(step);
|
|
123
|
+
stepOf.set(String(c.id), step);
|
|
124
|
+
}
|
|
114
125
|
}
|
|
115
126
|
}
|
|
116
127
|
} else if (e.type === "user") {
|
|
117
128
|
for (const c of e.message?.content ?? []) {
|
|
118
129
|
if (c?.type !== "tool_result") continue;
|
|
119
130
|
const text = typeof c.content === "string" ? c.content : JSON.stringify(c.content ?? "");
|
|
131
|
+
const step = stepOf.get(String(c.tool_use_id));
|
|
132
|
+
if (step) step.ok = !c.is_error;
|
|
120
133
|
if (c.is_error) {
|
|
121
|
-
if (DENIED.test(text))
|
|
134
|
+
if (DENIED.test(text)) usage2.denied++;
|
|
122
135
|
} else if (pendingEdits.has(c.tool_use_id)) {
|
|
123
136
|
edits.push(pendingEdits.get(c.tool_use_id));
|
|
124
137
|
}
|
|
125
138
|
}
|
|
126
139
|
} else if (e.type === "result") {
|
|
127
140
|
const u = e.usage ?? {};
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
141
|
+
usage2.input += num(u.input_tokens);
|
|
142
|
+
usage2.cacheRead += num(u.cache_read_input_tokens);
|
|
143
|
+
usage2.cacheWrite += num(u.cache_creation_input_tokens);
|
|
144
|
+
usage2.reasoning += num(u.output_tokens_details?.thinking_tokens);
|
|
145
|
+
usage2.output += Math.max(0, num(u.output_tokens) - num(u.output_tokens_details?.thinking_tokens));
|
|
146
|
+
usage2.total += num(u.input_tokens) + num(u.cache_read_input_tokens) + num(u.cache_creation_input_tokens) + num(u.output_tokens);
|
|
147
|
+
usage2.cost = (usage2.cost ?? 0) + num(e.total_cost_usd);
|
|
148
|
+
usage2.denied = Math.max(usage2.denied, Array.isArray(e.permission_denials) ? e.permission_denials.length : 0);
|
|
136
149
|
if (e.is_error) {
|
|
137
150
|
const tags = [e.terminal_reason, e.api_error_status && `HTTP ${e.api_error_status}`].filter(Boolean);
|
|
138
151
|
error ??= `${String(e.result ?? e.subtype ?? "Claude Code error")}${tags.length ? ` [${tags.join(", ")}]` : ""}`;
|
|
@@ -141,9 +154,10 @@ function parseEvents(jsonl) {
|
|
|
141
154
|
}
|
|
142
155
|
}
|
|
143
156
|
}
|
|
144
|
-
|
|
145
|
-
return { sessionId, model, finalText: finalText ?? (error ? "" : lastText), usage, tools, edits, lastActivity, error };
|
|
157
|
+
usage2.steps = steps.size;
|
|
158
|
+
return { sessionId, model, finalText: finalText ?? (error ? "" : lastText), usage: usage2, tools, edits, lastActivity, timeline, error };
|
|
146
159
|
}
|
|
160
|
+
var clip = (s, n) => s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
|
|
147
161
|
function num(v) {
|
|
148
162
|
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
149
163
|
}
|
|
@@ -231,7 +245,9 @@ function invocation(req) {
|
|
|
231
245
|
"stream-json",
|
|
232
246
|
"--verbose"
|
|
233
247
|
);
|
|
234
|
-
|
|
248
|
+
const [model, effort] = (req.model ?? "").split("#");
|
|
249
|
+
if (model) args.push("--model", model);
|
|
250
|
+
if (effort) args.push("--effort", effort);
|
|
235
251
|
if (req.sessionId) args.push("--resume", req.sessionId);
|
|
236
252
|
args.push("--", req.prompt);
|
|
237
253
|
const { command, prefix } = resolveCommand(binary());
|
|
@@ -284,11 +300,19 @@ function doctor({ models, hasFallback }) {
|
|
|
284
300
|
if (!hasFallback) checks.push({ level: "warn", message: "no fallback workers configured for Claude Code runs" });
|
|
285
301
|
return checks;
|
|
286
302
|
}
|
|
303
|
+
var CLAUDE_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
304
|
+
function catalog() {
|
|
305
|
+
const def = defaultModel();
|
|
306
|
+
const models = ["haiku", "sonnet", "opus"].map((id) => ({ id, efforts: CLAUDE_EFFORTS }));
|
|
307
|
+
if (def && !models.some((m) => m.id === def)) models.push({ id: def, efforts: CLAUDE_EFFORTS });
|
|
308
|
+
return { models, source: "Claude Code aliases (latest of each size) and --effort levels from `claude --help`" };
|
|
309
|
+
}
|
|
287
310
|
var claude = {
|
|
288
311
|
id: "claude",
|
|
289
312
|
name: "Claude Code",
|
|
290
313
|
capabilities: { readOnly: "tool-allowlist", resume: "by-id", reportsCost: true, attachFiles: false },
|
|
291
314
|
binary,
|
|
315
|
+
catalog,
|
|
292
316
|
invocation,
|
|
293
317
|
parse: parseEvents,
|
|
294
318
|
failure,
|
|
@@ -306,7 +330,7 @@ import path3 from "node:path";
|
|
|
306
330
|
var WORK = /* @__PURE__ */ new Set(["agent_message", "reasoning", "command_execution", "file_change", "mcp_tool_call", "web_search", "todo_list"]);
|
|
307
331
|
var TOOLS = /* @__PURE__ */ new Set(["command_execution", "file_change", "mcp_tool_call", "web_search"]);
|
|
308
332
|
function parseEvents2(jsonl) {
|
|
309
|
-
const
|
|
333
|
+
const usage2 = {
|
|
310
334
|
input: 0,
|
|
311
335
|
output: 0,
|
|
312
336
|
reasoning: 0,
|
|
@@ -319,6 +343,7 @@ function parseEvents2(jsonl) {
|
|
|
319
343
|
};
|
|
320
344
|
const tools = {};
|
|
321
345
|
const edits = [];
|
|
346
|
+
const timeline = [];
|
|
322
347
|
let sessionId;
|
|
323
348
|
let finalText = "";
|
|
324
349
|
let lastActivity;
|
|
@@ -338,35 +363,38 @@ function parseEvents2(jsonl) {
|
|
|
338
363
|
case "item.completed": {
|
|
339
364
|
const it = e.item ?? {};
|
|
340
365
|
if (!WORK.has(it.type)) break;
|
|
341
|
-
|
|
366
|
+
usage2.steps++;
|
|
342
367
|
if (TOOLS.has(it.type)) {
|
|
343
|
-
|
|
368
|
+
usage2.toolCalls++;
|
|
344
369
|
tools[it.type] = (tools[it.type] ?? 0) + 1;
|
|
345
370
|
}
|
|
346
371
|
if (it.type === "agent_message" && String(it.text ?? "").trim()) {
|
|
347
372
|
finalText = String(it.text).trim();
|
|
348
373
|
lastActivity = `says: ${oneLine2(finalText)}`;
|
|
374
|
+
if (timeline.length < MAX_STEPS) timeline.push({ kind: "say", text: clip2(finalText, 600) });
|
|
349
375
|
} else if (it.type === "command_execution") {
|
|
350
|
-
if (it.status === "declined" || /operation not permitted|sandbox/i.test(String(it.aggregated_output ?? "")))
|
|
376
|
+
if (it.status === "declined" || /operation not permitted|sandbox/i.test(String(it.aggregated_output ?? ""))) usage2.denied++;
|
|
351
377
|
lastActivity = `shell ${oneLine2(String(it.command ?? ""), 60)}`;
|
|
378
|
+
if (timeline.length < MAX_STEPS) timeline.push({ kind: "shell", name: "shell", text: clip2(String(it.command ?? "").replace(/^\/bin\/\w+ -lc /, ""), 240), ok: it.exit_code === 0 });
|
|
352
379
|
} else if (it.type === "file_change") {
|
|
353
380
|
const paths = (it.changes ?? []).map((c) => String(c.path));
|
|
354
381
|
if (it.status !== "failed" && it.status !== "declined") edits.push(...paths);
|
|
355
|
-
else
|
|
382
|
+
else usage2.denied++;
|
|
356
383
|
lastActivity = `edit ${oneLine2(paths.join(", "), 60)}`;
|
|
384
|
+
if (timeline.length < MAX_STEPS) timeline.push({ kind: "edit", name: "edit", text: clip2(paths.join(", "), 240), ok: it.status !== "failed" && it.status !== "declined" });
|
|
357
385
|
}
|
|
358
386
|
break;
|
|
359
387
|
}
|
|
360
388
|
case "turn.completed": {
|
|
361
389
|
const u = e.usage ?? {};
|
|
362
|
-
const
|
|
390
|
+
const cached3 = num2(u.cached_input_tokens);
|
|
363
391
|
const reasoning = num2(u.reasoning_output_tokens);
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
392
|
+
usage2.input += Math.max(0, num2(u.input_tokens) - cached3);
|
|
393
|
+
usage2.cacheRead += cached3;
|
|
394
|
+
usage2.cacheWrite += num2(u.cache_write_input_tokens);
|
|
395
|
+
usage2.output += Math.max(0, num2(u.output_tokens) - reasoning);
|
|
396
|
+
usage2.reasoning += reasoning;
|
|
397
|
+
usage2.total += num2(u.input_tokens) + num2(u.output_tokens);
|
|
370
398
|
break;
|
|
371
399
|
}
|
|
372
400
|
case "error":
|
|
@@ -377,7 +405,7 @@ function parseEvents2(jsonl) {
|
|
|
377
405
|
break;
|
|
378
406
|
}
|
|
379
407
|
}
|
|
380
|
-
return { sessionId, finalText, usage, tools, edits, lastActivity, error };
|
|
408
|
+
return { sessionId, finalText, usage: usage2, tools, edits, lastActivity, timeline, error };
|
|
381
409
|
}
|
|
382
410
|
function describe(raw) {
|
|
383
411
|
const s = String(raw ?? "unknown Codex error");
|
|
@@ -397,6 +425,7 @@ function oneLine2(s, max = 80) {
|
|
|
397
425
|
const t = s.replace(/\s+/g, " ").trim();
|
|
398
426
|
return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
|
|
399
427
|
}
|
|
428
|
+
var clip2 = (s, n) => s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
|
|
400
429
|
|
|
401
430
|
// src/backends/codex/index.ts
|
|
402
431
|
var binary2 = () => findBinary("codex", "PITROOM_CODEX_BIN");
|
|
@@ -482,17 +511,35 @@ function doctor2({ models, hasFallback }) {
|
|
|
482
511
|
login.ok && /logged in/i.test(login.out) ? { level: "ok", message: `Codex: ${login.out.trim().split("\n")[0]}` } : { level: "fail", message: "Codex is not logged in: run `codex login`" }
|
|
483
512
|
);
|
|
484
513
|
for (const m of models) {
|
|
514
|
+
const pinned = m?.replace(/#.*$/, "");
|
|
485
515
|
checks.push({
|
|
486
|
-
level: "ok",
|
|
487
|
-
message:
|
|
516
|
+
level: pinned ? "ok" : "warn",
|
|
517
|
+
message: pinned ? `Codex model: ${pinned} (checked on first use; unsupported models fail over)` : 'Codex model: none pinned, so Codex runs its own default (it can change and cost more); set "models": {"codex": "<model>"} or use -W codex:<model>'
|
|
488
518
|
});
|
|
489
519
|
}
|
|
490
520
|
if (!hasFallback) checks.push({ level: "warn", message: "no fallback workers configured for Codex runs" });
|
|
491
521
|
return checks;
|
|
492
522
|
}
|
|
523
|
+
function catalog2() {
|
|
524
|
+
const file = path3.join(codexHome(), "models_cache.json");
|
|
525
|
+
try {
|
|
526
|
+
const raw = JSON.parse(fs3.readFileSync(file, "utf8"));
|
|
527
|
+
const models = (raw.models ?? []).filter((m) => m.visibility !== "hide" && typeof m.slug === "string").map((m) => ({
|
|
528
|
+
id: m.slug,
|
|
529
|
+
efforts: (m.supported_reasoning_levels ?? []).map(
|
|
530
|
+
(l) => typeof l === "string" ? l : l.effort ?? ""
|
|
531
|
+
).filter(Boolean),
|
|
532
|
+
defaultEffort: typeof m.default_reasoning_level === "string" ? m.default_reasoning_level : void 0
|
|
533
|
+
}));
|
|
534
|
+
return { models, source: `Codex model cache${raw.fetched_at ? `, fetched ${raw.fetched_at.slice(0, 10)}` : ""}` };
|
|
535
|
+
} catch {
|
|
536
|
+
return { models: [], source: "no Codex model cache found (run codex once)" };
|
|
537
|
+
}
|
|
538
|
+
}
|
|
493
539
|
var codex = {
|
|
494
540
|
id: "codex",
|
|
495
541
|
name: "Codex",
|
|
542
|
+
catalog: catalog2,
|
|
496
543
|
capabilities: { readOnly: "os-sandbox", resume: "by-id", reportsCost: false, attachFiles: false },
|
|
497
544
|
binary: binary2,
|
|
498
545
|
invocation: invocation2,
|
|
@@ -509,7 +556,7 @@ import { spawnSync as spawnSync3 } from "node:child_process";
|
|
|
509
556
|
var EDIT_TOOLS2 = /* @__PURE__ */ new Set(["edit", "write", "patch", "multiedit", "apply_patch"]);
|
|
510
557
|
var DENIED2 = /rule which prevents you|permission denied|permission\.rejected/i;
|
|
511
558
|
function parseEvents3(ndjson) {
|
|
512
|
-
const
|
|
559
|
+
const usage2 = {
|
|
513
560
|
input: 0,
|
|
514
561
|
output: 0,
|
|
515
562
|
reasoning: 0,
|
|
@@ -523,6 +570,7 @@ function parseEvents3(ndjson) {
|
|
|
523
570
|
};
|
|
524
571
|
const tools = {};
|
|
525
572
|
const edits = [];
|
|
573
|
+
const timeline = [];
|
|
526
574
|
const textByMessage = /* @__PURE__ */ new Map();
|
|
527
575
|
let sessionId;
|
|
528
576
|
let lastActivity;
|
|
@@ -545,30 +593,36 @@ function parseEvents3(ndjson) {
|
|
|
545
593
|
if (!textByMessage.has(key)) textByMessage.set(key, []);
|
|
546
594
|
textByMessage.get(key).push(text);
|
|
547
595
|
lastActivity = `says: ${oneLine3(text)}`;
|
|
596
|
+
if (timeline.length < MAX_STEPS) timeline.push({ kind: "say", text: clip3(text.trim(), 600), at: stamp(e.timestamp) });
|
|
548
597
|
break;
|
|
549
598
|
}
|
|
550
599
|
case "tool_use": {
|
|
551
600
|
const name = String(p.tool ?? "tool");
|
|
552
601
|
const st = p.state ?? {};
|
|
553
|
-
|
|
602
|
+
usage2.toolCalls++;
|
|
554
603
|
tools[name] = (tools[name] ?? 0) + 1;
|
|
555
|
-
if (st.status === "error" && DENIED2.test(String(st.error ?? "")))
|
|
604
|
+
if (st.status === "error" && DENIED2.test(String(st.error ?? ""))) usage2.denied++;
|
|
556
605
|
if (st.status === "completed" && EDIT_TOOLS2.has(name)) {
|
|
557
606
|
edits.push(String(st.input?.filePath ?? st.input?.path ?? name));
|
|
558
607
|
}
|
|
559
608
|
lastActivity = `${name} ${describe2(st.input)}`.trim();
|
|
609
|
+
if (timeline.length < MAX_STEPS) {
|
|
610
|
+
const kind2 = EDIT_TOOLS2.has(name) ? "edit" : /^(bash|shell)$/.test(name) ? "shell" : "tool";
|
|
611
|
+
const text = kind2 === "shell" ? String(st.input?.command ?? "") : String(st.input?.filePath ?? st.input?.path ?? st.input?.pattern ?? st.input?.url ?? "") || describe2(st.input);
|
|
612
|
+
timeline.push({ kind: kind2, name, text: clip3(text, 240), ok: st.status === "completed" ? true : st.status === "error" ? false : void 0, at: stamp(e.timestamp) });
|
|
613
|
+
}
|
|
560
614
|
break;
|
|
561
615
|
}
|
|
562
616
|
case "step_finish": {
|
|
563
617
|
const t = p.tokens ?? {};
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
618
|
+
usage2.steps++;
|
|
619
|
+
usage2.input += num3(t.input);
|
|
620
|
+
usage2.output += num3(t.output);
|
|
621
|
+
usage2.reasoning += num3(t.reasoning);
|
|
622
|
+
usage2.cacheRead += num3(t.cache?.read);
|
|
623
|
+
usage2.cacheWrite += num3(t.cache?.write);
|
|
624
|
+
usage2.total += num3(t.total) || num3(t.input) + num3(t.output) + num3(t.reasoning) + num3(t.cache?.read);
|
|
625
|
+
usage2.cost = (usage2.cost ?? 0) + num3(p.cost);
|
|
572
626
|
break;
|
|
573
627
|
}
|
|
574
628
|
case "error": {
|
|
@@ -579,7 +633,7 @@ function parseEvents3(ndjson) {
|
|
|
579
633
|
}
|
|
580
634
|
const groups = [...textByMessage.values()];
|
|
581
635
|
const finalText = (groups[groups.length - 1] ?? []).join("\n").trim();
|
|
582
|
-
return { sessionId, finalText, usage, tools, edits, lastActivity, error };
|
|
636
|
+
return { sessionId, finalText, usage: usage2, tools, edits, lastActivity, timeline, error };
|
|
583
637
|
}
|
|
584
638
|
function describeError(err) {
|
|
585
639
|
if (!err || typeof err !== "object") return "unknown OpenCode error";
|
|
@@ -592,6 +646,8 @@ function describeError(err) {
|
|
|
592
646
|
const tags = [err.type, inner, err.status && `HTTP ${err.status}`].filter(Boolean);
|
|
593
647
|
return tags.length ? `${message} [${tags.join(", ")}]` : message;
|
|
594
648
|
}
|
|
649
|
+
var clip3 = (s, n) => s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
|
|
650
|
+
var stamp = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
|
|
595
651
|
function num3(v) {
|
|
596
652
|
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
597
653
|
}
|
|
@@ -784,15 +840,15 @@ function configContent(existing, allowWeb = false) {
|
|
|
784
840
|
}
|
|
785
841
|
}
|
|
786
842
|
};
|
|
787
|
-
let
|
|
843
|
+
let base2 = {};
|
|
788
844
|
if (existing?.trim()) {
|
|
789
845
|
try {
|
|
790
|
-
|
|
846
|
+
base2 = JSON.parse(existing);
|
|
791
847
|
} catch {
|
|
792
|
-
|
|
848
|
+
base2 = {};
|
|
793
849
|
}
|
|
794
850
|
}
|
|
795
|
-
return JSON.stringify(deepMerge(
|
|
851
|
+
return JSON.stringify(deepMerge(base2, ours));
|
|
796
852
|
}
|
|
797
853
|
function deepMerge(a, b) {
|
|
798
854
|
if (!isObject(a) || !isObject(b)) return b;
|
|
@@ -897,6 +953,13 @@ function resolveModel2(sessionId) {
|
|
|
897
953
|
return void 0;
|
|
898
954
|
}
|
|
899
955
|
}
|
|
956
|
+
function catalog3() {
|
|
957
|
+
const ids = listModels();
|
|
958
|
+
return {
|
|
959
|
+
models: (ids.length ? ids : listModels()).map((id) => ({ id })),
|
|
960
|
+
source: "`opencode models` (reasoning variants are provider-specific: provider/model#variant)"
|
|
961
|
+
};
|
|
962
|
+
}
|
|
900
963
|
function listModels() {
|
|
901
964
|
return oc(["models"]).out.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
902
965
|
}
|
|
@@ -946,6 +1009,7 @@ var opencode = {
|
|
|
946
1009
|
defaultModel: defaultModel2,
|
|
947
1010
|
resolveModel: resolveModel2,
|
|
948
1011
|
listModels,
|
|
1012
|
+
catalog: catalog3,
|
|
949
1013
|
doctor: doctor3
|
|
950
1014
|
};
|
|
951
1015
|
|
|
@@ -954,6 +1018,7 @@ var REGISTRY = new Map([opencode, codex, claude].map((b) => [b.id, b]));
|
|
|
954
1018
|
var DEFAULT_BACKEND = opencode.id;
|
|
955
1019
|
var PLANNED = ["gemini"];
|
|
956
1020
|
var backendIds = () => [...REGISTRY.keys()];
|
|
1021
|
+
var allBackends = () => [...REGISTRY.values()];
|
|
957
1022
|
var isBackendId = (id) => REGISTRY.has(id) || PLANNED.includes(id);
|
|
958
1023
|
function getBackend(id) {
|
|
959
1024
|
const backend = REGISTRY.get(id);
|
|
@@ -982,12 +1047,13 @@ var SCHEMA = {
|
|
|
982
1047
|
web: "boolean",
|
|
983
1048
|
maxParallel: "number",
|
|
984
1049
|
models: "record",
|
|
985
|
-
tiers: "record"
|
|
1050
|
+
tiers: "record",
|
|
1051
|
+
costs: "numbers"
|
|
986
1052
|
};
|
|
987
1053
|
function configPath() {
|
|
988
1054
|
if (process.env.PITROOM_CONFIG) return path5.resolve(process.env.PITROOM_CONFIG);
|
|
989
|
-
const
|
|
990
|
-
return path5.join(
|
|
1055
|
+
const base2 = process.platform === "win32" ? process.env.APPDATA ?? path5.join(os5.homedir(), "AppData", "Roaming") : process.env.XDG_CONFIG_HOME ?? path5.join(os5.homedir(), ".config");
|
|
1056
|
+
return path5.join(base2, "pitroom", "config.json");
|
|
991
1057
|
}
|
|
992
1058
|
var cached;
|
|
993
1059
|
function loadConfig() {
|
|
@@ -1023,8 +1089,14 @@ function matches(v, type) {
|
|
|
1023
1089
|
if (type === "record") {
|
|
1024
1090
|
return typeof v === "object" && v !== null && !Array.isArray(v) && Object.values(v).every((s) => typeof s === "string");
|
|
1025
1091
|
}
|
|
1092
|
+
if (type === "numbers") {
|
|
1093
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) && Object.values(v).every((n) => typeof n === "number" && Number.isFinite(n) && n >= 0);
|
|
1094
|
+
}
|
|
1026
1095
|
return typeof v === type;
|
|
1027
1096
|
}
|
|
1097
|
+
var DEFAULT_PARALLEL = 20;
|
|
1098
|
+
var MAX_PARALLEL_LIMIT = 30;
|
|
1099
|
+
var clampParallel = (s) => ({ ...s, value: Math.min(s.value, MAX_PARALLEL_LIMIT) });
|
|
1028
1100
|
var positiveInt = (v) => {
|
|
1029
1101
|
const n = Number(v);
|
|
1030
1102
|
return v !== void 0 && v !== "" && Number.isInteger(n) && n > 0 ? n : void 0;
|
|
@@ -1049,9 +1121,10 @@ function effective(flags = {}) {
|
|
|
1049
1121
|
price: setting(void 0, e.PITROOM_PRICE, c.price, void 0),
|
|
1050
1122
|
link: setting(void 0, void 0, c.link, []),
|
|
1051
1123
|
web: setting(void 0, void 0, c.web, false),
|
|
1052
|
-
maxParallel: setting(void 0, positiveInt(e.PITROOM_MAX_PARALLEL), positiveInt(c.maxParallel),
|
|
1124
|
+
maxParallel: clampParallel(setting(void 0, positiveInt(e.PITROOM_MAX_PARALLEL), positiveInt(c.maxParallel), DEFAULT_PARALLEL)),
|
|
1053
1125
|
models: setting(void 0, void 0, c.models, {}),
|
|
1054
|
-
tiers: setting(void 0, void 0, c.tiers, {})
|
|
1126
|
+
tiers: setting(void 0, void 0, c.tiers, {}),
|
|
1127
|
+
costs: setting(void 0, void 0, c.costs, {})
|
|
1055
1128
|
};
|
|
1056
1129
|
}
|
|
1057
1130
|
|
|
@@ -1076,8 +1149,14 @@ var runDir = (id) => path6.join(runsDir(), id);
|
|
|
1076
1149
|
var runFile = (id, name) => path6.join(runDir(id), name);
|
|
1077
1150
|
function newRunId(now = /* @__PURE__ */ new Date()) {
|
|
1078
1151
|
const p = (n) => String(n).padStart(2, "0");
|
|
1079
|
-
const
|
|
1080
|
-
return `${
|
|
1152
|
+
const stamp2 = `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
|
|
1153
|
+
return `${stamp2}-${crypto.randomBytes(2).toString("hex")}`;
|
|
1154
|
+
}
|
|
1155
|
+
var onFinished;
|
|
1156
|
+
var archive;
|
|
1157
|
+
function useArchive(hooks) {
|
|
1158
|
+
onFinished = hooks.onFinished;
|
|
1159
|
+
archive = { meta: hooks.meta, id: hooks.id };
|
|
1081
1160
|
}
|
|
1082
1161
|
function writeMeta(meta) {
|
|
1083
1162
|
fs6.mkdirSync(runDir(meta.id), { recursive: true });
|
|
@@ -1085,9 +1164,20 @@ function writeMeta(meta) {
|
|
|
1085
1164
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
1086
1165
|
fs6.writeFileSync(tmp, JSON.stringify(meta, null, 2));
|
|
1087
1166
|
fs6.renameSync(tmp, file);
|
|
1167
|
+
if (TERMINAL.includes(meta.state)) {
|
|
1168
|
+
try {
|
|
1169
|
+
onFinished?.(meta);
|
|
1170
|
+
} catch {
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1088
1173
|
}
|
|
1089
1174
|
function readMeta(id) {
|
|
1090
|
-
|
|
1175
|
+
const file = runFile(id, "meta.json");
|
|
1176
|
+
if (!fs6.existsSync(file)) {
|
|
1177
|
+
const kept = archive?.meta(id);
|
|
1178
|
+
if (kept) return upgrade(kept);
|
|
1179
|
+
}
|
|
1180
|
+
return upgrade(JSON.parse(fs6.readFileSync(file, "utf8")));
|
|
1091
1181
|
}
|
|
1092
1182
|
function upgrade(raw) {
|
|
1093
1183
|
if (!raw.worker) raw.worker = raw.model ? { backend: "opencode", model: raw.model } : { backend: "opencode" };
|
|
@@ -1105,12 +1195,18 @@ function listRunIds() {
|
|
|
1105
1195
|
}
|
|
1106
1196
|
function resolveRun(ref) {
|
|
1107
1197
|
const ids = listRunIds();
|
|
1108
|
-
if (!
|
|
1109
|
-
|
|
1198
|
+
if (!ref || ref === "latest" || ref === "last") {
|
|
1199
|
+
if (!ids.length) throw new UserError("no runs yet");
|
|
1200
|
+
return ids[ids.length - 1];
|
|
1201
|
+
}
|
|
1110
1202
|
if (ids.includes(ref)) return ref;
|
|
1111
1203
|
const hits = ids.filter((id) => id.startsWith(ref) || id.endsWith(ref));
|
|
1112
1204
|
if (hits.length === 1) return hits[0];
|
|
1113
|
-
|
|
1205
|
+
if (!hits.length) {
|
|
1206
|
+
const kept = archive?.id(ref);
|
|
1207
|
+
if (kept) return kept;
|
|
1208
|
+
}
|
|
1209
|
+
throw new UserError(hits.length ? `ambiguous run "${ref}": ${hits.join(", ")}` : ids.length ? `unknown run "${ref}"` : "no runs yet");
|
|
1114
1210
|
}
|
|
1115
1211
|
function isAlive(pid) {
|
|
1116
1212
|
if (!pid) return false;
|
|
@@ -1143,6 +1239,7 @@ var VALUE_FLAGS = {
|
|
|
1143
1239
|
"-W": "worker",
|
|
1144
1240
|
"--worker": "worker",
|
|
1145
1241
|
"--tier": "tier",
|
|
1242
|
+
"--effort": "effort",
|
|
1146
1243
|
"-g": "group",
|
|
1147
1244
|
"--group": "group",
|
|
1148
1245
|
"-t": "timeout",
|
|
@@ -1158,6 +1255,10 @@ var VALUE_FLAGS = {
|
|
|
1158
1255
|
"--interval": "interval",
|
|
1159
1256
|
"--range": "range",
|
|
1160
1257
|
"--then": "then",
|
|
1258
|
+
"--port": "port",
|
|
1259
|
+
"--idle": "idle",
|
|
1260
|
+
"--state": "state",
|
|
1261
|
+
"--limit": "limit",
|
|
1161
1262
|
"--plan": "plan",
|
|
1162
1263
|
"--step": "step"
|
|
1163
1264
|
};
|
|
@@ -1179,12 +1280,18 @@ var BOOL_FLAGS = {
|
|
|
1179
1280
|
"--badge": "badge",
|
|
1180
1281
|
"--probe": "probe",
|
|
1181
1282
|
"--copy": "copy",
|
|
1283
|
+
"--all": "all",
|
|
1284
|
+
"--models": "models",
|
|
1182
1285
|
"--force": "force",
|
|
1183
1286
|
"--allow-delete": "allow-delete",
|
|
1184
1287
|
"--yes": "yes",
|
|
1185
1288
|
"--any": "any",
|
|
1186
1289
|
"--brief": "brief",
|
|
1187
1290
|
"--running": "running",
|
|
1291
|
+
"--detach": "detach",
|
|
1292
|
+
"--stop": "stop",
|
|
1293
|
+
"--open": "open",
|
|
1294
|
+
"--serve": "serve",
|
|
1188
1295
|
"-h": "help",
|
|
1189
1296
|
"--help": "help",
|
|
1190
1297
|
"-v": "version",
|
|
@@ -1258,6 +1365,7 @@ function runOptions(p, task) {
|
|
|
1258
1365
|
worker: flag(p, "worker"),
|
|
1259
1366
|
model: flag(p, "model"),
|
|
1260
1367
|
tier: flag(p, "tier"),
|
|
1368
|
+
effort: effortFlag(p),
|
|
1261
1369
|
timeoutSec: parseDuration(effective({ timeout: flag(p, "timeout") }).timeout.value),
|
|
1262
1370
|
verify: flag(p, "verify"),
|
|
1263
1371
|
continueFrom: cont ? resolveRun(cont) : void 0,
|
|
@@ -1275,90 +1383,29 @@ function planStep(p) {
|
|
|
1275
1383
|
if (!/^\d+$/.test(step)) throw new UserError(`--step takes a task number, not "${step}"`);
|
|
1276
1384
|
return { file, step: Number(step) };
|
|
1277
1385
|
}
|
|
1386
|
+
function effortFlag(p) {
|
|
1387
|
+
const v = flag(p, "effort");
|
|
1388
|
+
if (v !== void 0 && !/^[a-z][a-z0-9-]*$/i.test(v)) throw new UserError(`--effort takes a level such as low, medium, high or xhigh, not "${v}"`);
|
|
1389
|
+
return v?.toLowerCase();
|
|
1390
|
+
}
|
|
1278
1391
|
|
|
1279
1392
|
// src/cli/commands.ts
|
|
1280
1393
|
import { spawnSync as spawnSync6 } from "node:child_process";
|
|
1281
|
-
import
|
|
1282
|
-
import
|
|
1394
|
+
import fs24 from "node:fs";
|
|
1395
|
+
import path20 from "node:path";
|
|
1283
1396
|
|
|
1284
|
-
// src/core/
|
|
1397
|
+
// src/core/history.ts
|
|
1285
1398
|
import fs10 from "node:fs";
|
|
1399
|
+
import { createRequire } from "node:module";
|
|
1400
|
+
import path8 from "node:path";
|
|
1401
|
+
import zlib from "node:zlib";
|
|
1286
1402
|
|
|
1287
|
-
// src/core/
|
|
1288
|
-
import
|
|
1289
|
-
import path7 from "node:path";
|
|
1290
|
-
var FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
1291
|
-
var RULE = /^ {0,3}([-*_])(\s*\1){2,}\s*$/;
|
|
1292
|
-
function structure(lines) {
|
|
1293
|
-
const headings = [];
|
|
1294
|
-
const rules = [];
|
|
1295
|
-
const fenced = [];
|
|
1296
|
-
let open;
|
|
1297
|
-
lines.forEach((l, i) => {
|
|
1298
|
-
const f = FENCE.exec(l);
|
|
1299
|
-
if (f) {
|
|
1300
|
-
fenced[i] = true;
|
|
1301
|
-
const mark = f[1];
|
|
1302
|
-
if (!open) open = mark;
|
|
1303
|
-
else if (mark[0] === open[0] && mark.length >= open.length && !f[2].trim()) open = void 0;
|
|
1304
|
-
return;
|
|
1305
|
-
}
|
|
1306
|
-
fenced[i] = !!open;
|
|
1307
|
-
if (open) return;
|
|
1308
|
-
const h = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(l);
|
|
1309
|
-
if (h) headings.push({ line: i, level: h[1].length, text: h[2] });
|
|
1310
|
-
else if (RULE.test(l)) rules.push(i);
|
|
1311
|
-
});
|
|
1312
|
-
return { headings, rules, fenced };
|
|
1313
|
-
}
|
|
1314
|
-
function parsePlan(text, file = "") {
|
|
1315
|
-
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
|
1316
|
-
const { headings, rules, fenced } = structure(lines);
|
|
1317
|
-
const after = (line) => Math.min(headings.find((h) => h.line > line)?.line ?? lines.length, rules.find((r) => r > line) ?? lines.length);
|
|
1318
|
-
const titleHeading = headings.find((h) => h.level === 1);
|
|
1319
|
-
const start = titleHeading ? titleHeading.line + 1 : 0;
|
|
1320
|
-
const header = lines.slice(start, after(start - 1)).filter((l) => !/^\s*>/.test(l)).join("\n").trim();
|
|
1321
|
-
const gc = headings.find((h) => /^global constraints\b/i.test(h.text));
|
|
1322
|
-
const constraints = gc ? lines.slice(gc.line + 1, after(gc.line)).join("\n").trim() : "";
|
|
1323
|
-
const tasks = [];
|
|
1324
|
-
for (const h of headings) {
|
|
1325
|
-
const m = /^Task\s+(\d+)\b\s*[:.)\-–—]?\s*(.*)$/i.exec(h.text);
|
|
1326
|
-
if (!m) continue;
|
|
1327
|
-
const end = headings.find((o) => o.line > h.line && o.level <= h.level)?.line ?? lines.length;
|
|
1328
|
-
const body = lines.slice(h.line, end).join("\n").replace(/(\n\s*(?:---|\*\*\*|___)\s*)+$/, "").trimEnd();
|
|
1329
|
-
const unfenced = lines.slice(h.line, end).filter((_, j) => !fenced[h.line + j]).join("\n");
|
|
1330
|
-
const tier = /^\s*[-*]?\s*\*\*Worker:\*\*\s*`?([\w-]+)`?/m.exec(unfenced)?.[1]?.toLowerCase();
|
|
1331
|
-
tasks.push({ step: Number(m[1]), title: m[2].trim(), text: body, ...tier ? { tier } : {} });
|
|
1332
|
-
}
|
|
1333
|
-
return { file, title: titleHeading?.text ?? "", header, constraints, tasks };
|
|
1334
|
-
}
|
|
1335
|
-
function loadPlan(file) {
|
|
1336
|
-
const abs = path7.resolve(file);
|
|
1337
|
-
if (!fs8.existsSync(abs) || !fs8.statSync(abs).isFile()) throw new UserError(`plan not found: ${file}`);
|
|
1338
|
-
const plan = parsePlan(fs8.readFileSync(abs, "utf8"), fs8.realpathSync(abs));
|
|
1339
|
-
if (!plan.tasks.length) throw new UserError(`${file} has no "Task N" headings (see pitroom-writing-plans)`);
|
|
1340
|
-
return plan;
|
|
1341
|
-
}
|
|
1342
|
-
function planTask(plan, step) {
|
|
1343
|
-
const t = plan.tasks.find((x) => x.step === step);
|
|
1344
|
-
if (!t) throw new UserError(`no Task ${step} in ${plan.file}; it has ${plan.tasks.map((x) => `Task ${x.step}`).join(", ")}`);
|
|
1345
|
-
return t;
|
|
1346
|
-
}
|
|
1347
|
-
function brief(plan, task) {
|
|
1348
|
-
return `${[
|
|
1349
|
-
`# ${plan.title || planName(plan.file)}`,
|
|
1350
|
-
plan.header,
|
|
1351
|
-
"## Global Constraints",
|
|
1352
|
-
plan.constraints || "(none stated in the plan)",
|
|
1353
|
-
task.text
|
|
1354
|
-
].filter(Boolean).join("\n\n")}
|
|
1355
|
-
`;
|
|
1356
|
-
}
|
|
1357
|
-
var planName = (file) => path7.basename(file).replace(/\.md$/i, "");
|
|
1403
|
+
// src/core/ui.ts
|
|
1404
|
+
import fs9 from "node:fs";
|
|
1358
1405
|
|
|
1359
1406
|
// src/core/receipt.ts
|
|
1360
|
-
import
|
|
1361
|
-
import
|
|
1407
|
+
import fs8 from "node:fs";
|
|
1408
|
+
import path7 from "node:path";
|
|
1362
1409
|
var PRESETS = {
|
|
1363
1410
|
sonnet: { name: "Claude Sonnet", input: 3, output: 15, cachedInput: 0.3 },
|
|
1364
1411
|
opus: { name: "Claude Opus", input: 5, output: 25, cachedInput: 0.5 },
|
|
@@ -1374,10 +1421,10 @@ function primaryPrice() {
|
|
|
1374
1421
|
return PRESETS[eff.primary.value.toLowerCase()] ?? PRESETS.sonnet;
|
|
1375
1422
|
}
|
|
1376
1423
|
var estimateTokens = (text) => Math.ceil(text.length / 4);
|
|
1377
|
-
function savedUsd(
|
|
1378
|
-
const wouldCost = (
|
|
1424
|
+
function savedUsd(usage2, returnedTokens, price = primaryPrice()) {
|
|
1425
|
+
const wouldCost = (usage2.input * price.input + usage2.cacheRead * price.cachedInput + (usage2.output + usage2.reasoning) * price.output) / 1e6;
|
|
1379
1426
|
const readingTheReport = returnedTokens * price.input / 1e6;
|
|
1380
|
-
return Math.max(0, wouldCost - (
|
|
1427
|
+
return Math.max(0, wouldCost - (usage2.cost ?? 0) - readingTheReport);
|
|
1381
1428
|
}
|
|
1382
1429
|
function record(meta) {
|
|
1383
1430
|
if (!meta.usage?.steps) return;
|
|
@@ -1394,13 +1441,13 @@ function record(meta) {
|
|
|
1394
1441
|
saved: meta.savedUsd ?? 0,
|
|
1395
1442
|
price: primaryPrice().name
|
|
1396
1443
|
};
|
|
1397
|
-
|
|
1398
|
-
|
|
1444
|
+
fs8.mkdirSync(path7.dirname(ledgerFile()), { recursive: true });
|
|
1445
|
+
fs8.appendFileSync(ledgerFile(), `${JSON.stringify(entry)}
|
|
1399
1446
|
`);
|
|
1400
1447
|
}
|
|
1401
1448
|
function readLedger(sinceMs2) {
|
|
1402
|
-
if (!
|
|
1403
|
-
return
|
|
1449
|
+
if (!fs8.existsSync(ledgerFile())) return [];
|
|
1450
|
+
return fs8.readFileSync(ledgerFile(), "utf8").split("\n").filter(Boolean).flatMap((l) => {
|
|
1404
1451
|
try {
|
|
1405
1452
|
return [JSON.parse(l)];
|
|
1406
1453
|
} catch {
|
|
@@ -1428,38 +1475,507 @@ function compact(n) {
|
|
|
1428
1475
|
if (n >= 1e3) return `${fmt(n / 1e3)}k`;
|
|
1429
1476
|
return String(Math.round(n));
|
|
1430
1477
|
}
|
|
1431
|
-
function badgeUrl(t) {
|
|
1432
|
-
const msg = encodeURIComponent(`saved ${usd(t.saved)} \xB7 ${compact(t.tokens)} tokens offloaded`).replace(/-/g, "--");
|
|
1433
|
-
return `https://img.shields.io/badge/pitroom-${msg}-7c3aed`;
|
|
1478
|
+
function badgeUrl(t) {
|
|
1479
|
+
const msg = encodeURIComponent(`saved ${usd(t.saved)} \xB7 ${compact(t.tokens)} tokens offloaded`).replace(/-/g, "--");
|
|
1480
|
+
return `https://img.shields.io/badge/pitroom-${msg}-7c3aed`;
|
|
1481
|
+
}
|
|
1482
|
+
var esc = (s) => s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
1483
|
+
function card(t, period) {
|
|
1484
|
+
const stat = (x, value, label) => `<text x="${x}" y="148" class="v">${esc(value)}</text><text x="${x}" y="170" class="l">${esc(label)}</text>`;
|
|
1485
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="600" height="220" viewBox="0 0 600 220" role="img" aria-label="pitroom saved ${esc(usd(t.saved))}">
|
|
1486
|
+
<style>
|
|
1487
|
+
text{font-family:ui-sans-serif,-apple-system,Segoe UI,Helvetica,Arial,sans-serif;fill:#ede9fe}
|
|
1488
|
+
.h{font-size:15px;font-weight:600;fill:#a78bfa;letter-spacing:.08em}
|
|
1489
|
+
.big{font-size:54px;font-weight:800;fill:#fff}
|
|
1490
|
+
.sub{font-size:15px;fill:#c4b5fd}
|
|
1491
|
+
.v{font-size:22px;font-weight:700;fill:#fff}
|
|
1492
|
+
.l{font-size:12px;fill:#a78bfa}
|
|
1493
|
+
.f{font-size:11px;fill:#8b5cf6}
|
|
1494
|
+
</style>
|
|
1495
|
+
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#1e1b4b"/><stop offset="1" stop-color="#3b0764"/></linearGradient></defs>
|
|
1496
|
+
<rect width="600" height="220" rx="18" fill="url(#g)"/>
|
|
1497
|
+
<text x="32" y="42" class="h">PITROOM \xB7 ${esc(period.toUpperCase())}</text>
|
|
1498
|
+
<text x="32" y="98" class="big">${esc(usd(t.saved))}</text>
|
|
1499
|
+
<text x="${Math.min(60 + usd(t.saved).length * 30, 330)}" y="98" class="sub">saved on my main coding agent</text>
|
|
1500
|
+
${stat(32, compact(t.tokens), "tokens offloaded")}
|
|
1501
|
+
${stat(200, t.ratio ? `${Math.round(t.ratio)}\xD7` : "\u2014", "context compression")}
|
|
1502
|
+
${stat(380, String(t.runs), "delegated tasks")}
|
|
1503
|
+
<text x="32" y="202" class="f">estimated vs. ${esc(primaryPrice().name)} pricing \xB7 npx pitroom</text>
|
|
1504
|
+
</svg>
|
|
1505
|
+
`;
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
// src/core/ui.ts
|
|
1509
|
+
var WEEK_MS = 7 * 24 * 3600 * 1e3;
|
|
1510
|
+
var RECENT = 40;
|
|
1511
|
+
var RECENT_HOURS = 1;
|
|
1512
|
+
var MAX_CARDS = 5;
|
|
1513
|
+
function activeRuns() {
|
|
1514
|
+
const out = [];
|
|
1515
|
+
for (const id of listRunIds().slice(-RECENT)) {
|
|
1516
|
+
try {
|
|
1517
|
+
const m = readMeta(id);
|
|
1518
|
+
if (isActive(m.state) && (m.state === "queued" || isAlive(m.pid))) out.push(m);
|
|
1519
|
+
} catch {
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
return out;
|
|
1523
|
+
}
|
|
1524
|
+
function statusLine() {
|
|
1525
|
+
const running = activeRuns();
|
|
1526
|
+
const saved = totals(readLedger(Date.now() - WEEK_MS)).saved;
|
|
1527
|
+
if (!running.length && !saved) return "";
|
|
1528
|
+
const parts = ["\u{1F3C1} pitroom"];
|
|
1529
|
+
if (running.length) {
|
|
1530
|
+
const who = [...new Set(running.map((m) => (m.ran ?? m.worker).backend))].join(", ");
|
|
1531
|
+
parts.push(`${running.length} running (${who})`);
|
|
1532
|
+
}
|
|
1533
|
+
if (saved) parts.push(`~${usd(saved)} saved this week`);
|
|
1534
|
+
return parts.join(" \xB7 ");
|
|
1535
|
+
}
|
|
1536
|
+
var RUN_ID = /\b\d{8}-\d{6}-[0-9a-f]{4}\b/g;
|
|
1537
|
+
var PITROOM_CALL = /(^|[\s;&|(`])(\S*\/)?pitroom(\.mjs)?(\s|$)/;
|
|
1538
|
+
var ICON = { done: "\u2714", failed: "\u2718", timeout: "\u23F1", stopped: "\u25A0" };
|
|
1539
|
+
function kind(m) {
|
|
1540
|
+
if (m.reviewOf) return m.reviewKind === "range" ? "branch review" : m.reviewKind === "fix" ? "re-review" : "review";
|
|
1541
|
+
return m.mode === "read" ? "research" : m.mode === "isolate" ? "change (isolated copy)" : "change";
|
|
1542
|
+
}
|
|
1543
|
+
var SUBJECT_MAX = 48;
|
|
1544
|
+
function short(text) {
|
|
1545
|
+
const s = text.replace(/`/g, "").replace(/^#+\s*/, "").replace(/\s+/g, " ").trim();
|
|
1546
|
+
if (s.length <= SUBJECT_MAX) return s;
|
|
1547
|
+
const cut = s.slice(0, SUBJECT_MAX);
|
|
1548
|
+
const space = cut.lastIndexOf(" ");
|
|
1549
|
+
return `${(space > SUBJECT_MAX / 2 ? cut.slice(0, space) : cut).replace(/[\s,.;:·-]+$/, "")}\u2026`;
|
|
1550
|
+
}
|
|
1551
|
+
var MODEL_MAX = 24;
|
|
1552
|
+
function workerName(t) {
|
|
1553
|
+
if (!t.model) return t.backend;
|
|
1554
|
+
const model = (t.model.split("/").pop() ?? t.model).replace(/-(contributor-)?free$/, "").replace(/-\d+\.\d+$/, "");
|
|
1555
|
+
return `${t.backend} (${model.length > MODEL_MAX ? `${model.slice(0, MODEL_MAX - 1)}\u2026` : model})`;
|
|
1556
|
+
}
|
|
1557
|
+
function what(m) {
|
|
1558
|
+
if (m.plan) return short(`Task ${m.plan.step}: ${m.plan.title}`);
|
|
1559
|
+
if (m.reviewOf) return `of ${m.reviewOf.replace(/\b([0-9a-f]{9})[0-9a-f]{31}\b/g, "$1")}`;
|
|
1560
|
+
const line = m.task.split("\n").find((l) => l.trim()) ?? "";
|
|
1561
|
+
const brief2 = /^You are implementing Task (\d+)\b.*?\bplan\s+(\S+)/i.exec(line);
|
|
1562
|
+
if (brief2) return short(`Task ${brief2[1]} \xB7 ${brief2[2].split("/").pop().replace(/\.md\W*$/, "")}`);
|
|
1563
|
+
return short(line);
|
|
1564
|
+
}
|
|
1565
|
+
function elapsed(m) {
|
|
1566
|
+
const s = Math.round((Date.parse(m.endedAt ?? "") - Date.parse(m.startedAt)) / 1e3);
|
|
1567
|
+
if (!Number.isFinite(s) || s < 0) return "";
|
|
1568
|
+
return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s` : `${s}s`;
|
|
1569
|
+
}
|
|
1570
|
+
var startedCard = (m) => `\u{1F3C1} Pitroom \u25B6 ${kind(m)} on ${workerName(m.worker)} \xB7 ${what(m)} (${m.id})`;
|
|
1571
|
+
function endedCard(m) {
|
|
1572
|
+
const v = m.verdict;
|
|
1573
|
+
const bits = [
|
|
1574
|
+
`\u{1F3C1} Pitroom ${ICON[m.state] ?? "\u2022"} ${kind(m)} ${m.state} on ${workerName(m.ran ?? m.worker)}`,
|
|
1575
|
+
what(m),
|
|
1576
|
+
elapsed(m),
|
|
1577
|
+
v && `SPEC ${v.spec.toUpperCase()} \xB7 QUALITY ${v.quality.toUpperCase()}`,
|
|
1578
|
+
m.changes?.length ? `${m.changes.length} file${m.changes.length === 1 ? "" : "s"} changed` : "",
|
|
1579
|
+
m.savedUsd ? `~${usd(m.savedUsd)} saved` : ""
|
|
1580
|
+
];
|
|
1581
|
+
return `${bits.filter(Boolean).join(" \xB7 ")} (${m.id})`;
|
|
1582
|
+
}
|
|
1583
|
+
function cardsFor(m) {
|
|
1584
|
+
const phases = [["started", () => startedCard(m)]];
|
|
1585
|
+
if (!isActive(m.state)) phases.push(["ended", () => endedCard(m)]);
|
|
1586
|
+
if (m.applied) {
|
|
1587
|
+
phases.push(["applied", () => `\u{1F3C1} Pitroom \u2935 applied ${m.changes?.length ?? 0} file(s) from ${m.id} to your tree`]);
|
|
1588
|
+
}
|
|
1589
|
+
const out = [];
|
|
1590
|
+
for (const [phase, text] of phases) {
|
|
1591
|
+
const marker = runFile(m.id, `card-${phase}`);
|
|
1592
|
+
if (fs9.existsSync(marker)) continue;
|
|
1593
|
+
if (!(phase === "started" && !isActive(m.state))) out.push(text());
|
|
1594
|
+
fs9.writeFileSync(marker, "");
|
|
1595
|
+
}
|
|
1596
|
+
return out;
|
|
1597
|
+
}
|
|
1598
|
+
function hookCards(input) {
|
|
1599
|
+
const event = JSON.parse(input);
|
|
1600
|
+
const command = event.tool_input?.command ?? "";
|
|
1601
|
+
if (event.tool_name !== "Bash" || !PITROOM_CALL.test(command)) return "";
|
|
1602
|
+
const response = event.tool_response;
|
|
1603
|
+
const output = typeof response === "string" ? response : [response?.stdout, response?.stderr].filter(Boolean).join("\n");
|
|
1604
|
+
const ids = [...new Set(`${command}
|
|
1605
|
+
${output}`.match(RUN_ID) ?? [])].filter((id) => fs9.existsSync(`${runsDir()}/${id}`));
|
|
1606
|
+
const since = Date.now() - RECENT_HOURS * 36e5;
|
|
1607
|
+
for (const id of listRunIds().slice(-RECENT)) {
|
|
1608
|
+
if (ids.includes(id) || fs9.existsSync(runFile(id, "card-ended"))) continue;
|
|
1609
|
+
try {
|
|
1610
|
+
const m = readMeta(id);
|
|
1611
|
+
if (!isActive(m.state) && Date.parse(m.endedAt ?? m.startedAt) > since) ids.push(id);
|
|
1612
|
+
} catch {
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
const cards = [];
|
|
1616
|
+
for (const id of ids.slice(0, MAX_CARDS)) {
|
|
1617
|
+
try {
|
|
1618
|
+
cards.push(...cardsFor(readMeta(id)));
|
|
1619
|
+
} catch {
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
return cards.join("\n");
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// src/core/history.ts
|
|
1626
|
+
var SCHEMA_VERSION = 1;
|
|
1627
|
+
var PATCH_MAX = 1e6;
|
|
1628
|
+
var ANSWER_MAX = 2e5;
|
|
1629
|
+
var cached2;
|
|
1630
|
+
var historyFile = () => path8.join(home(), "history.db");
|
|
1631
|
+
function openDb() {
|
|
1632
|
+
const file = historyFile();
|
|
1633
|
+
if (cached2?.file === file) return cached2.db;
|
|
1634
|
+
let db;
|
|
1635
|
+
try {
|
|
1636
|
+
const emit = process.emitWarning;
|
|
1637
|
+
process.emitWarning = ((w, ...rest) => /SQLite/i.test(String(w?.message ?? w)) ? void 0 : emit.call(process, w, ...rest));
|
|
1638
|
+
let DatabaseSync;
|
|
1639
|
+
try {
|
|
1640
|
+
({ DatabaseSync } = createRequire(import.meta.url)("node:sqlite"));
|
|
1641
|
+
} finally {
|
|
1642
|
+
process.emitWarning = emit;
|
|
1643
|
+
}
|
|
1644
|
+
fs10.mkdirSync(path8.dirname(file), { recursive: true });
|
|
1645
|
+
db = new DatabaseSync(file);
|
|
1646
|
+
db.exec("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; PRAGMA synchronous=NORMAL;");
|
|
1647
|
+
migrate(db);
|
|
1648
|
+
} catch {
|
|
1649
|
+
db = void 0;
|
|
1650
|
+
}
|
|
1651
|
+
cached2 = { file, db };
|
|
1652
|
+
return db;
|
|
1653
|
+
}
|
|
1654
|
+
function migrate(db) {
|
|
1655
|
+
const v = db.prepare("PRAGMA user_version").get()?.user_version ?? 0;
|
|
1656
|
+
if (v >= SCHEMA_VERSION) return;
|
|
1657
|
+
db.exec(`
|
|
1658
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
1659
|
+
id TEXT PRIMARY KEY, started_at TEXT NOT NULL, ended_at TEXT, state TEXT NOT NULL, mode TEXT, kind TEXT,
|
|
1660
|
+
backend TEXT, model TEXT, task TEXT, grp TEXT, dir TEXT, review_of TEXT, verdict TEXT,
|
|
1661
|
+
seconds INTEGER, steps INTEGER, tool_calls INTEGER, tokens INTEGER, returned_tokens INTEGER,
|
|
1662
|
+
cost REAL, saved REAL, files_changed INTEGER, applied INTEGER, error TEXT,
|
|
1663
|
+
answer TEXT, patch TEXT, meta_json TEXT NOT NULL
|
|
1664
|
+
);
|
|
1665
|
+
CREATE INDEX IF NOT EXISTS runs_started ON runs(started_at);
|
|
1666
|
+
CREATE INDEX IF NOT EXISTS runs_model ON runs(backend, model);
|
|
1667
|
+
CREATE INDEX IF NOT EXISTS runs_grp ON runs(grp);
|
|
1668
|
+
CREATE TABLE IF NOT EXISTS steps (
|
|
1669
|
+
run_id TEXT NOT NULL, n INTEGER NOT NULL, kind TEXT, name TEXT, text TEXT, ok INTEGER, at INTEGER,
|
|
1670
|
+
PRIMARY KEY (run_id, n)
|
|
1671
|
+
) WITHOUT ROWID;
|
|
1672
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS runs_fts USING fts5(id UNINDEXED, task, answer, steps);
|
|
1673
|
+
PRAGMA user_version = ${SCHEMA_VERSION};
|
|
1674
|
+
`);
|
|
1675
|
+
}
|
|
1676
|
+
function readRunFile(id, name) {
|
|
1677
|
+
const f = runFile(id, name);
|
|
1678
|
+
try {
|
|
1679
|
+
if (fs10.existsSync(f)) return fs10.readFileSync(f, "utf8");
|
|
1680
|
+
if (fs10.existsSync(`${f}.gz`)) return zlib.gunzipSync(fs10.readFileSync(`${f}.gz`)).toString("utf8");
|
|
1681
|
+
} catch {
|
|
1682
|
+
}
|
|
1683
|
+
return void 0;
|
|
1684
|
+
}
|
|
1685
|
+
function gzipFile(f) {
|
|
1686
|
+
if (!fs10.existsSync(f)) return;
|
|
1687
|
+
fs10.writeFileSync(`${f}.gz`, zlib.gzipSync(fs10.readFileSync(f)));
|
|
1688
|
+
fs10.rmSync(f);
|
|
1689
|
+
}
|
|
1690
|
+
function compactRun(meta) {
|
|
1691
|
+
if (!TERMINAL.includes(meta.state)) return;
|
|
1692
|
+
const dir = path8.dirname(runFile(meta.id, "meta.json"));
|
|
1693
|
+
let names;
|
|
1694
|
+
try {
|
|
1695
|
+
names = fs10.readdirSync(dir);
|
|
1696
|
+
} catch {
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
for (const n of names) {
|
|
1700
|
+
if (/^events(\.attempt-\d+)?\.jsonl$/.test(n)) gzipFile(path8.join(dir, n));
|
|
1701
|
+
else if (/^stderr(\.attempt-\d+)?\.log$/.test(n)) {
|
|
1702
|
+
if (meta.state === "done" && !/attempt/.test(n)) fs10.rmSync(path8.join(dir, n), { force: true });
|
|
1703
|
+
else gzipFile(path8.join(dir, n));
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
var clip4 = (s, n) => s && s.length > n ? s.slice(0, n) : s;
|
|
1708
|
+
function timelineOf(meta) {
|
|
1709
|
+
const raw = readRunFile(meta.id, "events.jsonl");
|
|
1710
|
+
if (!raw) return [];
|
|
1711
|
+
try {
|
|
1712
|
+
return getBackend((meta.ran ?? meta.worker).backend).parse(raw).timeline ?? [];
|
|
1713
|
+
} catch {
|
|
1714
|
+
return [];
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
function recordRun(meta) {
|
|
1718
|
+
if (!TERMINAL.includes(meta.state)) return;
|
|
1719
|
+
const db = openDb();
|
|
1720
|
+
if (!db) return;
|
|
1721
|
+
try {
|
|
1722
|
+
const u = meta.usage;
|
|
1723
|
+
const known = db.prepare("SELECT answer IS NOT NULL AS has FROM runs WHERE id = ?").get(meta.id);
|
|
1724
|
+
const answer = known?.has ? void 0 : clip4(readRunFile(meta.id, "summary.md")?.trim(), ANSWER_MAX);
|
|
1725
|
+
const patch = known?.has ? void 0 : clip4(readRunFile(meta.id, "changes.patch"), PATCH_MAX);
|
|
1726
|
+
const seconds = meta.endedAt ? Math.max(0, Math.round((Date.parse(meta.endedAt) - Date.parse(meta.startedAt)) / 1e3)) : null;
|
|
1727
|
+
const t = meta.ran ?? meta.worker;
|
|
1728
|
+
const fields = {
|
|
1729
|
+
id: meta.id,
|
|
1730
|
+
started_at: meta.startedAt,
|
|
1731
|
+
ended_at: meta.endedAt ?? null,
|
|
1732
|
+
state: meta.state,
|
|
1733
|
+
mode: meta.mode,
|
|
1734
|
+
kind: kind(meta),
|
|
1735
|
+
backend: t.backend,
|
|
1736
|
+
model: meta.resolvedModel ?? t.model ?? null,
|
|
1737
|
+
task: clip4(meta.task, 2e4) ?? "",
|
|
1738
|
+
grp: meta.group ?? null,
|
|
1739
|
+
dir: meta.dir,
|
|
1740
|
+
review_of: meta.reviewOf ?? null,
|
|
1741
|
+
verdict: meta.verdict ? `SPEC ${meta.verdict.spec.toUpperCase()} \xB7 QUALITY ${meta.verdict.quality.toUpperCase()}` : null,
|
|
1742
|
+
seconds,
|
|
1743
|
+
steps: u?.steps ?? null,
|
|
1744
|
+
tool_calls: u?.toolCalls ?? null,
|
|
1745
|
+
tokens: u?.total ?? null,
|
|
1746
|
+
returned_tokens: meta.returnedTokens ?? null,
|
|
1747
|
+
cost: u?.cost ?? null,
|
|
1748
|
+
saved: meta.savedUsd ?? null,
|
|
1749
|
+
files_changed: meta.changes?.length ?? 0,
|
|
1750
|
+
applied: meta.applied ? 1 : 0,
|
|
1751
|
+
error: clip4(meta.error, 2e3) ?? null,
|
|
1752
|
+
meta_json: JSON.stringify(meta)
|
|
1753
|
+
};
|
|
1754
|
+
db.exec("BEGIN IMMEDIATE");
|
|
1755
|
+
try {
|
|
1756
|
+
const cols = Object.keys(fields);
|
|
1757
|
+
db.prepare(`INSERT INTO runs (${cols.join(",")}) VALUES (${cols.map(() => "?").join(",")})
|
|
1758
|
+
ON CONFLICT(id) DO UPDATE SET ${cols.filter((c) => c !== "id").map((c) => `${c}=excluded.${c}`).join(",")}`).run(...Object.values(fields));
|
|
1759
|
+
if (!known?.has) {
|
|
1760
|
+
const steps = timelineOf(meta);
|
|
1761
|
+
db.prepare("UPDATE runs SET answer = ?, patch = ? WHERE id = ?").run(answer ?? "", patch ?? null, meta.id);
|
|
1762
|
+
db.prepare("DELETE FROM steps WHERE run_id = ?").run(meta.id);
|
|
1763
|
+
const ins = db.prepare("INSERT INTO steps (run_id, n, kind, name, text, ok, at) VALUES (?,?,?,?,?,?,?)");
|
|
1764
|
+
steps.forEach((s, n) => ins.run(meta.id, n, s.kind, s.name ?? null, s.text, s.ok === void 0 ? null : s.ok ? 1 : 0, s.at ?? null));
|
|
1765
|
+
db.prepare("DELETE FROM runs_fts WHERE id = ?").run(meta.id);
|
|
1766
|
+
db.prepare("INSERT INTO runs_fts (id, task, answer, steps) VALUES (?,?,?,?)").run(meta.id, fields.task, answer ?? "", steps.map((s) => s.text).join("\n"));
|
|
1767
|
+
}
|
|
1768
|
+
db.exec("COMMIT");
|
|
1769
|
+
} catch (e) {
|
|
1770
|
+
db.exec("ROLLBACK");
|
|
1771
|
+
throw e;
|
|
1772
|
+
}
|
|
1773
|
+
compactRun(meta);
|
|
1774
|
+
} catch {
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
function importRuns() {
|
|
1778
|
+
const db = openDb();
|
|
1779
|
+
if (!db) return { imported: 0, known: 0 };
|
|
1780
|
+
const have = new Set(db.prepare("SELECT id FROM runs").all().map((r) => r.id));
|
|
1781
|
+
let imported = 0;
|
|
1782
|
+
for (const id of listRunIds()) {
|
|
1783
|
+
if (have.has(id)) continue;
|
|
1784
|
+
try {
|
|
1785
|
+
const m = readMeta(id);
|
|
1786
|
+
if (!TERMINAL.includes(m.state)) continue;
|
|
1787
|
+
recordRun(m);
|
|
1788
|
+
imported++;
|
|
1789
|
+
} catch {
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
return { imported, known: have.size };
|
|
1793
|
+
}
|
|
1794
|
+
function archivedRun(id) {
|
|
1795
|
+
const db = openDb();
|
|
1796
|
+
if (!db) return void 0;
|
|
1797
|
+
try {
|
|
1798
|
+
const r = db.prepare("SELECT meta_json, answer, patch, started_at FROM runs WHERE id = ?").get(id);
|
|
1799
|
+
if (!r) return void 0;
|
|
1800
|
+
const start = Date.parse(r.started_at);
|
|
1801
|
+
const steps = db.prepare("SELECT kind, name, text, ok, at FROM steps WHERE run_id = ? ORDER BY n").all(id).map((s) => ({
|
|
1802
|
+
kind: s.kind,
|
|
1803
|
+
name: s.name ?? void 0,
|
|
1804
|
+
text: s.text,
|
|
1805
|
+
ok: s.ok === null ? void 0 : !!s.ok,
|
|
1806
|
+
at: s.at ?? void 0,
|
|
1807
|
+
t: s.at && Number.isFinite(start) ? Math.max(0, Math.round((s.at - start) / 1e3)) : void 0
|
|
1808
|
+
}));
|
|
1809
|
+
return { meta: JSON.parse(r.meta_json), answer: r.answer ?? "", patch: r.patch ?? void 0, steps };
|
|
1810
|
+
} catch {
|
|
1811
|
+
return void 0;
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
function archivedId(ref) {
|
|
1815
|
+
const db = openDb();
|
|
1816
|
+
if (!db || !/^[\w-]{3,}$/.test(ref)) return void 0;
|
|
1817
|
+
try {
|
|
1818
|
+
const rows = db.prepare("SELECT id FROM runs WHERE id = ? OR id LIKE ? OR id LIKE ? ORDER BY id").all(ref, `${ref}%`, `%${ref}`);
|
|
1819
|
+
return rows.length === 1 ? rows[0].id : rows.find((r) => r.id === ref)?.id;
|
|
1820
|
+
} catch {
|
|
1821
|
+
return void 0;
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
var ftsQuery = (text) => text.split(/\s+/).filter(Boolean).map((w) => `"${w.replace(/"/g, '""')}"*`).join(" ");
|
|
1825
|
+
function listHistory(q = {}) {
|
|
1826
|
+
const db = openDb();
|
|
1827
|
+
if (!db) return { rows: [], total: 0 };
|
|
1828
|
+
const where = [];
|
|
1829
|
+
const args = [];
|
|
1830
|
+
if (q.text?.trim()) {
|
|
1831
|
+
where.push("r.id IN (SELECT id FROM runs_fts WHERE runs_fts MATCH ?)");
|
|
1832
|
+
args.push(ftsQuery(q.text));
|
|
1833
|
+
}
|
|
1834
|
+
if (q.model) {
|
|
1835
|
+
where.push("r.model = ?");
|
|
1836
|
+
args.push(q.model);
|
|
1837
|
+
}
|
|
1838
|
+
if (q.backend) {
|
|
1839
|
+
where.push("r.backend = ?");
|
|
1840
|
+
args.push(q.backend);
|
|
1841
|
+
}
|
|
1842
|
+
if (q.state) {
|
|
1843
|
+
where.push(q.state === "problem" ? "r.state IN ('failed','timeout','stopped')" : "r.state = ?");
|
|
1844
|
+
if (q.state !== "problem") args.push(q.state);
|
|
1845
|
+
}
|
|
1846
|
+
if (q.group) {
|
|
1847
|
+
where.push("r.grp = ?");
|
|
1848
|
+
args.push(q.group);
|
|
1849
|
+
}
|
|
1850
|
+
if (q.sinceMs) {
|
|
1851
|
+
where.push("r.started_at >= ?");
|
|
1852
|
+
args.push(new Date(q.sinceMs).toISOString());
|
|
1853
|
+
}
|
|
1854
|
+
const base2 = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
|
1855
|
+
try {
|
|
1856
|
+
const total = db.prepare(`SELECT COUNT(*) AS n FROM runs r ${base2}`).get(...args).n;
|
|
1857
|
+
const page = q.beforeId ? `${base2 ? `${base2} AND` : "WHERE"} r.id < ?` : base2;
|
|
1858
|
+
const rows = db.prepare(`SELECT r.* FROM runs r ${page} ORDER BY r.id DESC LIMIT ?`).all(...args, ...q.beforeId ? [q.beforeId] : [], Math.min(Math.max(q.limit ?? 30, 1), 200));
|
|
1859
|
+
return {
|
|
1860
|
+
total,
|
|
1861
|
+
rows: rows.map((r) => ({
|
|
1862
|
+
id: r.id,
|
|
1863
|
+
startedAt: r.started_at,
|
|
1864
|
+
state: r.state,
|
|
1865
|
+
kind: r.kind,
|
|
1866
|
+
backend: r.backend,
|
|
1867
|
+
model: r.model ?? void 0,
|
|
1868
|
+
task: r.review_of ? `of ${r.review_of.replace(/\b([0-9a-f]{9})[0-9a-f]{31}\b/g, "$1")}` : r.task.split("\n").find((l) => l.trim()) ?? "",
|
|
1869
|
+
group: r.grp ?? void 0,
|
|
1870
|
+
verdict: r.verdict ?? void 0,
|
|
1871
|
+
seconds: r.seconds ?? void 0,
|
|
1872
|
+
steps: r.steps ?? void 0,
|
|
1873
|
+
tokens: r.tokens ?? void 0,
|
|
1874
|
+
saved: r.saved ?? void 0,
|
|
1875
|
+
files: r.files_changed ?? 0,
|
|
1876
|
+
applied: !!r.applied
|
|
1877
|
+
}))
|
|
1878
|
+
};
|
|
1879
|
+
} catch {
|
|
1880
|
+
return { rows: [], total: 0 };
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
function historyStats(sinceMs2) {
|
|
1884
|
+
const empty = { totals: { runs: 0, ok: 0, failed: 0, seconds: 0, tokens: 0, saved: 0 }, byWorker: [], byDay: [] };
|
|
1885
|
+
const db = openDb();
|
|
1886
|
+
if (!db) return empty;
|
|
1887
|
+
const since = sinceMs2 ? new Date(sinceMs2).toISOString() : "";
|
|
1888
|
+
try {
|
|
1889
|
+
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 >= ?").get(since);
|
|
1890
|
+
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 >= ? GROUP BY backend, model ORDER BY runs DESC LIMIT 40").all(since);
|
|
1891
|
+
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 >= ? GROUP BY day ORDER BY day DESC LIMIT 60").all(since);
|
|
1892
|
+
return {
|
|
1893
|
+
totals: { runs: t.runs, ok: t.ok, failed: t.runs - t.ok, seconds: t.seconds, tokens: t.tokens, saved: t.saved },
|
|
1894
|
+
byWorker: w.map((r) => ({ backend: r.backend, model: r.model ?? void 0, runs: r.runs, ok: r.ok ?? 0, avgSeconds: r.avg_s, avgTokens: r.avg_t, saved: r.saved })),
|
|
1895
|
+
byDay: d.reverse().map((r) => ({ day: r.day, runs: r.runs, ok: r.ok ?? 0, saved: r.saved }))
|
|
1896
|
+
};
|
|
1897
|
+
} catch {
|
|
1898
|
+
return empty;
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
// src/core/report.ts
|
|
1903
|
+
import fs12 from "node:fs";
|
|
1904
|
+
|
|
1905
|
+
// src/core/plan.ts
|
|
1906
|
+
import fs11 from "node:fs";
|
|
1907
|
+
import path9 from "node:path";
|
|
1908
|
+
var FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
1909
|
+
var RULE = /^ {0,3}([-*_])(\s*\1){2,}\s*$/;
|
|
1910
|
+
function structure(lines) {
|
|
1911
|
+
const headings = [];
|
|
1912
|
+
const rules = [];
|
|
1913
|
+
const fenced = [];
|
|
1914
|
+
let open;
|
|
1915
|
+
lines.forEach((l, i) => {
|
|
1916
|
+
const f = FENCE.exec(l);
|
|
1917
|
+
if (f) {
|
|
1918
|
+
fenced[i] = true;
|
|
1919
|
+
const mark = f[1];
|
|
1920
|
+
if (!open) open = mark;
|
|
1921
|
+
else if (mark[0] === open[0] && mark.length >= open.length && !f[2].trim()) open = void 0;
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
fenced[i] = !!open;
|
|
1925
|
+
if (open) return;
|
|
1926
|
+
const h = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(l);
|
|
1927
|
+
if (h) headings.push({ line: i, level: h[1].length, text: h[2] });
|
|
1928
|
+
else if (RULE.test(l)) rules.push(i);
|
|
1929
|
+
});
|
|
1930
|
+
return { headings, rules, fenced };
|
|
1931
|
+
}
|
|
1932
|
+
function parsePlan(text, file = "") {
|
|
1933
|
+
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
|
1934
|
+
const { headings, rules, fenced } = structure(lines);
|
|
1935
|
+
const after = (line) => Math.min(headings.find((h) => h.line > line)?.line ?? lines.length, rules.find((r) => r > line) ?? lines.length);
|
|
1936
|
+
const titleHeading = headings.find((h) => h.level === 1);
|
|
1937
|
+
const start = titleHeading ? titleHeading.line + 1 : 0;
|
|
1938
|
+
const header = lines.slice(start, after(start - 1)).filter((l) => !/^\s*>/.test(l)).join("\n").trim();
|
|
1939
|
+
const gc = headings.find((h) => /^global constraints\b/i.test(h.text));
|
|
1940
|
+
const constraints = gc ? lines.slice(gc.line + 1, after(gc.line)).join("\n").trim() : "";
|
|
1941
|
+
const tasks = [];
|
|
1942
|
+
for (const h of headings) {
|
|
1943
|
+
const m = /^Task\s+(\d+)\b\s*[:.)\-–—]?\s*(.*)$/i.exec(h.text);
|
|
1944
|
+
if (!m) continue;
|
|
1945
|
+
const end = headings.find((o) => o.line > h.line && o.level <= h.level)?.line ?? lines.length;
|
|
1946
|
+
const body = lines.slice(h.line, end).join("\n").replace(/(\n\s*(?:---|\*\*\*|___)\s*)+$/, "").trimEnd();
|
|
1947
|
+
const unfenced = lines.slice(h.line, end).filter((_, j) => !fenced[h.line + j]).join("\n");
|
|
1948
|
+
const tier = /^\s*[-*]?\s*\*\*Worker:\*\*\s*`?([\w-]+)`?/m.exec(unfenced)?.[1]?.toLowerCase();
|
|
1949
|
+
tasks.push({ step: Number(m[1]), title: m[2].trim(), text: body, ...tier ? { tier } : {} });
|
|
1950
|
+
}
|
|
1951
|
+
return { file, title: titleHeading?.text ?? "", header, constraints, tasks };
|
|
1952
|
+
}
|
|
1953
|
+
function loadPlan(file) {
|
|
1954
|
+
const abs = path9.resolve(file);
|
|
1955
|
+
if (!fs11.existsSync(abs) || !fs11.statSync(abs).isFile()) throw new UserError(`plan not found: ${file}`);
|
|
1956
|
+
const plan = parsePlan(fs11.readFileSync(abs, "utf8"), fs11.realpathSync(abs));
|
|
1957
|
+
if (!plan.tasks.length) throw new UserError(`${file} has no "Task N" headings (see pitroom-writing-plans)`);
|
|
1958
|
+
return plan;
|
|
1959
|
+
}
|
|
1960
|
+
function planTask(plan, step) {
|
|
1961
|
+
const t = plan.tasks.find((x) => x.step === step);
|
|
1962
|
+
if (!t) throw new UserError(`no Task ${step} in ${plan.file}; it has ${plan.tasks.map((x) => `Task ${x.step}`).join(", ")}`);
|
|
1963
|
+
return t;
|
|
1434
1964
|
}
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
.
|
|
1442
|
-
.
|
|
1443
|
-
.sub{font-size:15px;fill:#c4b5fd}
|
|
1444
|
-
.v{font-size:22px;font-weight:700;fill:#fff}
|
|
1445
|
-
.l{font-size:12px;fill:#a78bfa}
|
|
1446
|
-
.f{font-size:11px;fill:#8b5cf6}
|
|
1447
|
-
</style>
|
|
1448
|
-
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#1e1b4b"/><stop offset="1" stop-color="#3b0764"/></linearGradient></defs>
|
|
1449
|
-
<rect width="600" height="220" rx="18" fill="url(#g)"/>
|
|
1450
|
-
<text x="32" y="42" class="h">PITROOM \xB7 ${esc(period.toUpperCase())}</text>
|
|
1451
|
-
<text x="32" y="98" class="big">${esc(usd(t.saved))}</text>
|
|
1452
|
-
<text x="${Math.min(60 + usd(t.saved).length * 30, 330)}" y="98" class="sub">saved on my main coding agent</text>
|
|
1453
|
-
${stat(32, compact(t.tokens), "tokens offloaded")}
|
|
1454
|
-
${stat(200, t.ratio ? `${Math.round(t.ratio)}\xD7` : "\u2014", "context compression")}
|
|
1455
|
-
${stat(380, String(t.runs), "delegated tasks")}
|
|
1456
|
-
<text x="32" y="202" class="f">estimated vs. ${esc(primaryPrice().name)} pricing \xB7 npx pitroom</text>
|
|
1457
|
-
</svg>
|
|
1965
|
+
function brief(plan, task) {
|
|
1966
|
+
return `${[
|
|
1967
|
+
`# ${plan.title || planName(plan.file)}`,
|
|
1968
|
+
plan.header,
|
|
1969
|
+
"## Global Constraints",
|
|
1970
|
+
plan.constraints || "(none stated in the plan)",
|
|
1971
|
+
task.text
|
|
1972
|
+
].filter(Boolean).join("\n\n")}
|
|
1458
1973
|
`;
|
|
1459
1974
|
}
|
|
1975
|
+
var planName = (file) => path9.basename(file).replace(/\.md$/i, "");
|
|
1460
1976
|
|
|
1461
1977
|
// src/core/report.ts
|
|
1462
|
-
var
|
|
1978
|
+
var ICON2 = {
|
|
1463
1979
|
queued: "\u22EF",
|
|
1464
1980
|
running: "\u2026",
|
|
1465
1981
|
done: "\u2714",
|
|
@@ -1475,11 +1991,11 @@ function duration(meta) {
|
|
|
1475
1991
|
}
|
|
1476
1992
|
function readSummary(meta) {
|
|
1477
1993
|
const f = runFile(meta.id, "summary.md");
|
|
1478
|
-
return
|
|
1994
|
+
return fs12.existsSync(f) ? fs12.readFileSync(f, "utf8").trim() : archivedRun(meta.id)?.answer ?? "";
|
|
1479
1995
|
}
|
|
1480
1996
|
function formatReport(meta, finalText = readSummary(meta), maxLines = 400) {
|
|
1481
1997
|
const out = [];
|
|
1482
|
-
out.push(`pitroom ${
|
|
1998
|
+
out.push(`pitroom ${ICON2[meta.state]} ${meta.state} \xB7 ${meta.mode} \xB7 ${duration(meta)} \xB7 run ${meta.id}`);
|
|
1483
1999
|
const ran = meta.ran ?? meta.worker;
|
|
1484
2000
|
const ids = [`worker ${ran.backend}`, meta.resolvedModel && `model ${meta.resolvedModel}`, meta.sessionId && `session ${meta.sessionId}`];
|
|
1485
2001
|
out.push(ids.filter(Boolean).join(" \xB7 "));
|
|
@@ -1550,8 +2066,8 @@ function formatReport(meta, finalText = readSummary(meta), maxLines = 400) {
|
|
|
1550
2066
|
}
|
|
1551
2067
|
function live(meta) {
|
|
1552
2068
|
const f = runFile(meta.id, "events.jsonl");
|
|
1553
|
-
if (!
|
|
1554
|
-
const p = getBackend((meta.ran ?? meta.worker).backend).parse(
|
|
2069
|
+
if (!fs12.existsSync(f)) return { steps: 0, toolCalls: 0 };
|
|
2070
|
+
const p = getBackend((meta.ran ?? meta.worker).backend).parse(fs12.readFileSync(f, "utf8"));
|
|
1555
2071
|
return { steps: p.usage.steps, toolCalls: p.usage.toolCalls, last: p.lastActivity };
|
|
1556
2072
|
}
|
|
1557
2073
|
function progress(meta) {
|
|
@@ -1622,22 +2138,30 @@ async function waitMany(ids, opts) {
|
|
|
1622
2138
|
}
|
|
1623
2139
|
async function watch(select, opts) {
|
|
1624
2140
|
const seen = /* @__PURE__ */ new Map();
|
|
1625
|
-
const
|
|
2141
|
+
const line = (s) => opts.write(`${s}
|
|
2142
|
+
`);
|
|
2143
|
+
const emit = (e, m) => {
|
|
2144
|
+
if (!opts.brief) return opts.write(`${JSON.stringify(e)}
|
|
1626
2145
|
`);
|
|
2146
|
+
if (e.event === "started" && m) line(startedCard(m));
|
|
2147
|
+
else if (e.event === "fallback" && m) line(`\u{1F3C1} Pitroom \u21BB ${m.id} fell back from ${String(e.failed)}: ${String(e.reason)}`);
|
|
2148
|
+
else if (m && TERMINAL.includes(m.state)) line(endedCard(m));
|
|
2149
|
+
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` : ""})`);
|
|
2150
|
+
};
|
|
1627
2151
|
const deadline = opts.timeoutMs > 0 ? Date.now() + opts.timeoutMs : Infinity;
|
|
1628
2152
|
for (; ; ) {
|
|
1629
2153
|
const metas = select().map((id) => freshMeta(id));
|
|
1630
|
-
if (opts.json) {
|
|
2154
|
+
if (opts.json || opts.brief) {
|
|
1631
2155
|
for (const m of metas) {
|
|
1632
2156
|
const prev = seen.get(m.id);
|
|
1633
2157
|
const l = m.state === "running" ? live(m) : { steps: m.usage?.steps ?? 0, toolCalls: m.usage?.toolCalls ?? 0 };
|
|
1634
2158
|
const attempts = m.attempts?.length ?? 0;
|
|
1635
|
-
const
|
|
1636
|
-
if (!prev) emit({ event: m.state === "queued" ? "queued" : "started", ...
|
|
1637
|
-
else if (prev.state === "queued" && m.state === "running") emit({ event: "started", ...
|
|
2159
|
+
const base2 = { run: m.id, mode: m.mode, task: oneLine4(m.task, 60) };
|
|
2160
|
+
if (!prev) emit({ event: m.state === "queued" ? "queued" : "started", ...base2 }, m);
|
|
2161
|
+
else if (prev.state === "queued" && m.state === "running") emit({ event: "started", ...base2 }, m);
|
|
1638
2162
|
if (attempts > (prev?.attempts ?? 0)) {
|
|
1639
2163
|
const a = m.attempts[attempts - 1];
|
|
1640
|
-
emit({ event: "fallback", run: m.id, failed: a.target, reason: oneLine4(a.error, 120) });
|
|
2164
|
+
emit({ event: "fallback", run: m.id, failed: a.target, reason: oneLine4(a.error, 120) }, m);
|
|
1641
2165
|
}
|
|
1642
2166
|
if (m.state === "running" && l.steps > (prev?.steps ?? 0)) {
|
|
1643
2167
|
emit({ event: "progress", run: m.id, steps: l.steps, tools: l.toolCalls, last: "last" in l && l.last ? oneLine4(String(l.last), 80) : void 0 });
|
|
@@ -1652,7 +2176,7 @@ async function watch(select, opts) {
|
|
|
1652
2176
|
error: m.error ? oneLine4(m.error, 160) : void 0,
|
|
1653
2177
|
refs: m.refs ? `${m.refs.valid}/${m.refs.total}` : void 0,
|
|
1654
2178
|
changes: m.changes?.length || void 0
|
|
1655
|
-
});
|
|
2179
|
+
}, m);
|
|
1656
2180
|
}
|
|
1657
2181
|
seen.set(m.id, { state: m.state, steps: l.steps, attempts });
|
|
1658
2182
|
}
|
|
@@ -1664,7 +2188,7 @@ ${(/* @__PURE__ */ new Date()).toLocaleTimeString()} \xB7 Ctrl-C to stop watchin
|
|
|
1664
2188
|
}
|
|
1665
2189
|
const allDone = metas.length > 0 && metas.every((m) => TERMINAL.includes(m.state));
|
|
1666
2190
|
if (allDone || metas.length === 0 && seen.size === 0) {
|
|
1667
|
-
if (opts.json) {
|
|
2191
|
+
if (opts.json || opts.brief) {
|
|
1668
2192
|
const ok = metas.filter((m) => m.state === "done").length;
|
|
1669
2193
|
emit({ event: "all-done", runs: metas.length, ok, failed: metas.length - ok });
|
|
1670
2194
|
}
|
|
@@ -1676,44 +2200,44 @@ ${(/* @__PURE__ */ new Date()).toLocaleTimeString()} \xB7 Ctrl-C to stop watchin
|
|
|
1676
2200
|
}
|
|
1677
2201
|
|
|
1678
2202
|
// src/core/install.ts
|
|
1679
|
-
import
|
|
2203
|
+
import fs13 from "node:fs";
|
|
1680
2204
|
import os7 from "node:os";
|
|
1681
|
-
import
|
|
2205
|
+
import path10 from "node:path";
|
|
1682
2206
|
import { fileURLToPath } from "node:url";
|
|
1683
2207
|
var LEGACY = ["pitroom", "opencode-worker"];
|
|
1684
2208
|
function packageRoot() {
|
|
1685
|
-
return
|
|
2209
|
+
return path10.resolve(path10.dirname(fileURLToPath(import.meta.url)), "..");
|
|
1686
2210
|
}
|
|
1687
2211
|
function skillNames(root = packageRoot()) {
|
|
1688
|
-
const dir =
|
|
1689
|
-
if (!
|
|
1690
|
-
return
|
|
2212
|
+
const dir = path10.join(root, "skills");
|
|
2213
|
+
if (!fs13.existsSync(dir)) return [];
|
|
2214
|
+
return fs13.readdirSync(dir).filter((d) => fs13.existsSync(path10.join(dir, d, "SKILL.md"))).sort();
|
|
1691
2215
|
}
|
|
1692
2216
|
function skillTargets() {
|
|
1693
2217
|
const home2 = os7.homedir();
|
|
1694
|
-
const targets = [
|
|
1695
|
-
if (
|
|
2218
|
+
const targets = [path10.join(home2, ".agents", "skills")];
|
|
2219
|
+
if (fs13.existsSync(path10.join(home2, ".claude"))) targets.push(path10.join(home2, ".claude", "skills"));
|
|
1696
2220
|
return targets;
|
|
1697
2221
|
}
|
|
1698
|
-
var launcherPath = () =>
|
|
2222
|
+
var launcherPath = () => path10.join(os7.homedir(), ".local", "bin", "pitroom");
|
|
1699
2223
|
var LAUNCHER_MARK = "# pitroom launcher";
|
|
1700
2224
|
function launcherScript(bundle) {
|
|
1701
2225
|
const q = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
1702
2226
|
return `#!/bin/sh
|
|
1703
2227
|
${LAUNCHER_MARK} (created by \`pitroom install\`; \`pitroom uninstall\` removes it)
|
|
1704
2228
|
cli=${q(bundle)}
|
|
1705
|
-
ok() { [ -n "$1" ] && [ -x "$1" ] && "$1" -e 'process.
|
|
2229
|
+
ok() { [ -n "$1" ] && [ -x "$1" ] && "$1" -e 'const [a,b]=process.versions.node.split(".").map(Number);process.exit(a>22||(a===22&&b>=13)?0:1)' 2>/dev/null; }
|
|
1706
2230
|
for n in "\${PITROOM_NODE:-}" ${q(process.execPath)} "$(command -v node 2>/dev/null)" "$HOME"/.nvm/versions/node/*/bin/node /opt/homebrew/bin/node /usr/local/bin/node; do
|
|
1707
2231
|
if ok "$n"; then exec "$n" "$cli" "$@"; fi
|
|
1708
2232
|
done
|
|
1709
|
-
echo "pitroom: needs Node.js
|
|
2233
|
+
echo "pitroom: needs Node.js 22.13 or newer (set PITROOM_NODE to its path)" >&2
|
|
1710
2234
|
exit 127
|
|
1711
2235
|
`;
|
|
1712
2236
|
}
|
|
1713
2237
|
function isOurLauncher(file, root) {
|
|
1714
2238
|
if (linksInto(file, root)) return true;
|
|
1715
2239
|
try {
|
|
1716
|
-
const s =
|
|
2240
|
+
const s = fs13.readFileSync(file, "utf8");
|
|
1717
2241
|
return s.includes(LAUNCHER_MARK) && s.includes(root);
|
|
1718
2242
|
} catch {
|
|
1719
2243
|
return false;
|
|
@@ -1721,96 +2245,96 @@ function isOurLauncher(file, root) {
|
|
|
1721
2245
|
}
|
|
1722
2246
|
function placeLauncher(bundle, root, force) {
|
|
1723
2247
|
const dest = launcherPath();
|
|
1724
|
-
|
|
1725
|
-
const exists =
|
|
2248
|
+
fs13.mkdirSync(path10.dirname(dest), { recursive: true });
|
|
2249
|
+
const exists = fs13.lstatSync(dest, { throwIfNoEntry: false });
|
|
1726
2250
|
if (exists && !isOurLauncher(dest, root)) {
|
|
1727
2251
|
if (!force) return `! ${dest} exists and is not Pitroom's launcher; kept (use --force to back it up and replace)`;
|
|
1728
|
-
|
|
2252
|
+
fs13.renameSync(dest, `${dest}.bak-${Date.now()}`);
|
|
1729
2253
|
} else if (exists) {
|
|
1730
|
-
|
|
2254
|
+
fs13.rmSync(dest, { force: true });
|
|
1731
2255
|
}
|
|
1732
|
-
|
|
1733
|
-
return `\u2714 ${dest} \u2192 launcher for ${bundle} (Node
|
|
2256
|
+
fs13.writeFileSync(dest, launcherScript(bundle), { mode: 493 });
|
|
2257
|
+
return `\u2714 ${dest} \u2192 launcher for ${bundle} (Node 22.13+)`;
|
|
1734
2258
|
}
|
|
1735
2259
|
function linksInto(link, dir) {
|
|
1736
|
-
const st =
|
|
2260
|
+
const st = fs13.lstatSync(link, { throwIfNoEntry: false });
|
|
1737
2261
|
if (!st?.isSymbolicLink()) return false;
|
|
1738
|
-
const target =
|
|
1739
|
-
const rel =
|
|
1740
|
-
return !rel.startsWith("..") && !
|
|
2262
|
+
const target = path10.resolve(path10.dirname(link), fs13.readlinkSync(link));
|
|
2263
|
+
const rel = path10.relative(dir, target);
|
|
2264
|
+
return !rel.startsWith("..") && !path10.isAbsolute(rel);
|
|
1741
2265
|
}
|
|
1742
2266
|
function place(src, dest, opts) {
|
|
1743
|
-
|
|
1744
|
-
const st =
|
|
2267
|
+
fs13.mkdirSync(path10.dirname(dest), { recursive: true });
|
|
2268
|
+
const st = fs13.lstatSync(dest, { throwIfNoEntry: false });
|
|
1745
2269
|
if (st && !st.isSymbolicLink()) {
|
|
1746
2270
|
if (!opts.force) return `! ${dest} exists and is not a link; kept (use --force to back it up and replace)`;
|
|
1747
|
-
|
|
2271
|
+
fs13.renameSync(dest, `${dest}.bak-${Date.now()}`);
|
|
1748
2272
|
} else if (st) {
|
|
1749
|
-
|
|
2273
|
+
fs13.unlinkSync(dest);
|
|
1750
2274
|
}
|
|
1751
|
-
if (opts.copy)
|
|
1752
|
-
else
|
|
2275
|
+
if (opts.copy) fs13.cpSync(src, dest, { recursive: true });
|
|
2276
|
+
else fs13.symlinkSync(src, dest, process.platform === "win32" ? "junction" : fs13.statSync(src).isDirectory() ? "dir" : "file");
|
|
1753
2277
|
return `\u2714 ${dest} \u2192 ${opts.copy ? "copied" : src}`;
|
|
1754
2278
|
}
|
|
1755
2279
|
function install(opts) {
|
|
1756
2280
|
const root = packageRoot();
|
|
1757
2281
|
const skills = skillNames(root);
|
|
1758
|
-
if (!skills.length) throw new Error(`no skills found under ${
|
|
2282
|
+
if (!skills.length) throw new Error(`no skills found under ${path10.join(root, "skills")}`);
|
|
1759
2283
|
const out = [];
|
|
1760
|
-
for (const
|
|
2284
|
+
for (const base2 of skillTargets()) {
|
|
1761
2285
|
for (const legacy of LEGACY) {
|
|
1762
|
-
const l =
|
|
2286
|
+
const l = path10.join(base2, legacy);
|
|
1763
2287
|
if (!skills.includes(legacy) && linksInto(l, root)) {
|
|
1764
|
-
|
|
2288
|
+
fs13.unlinkSync(l);
|
|
1765
2289
|
out.push(`\u2714 removed old link ${l}`);
|
|
1766
2290
|
}
|
|
1767
2291
|
}
|
|
1768
|
-
for (const name of skills) out.push(place(
|
|
2292
|
+
for (const name of skills) out.push(place(path10.join(root, "skills", name), path10.join(base2, name), opts));
|
|
1769
2293
|
}
|
|
1770
|
-
const bundle =
|
|
1771
|
-
if (
|
|
2294
|
+
const bundle = path10.join(root, "dist", "pitroom.mjs");
|
|
2295
|
+
if (fs13.existsSync(bundle)) {
|
|
1772
2296
|
out.push(placeLauncher(bundle, root, opts.force));
|
|
1773
|
-
const onPath = (process.env.PATH ?? "").split(
|
|
1774
|
-
if (!onPath) out.push(`! ${
|
|
2297
|
+
const onPath = (process.env.PATH ?? "").split(path10.delimiter).some((d) => path10.resolve(d) === path10.dirname(launcherPath()));
|
|
2298
|
+
if (!onPath) out.push(`! ${path10.dirname(launcherPath())} is not on PATH; add it, or run ${launcherPath()} directly`);
|
|
1775
2299
|
}
|
|
1776
2300
|
return out;
|
|
1777
2301
|
}
|
|
1778
2302
|
function uninstall() {
|
|
1779
2303
|
const root = packageRoot();
|
|
1780
2304
|
const out = [];
|
|
1781
|
-
for (const
|
|
1782
|
-
if (!
|
|
1783
|
-
for (const name of
|
|
1784
|
-
const l =
|
|
2305
|
+
for (const base2 of skillTargets()) {
|
|
2306
|
+
if (!fs13.existsSync(base2)) continue;
|
|
2307
|
+
for (const name of fs13.readdirSync(base2)) {
|
|
2308
|
+
const l = path10.join(base2, name);
|
|
1785
2309
|
if (linksInto(l, root)) {
|
|
1786
|
-
|
|
2310
|
+
fs13.unlinkSync(l);
|
|
1787
2311
|
out.push(`\u2714 removed ${l}`);
|
|
1788
2312
|
}
|
|
1789
2313
|
}
|
|
1790
2314
|
}
|
|
1791
2315
|
if (isOurLauncher(launcherPath(), root)) {
|
|
1792
|
-
|
|
2316
|
+
fs13.rmSync(launcherPath(), { force: true });
|
|
1793
2317
|
out.push(`\u2714 removed ${launcherPath()}`);
|
|
1794
2318
|
}
|
|
1795
2319
|
return out.length ? out : ["nothing to remove"];
|
|
1796
2320
|
}
|
|
1797
2321
|
function installedSkills() {
|
|
1798
2322
|
const names = skillNames();
|
|
1799
|
-
return skillTargets().map((
|
|
2323
|
+
return skillTargets().map((base2) => ({ base: base2, names: names.filter((n) => fs13.existsSync(path10.join(base2, n, "SKILL.md"))) }));
|
|
1800
2324
|
}
|
|
1801
2325
|
|
|
1802
2326
|
// src/core/plan-status.ts
|
|
1803
2327
|
import crypto2 from "node:crypto";
|
|
1804
|
-
import
|
|
1805
|
-
import
|
|
2328
|
+
import fs14 from "node:fs";
|
|
2329
|
+
import path11 from "node:path";
|
|
1806
2330
|
function notesFile(plan) {
|
|
1807
2331
|
const id = crypto2.createHash("sha1").update(plan.file).digest("hex").slice(0, 12);
|
|
1808
|
-
return
|
|
2332
|
+
return path11.join(home(), "plans", id, "notes.md");
|
|
1809
2333
|
}
|
|
1810
2334
|
function readNotes(plan) {
|
|
1811
2335
|
const f = notesFile(plan);
|
|
1812
|
-
if (!
|
|
1813
|
-
return
|
|
2336
|
+
if (!fs14.existsSync(f)) return [];
|
|
2337
|
+
return fs14.readFileSync(f, "utf8").split("\n").slice(1).filter(Boolean).map((l) => {
|
|
1814
2338
|
const m = /^(\d{4}-\d\d-\d\d \d\d:\d\d) (.*)$/.exec(l);
|
|
1815
2339
|
return m ? { at: m[1], text: m[2] } : { at: "", text: l };
|
|
1816
2340
|
});
|
|
@@ -1818,11 +2342,11 @@ function readNotes(plan) {
|
|
|
1818
2342
|
function addNote(planFile, text) {
|
|
1819
2343
|
const plan = loadPlan(planFile);
|
|
1820
2344
|
const f = notesFile(plan);
|
|
1821
|
-
|
|
1822
|
-
if (!
|
|
2345
|
+
fs14.mkdirSync(path11.dirname(f), { recursive: true });
|
|
2346
|
+
if (!fs14.existsSync(f)) fs14.writeFileSync(f, `# plan: ${plan.file}
|
|
1823
2347
|
`);
|
|
1824
2348
|
const at = (/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace("T", " ");
|
|
1825
|
-
|
|
2349
|
+
fs14.appendFileSync(f, `${at} ${text.replace(/\s+/g, " ").trim()}
|
|
1826
2350
|
`);
|
|
1827
2351
|
return f;
|
|
1828
2352
|
}
|
|
@@ -1890,15 +2414,15 @@ ${rulings}`,
|
|
|
1890
2414
|
|
|
1891
2415
|
// src/core/review.ts
|
|
1892
2416
|
import crypto4 from "node:crypto";
|
|
1893
|
-
import
|
|
1894
|
-
import
|
|
2417
|
+
import fs16 from "node:fs";
|
|
2418
|
+
import path13 from "node:path";
|
|
1895
2419
|
|
|
1896
2420
|
// src/vcs/git.ts
|
|
1897
2421
|
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
1898
2422
|
import crypto3 from "node:crypto";
|
|
1899
|
-
import
|
|
2423
|
+
import fs15 from "node:fs";
|
|
1900
2424
|
import os8 from "node:os";
|
|
1901
|
-
import
|
|
2425
|
+
import path12 from "node:path";
|
|
1902
2426
|
var SAFE = [
|
|
1903
2427
|
"-c",
|
|
1904
2428
|
"color.ui=false",
|
|
@@ -1930,20 +2454,20 @@ function gitAvailable() {
|
|
|
1930
2454
|
}
|
|
1931
2455
|
function repoRoot(dir) {
|
|
1932
2456
|
const r = git(dir, ["rev-parse", "--show-toplevel"]);
|
|
1933
|
-
return r.code === 0 ?
|
|
2457
|
+
return r.code === 0 ? path12.resolve(r.stdout.trim()) : void 0;
|
|
1934
2458
|
}
|
|
1935
2459
|
function snapshotTree(root, exclude = []) {
|
|
1936
|
-
const tmp =
|
|
1937
|
-
const real =
|
|
2460
|
+
const tmp = path12.join(os8.tmpdir(), `pitroom-index-${process.pid}-${crypto3.randomBytes(4).toString("hex")}`);
|
|
2461
|
+
const real = path12.resolve(root, must(root, ["rev-parse", "--git-path", "index"]).trim());
|
|
1938
2462
|
const env = { ...process.env, GIT_INDEX_FILE: tmp };
|
|
1939
2463
|
try {
|
|
1940
|
-
if (
|
|
2464
|
+
if (fs15.existsSync(real)) fs15.copyFileSync(real, tmp);
|
|
1941
2465
|
else must(root, ["read-tree", "--empty"], env);
|
|
1942
2466
|
must(root, ["add", "-A", "--", ":/", ...exclude.map((p) => `:(top,exclude)${p}`)], env);
|
|
1943
2467
|
return must(root, ["write-tree"], env).trim();
|
|
1944
2468
|
} finally {
|
|
1945
|
-
|
|
1946
|
-
|
|
2469
|
+
fs15.rmSync(tmp, { force: true });
|
|
2470
|
+
fs15.rmSync(`${tmp}.lock`, { force: true });
|
|
1947
2471
|
}
|
|
1948
2472
|
}
|
|
1949
2473
|
var DIFF = ["diff", "--no-ext-diff", "--no-textconv", "--no-renames"];
|
|
@@ -1963,27 +2487,27 @@ function diffTrees(root, a, b) {
|
|
|
1963
2487
|
return { changes, stats: { files: changes.length, insertions, deletions }, patch };
|
|
1964
2488
|
}
|
|
1965
2489
|
function createIsolatedCopy(root, tree, dest) {
|
|
1966
|
-
const objects =
|
|
1967
|
-
|
|
1968
|
-
must(
|
|
1969
|
-
|
|
2490
|
+
const objects = path12.join(path12.resolve(root, must(root, ["rev-parse", "--git-common-dir"]).trim()), "objects");
|
|
2491
|
+
fs15.mkdirSync(path12.dirname(dest), { recursive: true });
|
|
2492
|
+
must(path12.dirname(dest), ["init", "--quiet", dest]);
|
|
2493
|
+
fs15.writeFileSync(path12.join(dest, ".git", "objects", "info", "alternates"), `${objects}
|
|
1970
2494
|
`);
|
|
1971
2495
|
must(dest, ["read-tree", tree]);
|
|
1972
2496
|
must(dest, ["checkout-index", "--all", "--force"]);
|
|
1973
2497
|
}
|
|
1974
2498
|
function removeIsolatedCopy(dest, ownedBy) {
|
|
1975
|
-
const rel =
|
|
1976
|
-
if (!rel || rel.startsWith("..") ||
|
|
1977
|
-
|
|
2499
|
+
const rel = path12.relative(ownedBy, dest);
|
|
2500
|
+
if (!rel || rel.startsWith("..") || path12.isAbsolute(rel)) throw new Error(`refusing to remove ${dest}`);
|
|
2501
|
+
fs15.rmSync(dest, { recursive: true, force: true });
|
|
1978
2502
|
}
|
|
1979
2503
|
function linkIntoWorktree(root, worktree, rels) {
|
|
1980
2504
|
const linked = [];
|
|
1981
2505
|
for (const rel of rels) {
|
|
1982
|
-
const src =
|
|
1983
|
-
const dst =
|
|
1984
|
-
if (!
|
|
1985
|
-
|
|
1986
|
-
|
|
2506
|
+
const src = path12.join(root, rel);
|
|
2507
|
+
const dst = path12.join(worktree, rel);
|
|
2508
|
+
if (!fs15.existsSync(src) || fs15.existsSync(dst)) continue;
|
|
2509
|
+
fs15.mkdirSync(path12.dirname(dst), { recursive: true });
|
|
2510
|
+
fs15.symlinkSync(src, dst, process.platform === "win32" ? "junction" : void 0);
|
|
1987
2511
|
linked.push(rel);
|
|
1988
2512
|
}
|
|
1989
2513
|
return linked;
|
|
@@ -2042,7 +2566,7 @@ function ancestors(m) {
|
|
|
2042
2566
|
function briefOf(m) {
|
|
2043
2567
|
const root = [m, ...ancestors(m)].at(-1);
|
|
2044
2568
|
const f = runFile(root.id, "brief.md");
|
|
2045
|
-
return
|
|
2569
|
+
return fs16.existsSync(f) ? fs16.readFileSync(f, "utf8") : root.task;
|
|
2046
2570
|
}
|
|
2047
2571
|
function latestReview(ids) {
|
|
2048
2572
|
for (const id of listRunIds().reverse()) {
|
|
@@ -2056,11 +2580,11 @@ function latestReview(ids) {
|
|
|
2056
2580
|
}
|
|
2057
2581
|
function diffOf(m, from = m.baseTree) {
|
|
2058
2582
|
const where = m.mode === "isolate" ? m.worktree : m.repoRoot;
|
|
2059
|
-
if (where &&
|
|
2583
|
+
if (where && fs16.existsSync(where) && from && m.afterTree) return reviewDiff(where, from, m.afterTree);
|
|
2060
2584
|
if (from !== m.baseTree) {
|
|
2061
2585
|
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);
|
|
2062
2586
|
}
|
|
2063
|
-
return
|
|
2587
|
+
return readRunFile(m.id, "changes.patch") ?? archivedRun(m.id)?.patch ?? "";
|
|
2064
2588
|
}
|
|
2065
2589
|
function runReview(id) {
|
|
2066
2590
|
const m = freshMeta(id);
|
|
@@ -2070,7 +2594,7 @@ function runReview(id) {
|
|
|
2070
2594
|
if (!m.changes?.length) throw new UserError(`run ${m.id} made no changes; nothing to review`);
|
|
2071
2595
|
const chain = ancestors(m);
|
|
2072
2596
|
const previous = latestReview(chain.map((a) => a.id));
|
|
2073
|
-
const dir = m.mode === "isolate" && m.worktree &&
|
|
2597
|
+
const dir = m.mode === "isolate" && m.worktree && fs16.existsSync(m.worktree) ? m.cwd : m.dir;
|
|
2074
2598
|
const job = { of: m.id, dir, implementer: m.ran ?? m.worker, group: m.group, plan: m.plan };
|
|
2075
2599
|
const title = m.plan ? `Task ${m.plan.step}: ${m.plan.title}` : `run ${m.id}`;
|
|
2076
2600
|
if (!previous) {
|
|
@@ -2134,6 +2658,7 @@ function pickReviewer(job) {
|
|
|
2134
2658
|
const eff = effective();
|
|
2135
2659
|
const tiers = eff.tiers.value;
|
|
2136
2660
|
if (job.kind === "range") return tiers.capable;
|
|
2661
|
+
if (tiers.review) return tiers.review;
|
|
2137
2662
|
const implementer = job.implementer;
|
|
2138
2663
|
if (!implementer) return void 0;
|
|
2139
2664
|
const def = parseTarget(eff.worker.value, DEFAULT_BACKEND).backend;
|
|
@@ -2143,15 +2668,15 @@ function pickReviewer(job) {
|
|
|
2143
2668
|
function writePackage(job) {
|
|
2144
2669
|
const g = gitDir(job.dir);
|
|
2145
2670
|
if (!g) throw new UserError(`not a git repository: ${job.dir}`);
|
|
2146
|
-
const file =
|
|
2147
|
-
|
|
2148
|
-
|
|
2671
|
+
const file = path13.join(g, "pitroom", `review-${crypto4.randomBytes(4).toString("hex")}.md`);
|
|
2672
|
+
fs16.mkdirSync(path13.dirname(file), { recursive: true });
|
|
2673
|
+
fs16.writeFileSync(file, job.package);
|
|
2149
2674
|
return file;
|
|
2150
2675
|
}
|
|
2151
2676
|
|
|
2152
2677
|
// src/core/templates.ts
|
|
2153
|
-
import
|
|
2154
|
-
import
|
|
2678
|
+
import fs17 from "node:fs";
|
|
2679
|
+
import path14 from "node:path";
|
|
2155
2680
|
var FILES = {
|
|
2156
2681
|
implementer: "pitroom-driven-development/implementer-prompt.md",
|
|
2157
2682
|
"task-reviewer": "pitroom-driven-development/task-reviewer-prompt.md",
|
|
@@ -2159,9 +2684,9 @@ var FILES = {
|
|
|
2159
2684
|
"code-reviewer": "pitroom-review/code-reviewer.md"
|
|
2160
2685
|
};
|
|
2161
2686
|
function loadTemplate(name, root = packageRoot()) {
|
|
2162
|
-
const file =
|
|
2163
|
-
if (!
|
|
2164
|
-
return
|
|
2687
|
+
const file = path14.join(root, "skills", FILES[name]);
|
|
2688
|
+
if (!fs17.existsSync(file)) throw new UserError(`template missing: ${file} (broken install? run pitroom doctor)`, 3);
|
|
2689
|
+
return fs17.readFileSync(file, "utf8").replace(/^\s*<!--[\s\S]*?-->\s*/, "");
|
|
2165
2690
|
}
|
|
2166
2691
|
function fill(template, values) {
|
|
2167
2692
|
const missing = [...template.matchAll(/\{\{([A-Z_]+)\}\}/g)].map((m) => m[1]).filter((k) => !(k in values));
|
|
@@ -2169,122 +2694,425 @@ function fill(template, values) {
|
|
|
2169
2694
|
return template.replace(/\{\{([A-Z_]+)\}\}/g, (_, key) => values[key]);
|
|
2170
2695
|
}
|
|
2171
2696
|
|
|
2172
|
-
// src/core/
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
const
|
|
2178
|
-
|
|
2697
|
+
// src/core/models.ts
|
|
2698
|
+
var base = (model) => (model ?? "").split("#")[0];
|
|
2699
|
+
function usage() {
|
|
2700
|
+
const eff = effective();
|
|
2701
|
+
const out = /* @__PURE__ */ new Map();
|
|
2702
|
+
const add = (spec, label) => {
|
|
2703
|
+
let t;
|
|
2179
2704
|
try {
|
|
2180
|
-
|
|
2181
|
-
if (isActive(m.state) && (m.state === "queued" || isAlive(m.pid))) out.push(m);
|
|
2705
|
+
t = parseTarget(spec, "opencode");
|
|
2182
2706
|
} catch {
|
|
2707
|
+
return;
|
|
2183
2708
|
}
|
|
2184
|
-
|
|
2709
|
+
const model = base(t.model) || base(eff.models.value[t.backend]);
|
|
2710
|
+
if (!model) return;
|
|
2711
|
+
const key = `${t.backend}:${model}`;
|
|
2712
|
+
out.set(key, [...out.get(key) ?? [], label]);
|
|
2713
|
+
};
|
|
2714
|
+
add(eff.worker.value, "default worker");
|
|
2715
|
+
for (const [name, spec] of Object.entries(eff.tiers.value)) add(spec, `tier ${name}`);
|
|
2716
|
+
eff.fallback.value.forEach((spec) => add(spec, "fallback"));
|
|
2717
|
+
for (const [backend, model] of Object.entries(eff.models.value)) add(`${backend}:${model}`, "models");
|
|
2185
2718
|
return out;
|
|
2186
2719
|
}
|
|
2187
|
-
function
|
|
2188
|
-
const
|
|
2189
|
-
const
|
|
2190
|
-
|
|
2191
|
-
const
|
|
2192
|
-
|
|
2193
|
-
const
|
|
2194
|
-
|
|
2720
|
+
function modelTable(opts = {}) {
|
|
2721
|
+
const costs = effective().costs.value;
|
|
2722
|
+
const used = usage();
|
|
2723
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2724
|
+
for (const e of readLedger()) {
|
|
2725
|
+
if (!e.backend || !e.model) continue;
|
|
2726
|
+
const key = `${e.backend}:${base(e.model)}`;
|
|
2727
|
+
const a = seen.get(key) ?? { runs: 0, tokens: 0, usd: 0 };
|
|
2728
|
+
seen.set(key, { runs: a.runs + 1, tokens: a.tokens + e.tokens, usd: a.usd + e.workerCost });
|
|
2729
|
+
}
|
|
2730
|
+
const rows = [];
|
|
2731
|
+
const sources = [];
|
|
2732
|
+
let hiddenOpenCode = 0;
|
|
2733
|
+
const known = /* @__PURE__ */ new Set();
|
|
2734
|
+
for (const b of allBackends()) {
|
|
2735
|
+
if (opts.backend && b.id !== opts.backend) continue;
|
|
2736
|
+
const catalog4 = b.catalog?.() ?? { models: [], source: "no model list for this worker" };
|
|
2737
|
+
sources.push(`${b.id}: ${catalog4.source}`);
|
|
2738
|
+
for (const m of catalog4.models) {
|
|
2739
|
+
const key = `${b.id}:${m.id}`;
|
|
2740
|
+
known.add(key);
|
|
2741
|
+
const s = seen.get(key);
|
|
2742
|
+
const row = {
|
|
2743
|
+
worker: b.id,
|
|
2744
|
+
model: m.id,
|
|
2745
|
+
efforts: m.efforts?.length ? m.efforts : void 0,
|
|
2746
|
+
defaultEffort: m.defaultEffort,
|
|
2747
|
+
cost: costs[key],
|
|
2748
|
+
runs: s?.runs ?? 0,
|
|
2749
|
+
avgTokens: s ? Math.round(s.tokens / s.runs) : void 0,
|
|
2750
|
+
reportedUsdPerRun: s && s.usd > 0 ? s.usd / s.runs : void 0,
|
|
2751
|
+
inUse: used.get(key) ?? []
|
|
2752
|
+
};
|
|
2753
|
+
const relevant = row.inUse.length || row.runs || row.cost !== void 0;
|
|
2754
|
+
if (b.id === "opencode" && !opts.all && !relevant) hiddenOpenCode++;
|
|
2755
|
+
else rows.push(row);
|
|
2756
|
+
}
|
|
2195
2757
|
}
|
|
2196
|
-
|
|
2197
|
-
|
|
2758
|
+
for (const [key, labels] of used) {
|
|
2759
|
+
const [worker, ...rest] = key.split(":");
|
|
2760
|
+
if (known.has(key) || opts.backend && worker !== opts.backend) continue;
|
|
2761
|
+
const s = seen.get(key);
|
|
2762
|
+
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 });
|
|
2763
|
+
}
|
|
2764
|
+
rows.sort(
|
|
2765
|
+
(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)
|
|
2766
|
+
);
|
|
2767
|
+
return { rows, sources, hiddenOpenCode };
|
|
2768
|
+
}
|
|
2769
|
+
var compact2 = (n) => n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n);
|
|
2770
|
+
function formatModels(t) {
|
|
2771
|
+
const efforts = (r) => r.efforts ? r.efforts.map((e) => e === r.defaultEffort ? `${e}*` : e).join("/") : "-";
|
|
2772
|
+
const cells = t.rows.map((r) => [
|
|
2773
|
+
r.worker,
|
|
2774
|
+
r.model,
|
|
2775
|
+
efforts(r),
|
|
2776
|
+
r.cost !== void 0 ? String(r.cost) : "?",
|
|
2777
|
+
r.runs ? String(r.runs) : "-",
|
|
2778
|
+
r.avgTokens !== void 0 ? compact2(r.avgTokens) : "-",
|
|
2779
|
+
r.reportedUsdPerRun !== void 0 ? `$${r.reportedUsdPerRun.toFixed(3)}` : "-",
|
|
2780
|
+
r.inUse.join(", ") || "-"
|
|
2781
|
+
]);
|
|
2782
|
+
const head = ["worker", "model", "effort (* default)", "cost", "runs", "avg tokens", "$/run", "in use"];
|
|
2783
|
+
const widths = head.map((h, i) => Math.max(h.length, ...cells.map((c) => c[i].length)));
|
|
2784
|
+
const line = (c) => c.map((x, i) => x.padEnd(widths[i])).join(" ").trimEnd();
|
|
2785
|
+
const out = [line(head), ...cells.map(line), ""];
|
|
2786
|
+
out.push('cost: your relative cost from the config ("costs": {"codex:gpt-6-sol": 1, \u2026}); ? = not set. Pitroom cannot know vendor prices.');
|
|
2787
|
+
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.");
|
|
2788
|
+
if (t.hiddenOpenCode) out.push(`${t.hiddenOpenCode} more OpenCode models not shown (use --all).`);
|
|
2789
|
+
out.push(...t.sources.map((s) => `source ${s}`));
|
|
2790
|
+
return out.join("\n");
|
|
2198
2791
|
}
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2792
|
+
|
|
2793
|
+
// src/core/dash.ts
|
|
2794
|
+
import { spawn } from "node:child_process";
|
|
2795
|
+
import crypto5 from "node:crypto";
|
|
2796
|
+
import fs18 from "node:fs";
|
|
2797
|
+
import http from "node:http";
|
|
2798
|
+
import path15 from "node:path";
|
|
2799
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
2800
|
+
|
|
2801
|
+
// src/core/dash-page.ts
|
|
2802
|
+
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){}})()";
|
|
2803
|
+
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";
|
|
2804
|
+
var PAGE = `<!doctype html>
|
|
2805
|
+
<html lang="en" class="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
2806
|
+
<meta name="color-scheme" content="dark light"><title>Pitroom</title><link rel="icon" href="${ICON3}">
|
|
2807
|
+
<script>${THEME_SCRIPT}</script><link rel="stylesheet" href="/assets/app.css"></head>
|
|
2808
|
+
<body><div id="root"></div><script type="module" src="/assets/app.js"></script></body></html>`;
|
|
2809
|
+
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>`;
|
|
2810
|
+
|
|
2811
|
+
// src/core/dash.ts
|
|
2812
|
+
var DEFAULT_PORT = 7878;
|
|
2813
|
+
var WEEK_MS2 = 7 * 24 * 3600 * 1e3;
|
|
2814
|
+
var SCAN = 400;
|
|
2815
|
+
var RUN_ID2 = /^\d{8}-\d{6}-[0-9a-f]{4}$/;
|
|
2816
|
+
var LOCAL_HOST = /^(127\.0\.0\.1|localhost|\[::1\]):\d+$/i;
|
|
2817
|
+
var oneLine5 = (s, max) => {
|
|
2818
|
+
const t = s.replace(/\s+/g, " ").trim();
|
|
2819
|
+
return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
|
|
2820
|
+
};
|
|
2821
|
+
function findings(m) {
|
|
2822
|
+
const v = m.verdict;
|
|
2823
|
+
if (!v) return "";
|
|
2824
|
+
const parts = [v.critical && `${v.critical} critical`, v.important && `${v.important} important`, v.minor && `${v.minor} minor`].filter(Boolean);
|
|
2825
|
+
return parts.length ? parts.join(" \xB7 ") : "no findings";
|
|
2826
|
+
}
|
|
2827
|
+
function toRun(m) {
|
|
2828
|
+
const l = m.state === "running" ? live(m) : void 0;
|
|
2829
|
+
return {
|
|
2830
|
+
id: m.id,
|
|
2831
|
+
state: m.state,
|
|
2832
|
+
kind: kind(m),
|
|
2833
|
+
worker: workerName(m.ran ?? m.worker),
|
|
2834
|
+
task: what(m),
|
|
2835
|
+
group: m.group,
|
|
2836
|
+
startedAt: m.startedAt,
|
|
2837
|
+
time: elapsed(m),
|
|
2838
|
+
steps: l ? l.steps : m.usage?.steps ?? 0,
|
|
2839
|
+
tokens: m.usage?.total,
|
|
2840
|
+
saved: m.savedUsd || void 0,
|
|
2841
|
+
verdict: m.verdict ? `SPEC ${m.verdict.spec.toUpperCase()} \xB7 QUALITY ${m.verdict.quality.toUpperCase()}` : void 0,
|
|
2842
|
+
changes: m.changes?.length || void 0,
|
|
2843
|
+
applied: m.applied || void 0,
|
|
2844
|
+
note: l?.last ? oneLine5(String(l.last), 140) : isActive(m.state) ? "" : m.verdict ? findings(m) : headline(m, 200) || oneLine5(m.error ?? "", 200)
|
|
2845
|
+
};
|
|
2205
2846
|
}
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
const
|
|
2209
|
-
|
|
2210
|
-
const
|
|
2211
|
-
|
|
2212
|
-
|
|
2847
|
+
function dashState(opts = {}) {
|
|
2848
|
+
const limit = Math.min(Math.max(opts.limit ?? 40, 1), 200);
|
|
2849
|
+
const runs = [];
|
|
2850
|
+
const groups = /* @__PURE__ */ new Set();
|
|
2851
|
+
for (const id of listRunIds().slice(-SCAN).reverse()) {
|
|
2852
|
+
let m;
|
|
2853
|
+
try {
|
|
2854
|
+
m = freshMeta(id);
|
|
2855
|
+
} catch {
|
|
2856
|
+
continue;
|
|
2857
|
+
}
|
|
2858
|
+
if (m.group) groups.add(m.group);
|
|
2859
|
+
if (opts.group && m.group !== opts.group) continue;
|
|
2860
|
+
if (runs.length < limit) runs.push(toRun(m));
|
|
2861
|
+
}
|
|
2862
|
+
return {
|
|
2863
|
+
running: runs.filter((r) => r.state === "running" || r.state === "queued").length,
|
|
2864
|
+
saved: totals(readLedger(Date.now() - WEEK_MS2)).saved,
|
|
2865
|
+
groups: [...groups].sort(),
|
|
2866
|
+
runs
|
|
2867
|
+
};
|
|
2213
2868
|
}
|
|
2214
|
-
var
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
const
|
|
2218
|
-
return
|
|
2869
|
+
var TASK_MAX = 6e3;
|
|
2870
|
+
var PATCH_LINES = 300;
|
|
2871
|
+
function readFile(id, name) {
|
|
2872
|
+
const f = runFile(id, name);
|
|
2873
|
+
return fs18.existsSync(f) ? fs18.readFileSync(f, "utf8") : "";
|
|
2219
2874
|
}
|
|
2220
|
-
function
|
|
2221
|
-
if (
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2875
|
+
function runDetail(id) {
|
|
2876
|
+
if (!RUN_ID2.test(id)) return void 0;
|
|
2877
|
+
let m;
|
|
2878
|
+
try {
|
|
2879
|
+
m = freshMeta(id);
|
|
2880
|
+
} catch {
|
|
2881
|
+
return void 0;
|
|
2882
|
+
}
|
|
2883
|
+
const start = Date.parse(m.startedAt);
|
|
2884
|
+
const kept = isActive(m.state) ? void 0 : archivedRun(id);
|
|
2885
|
+
let steps = kept?.steps ?? [];
|
|
2886
|
+
if (!steps.length) try {
|
|
2887
|
+
const events = readRunFile(id, "events.jsonl");
|
|
2888
|
+
steps = (events ? getBackend((m.ran ?? m.worker).backend).parse(events).timeline ?? [] : []).map((s) => ({
|
|
2889
|
+
...s,
|
|
2890
|
+
t: s.at && Number.isFinite(start) ? Math.max(0, Math.round((s.at - start) / 1e3)) : void 0
|
|
2891
|
+
}));
|
|
2892
|
+
} catch {
|
|
2893
|
+
}
|
|
2894
|
+
const rel = (t) => [m.cwd, m.dir].filter(Boolean).reduce((x, base2) => x.split(`${base2}/`).join(""), t);
|
|
2895
|
+
steps = steps.map((x) => ({ ...x, text: rel(x.text) }));
|
|
2896
|
+
const answer = isActive(m.state) ? "" : readSummary(m).replace(/^SUMMARY:\s*SPEC[^\n]*\n+/i, "").replace(/^DETAILS:[ \t]*\n+/i, "").trim();
|
|
2897
|
+
const lastSay = steps.at(-1);
|
|
2898
|
+
if (lastSay?.kind === "say" && answer && (answer.includes(lastSay.text.slice(0, 80)) || lastSay.text.includes(answer.slice(0, 80)))) steps.pop();
|
|
2899
|
+
const patch = kept?.patch ?? readFile(id, "changes.patch");
|
|
2900
|
+
const patchLines = patch.split("\n");
|
|
2901
|
+
const u = m.usage;
|
|
2902
|
+
return {
|
|
2903
|
+
id,
|
|
2904
|
+
state: m.state,
|
|
2905
|
+
task: (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),
|
|
2906
|
+
steps,
|
|
2907
|
+
answer,
|
|
2908
|
+
changes: m.changes ?? [],
|
|
2909
|
+
patch: patch ? `${patchLines.slice(0, PATCH_LINES).join("\n")}${patchLines.length > PATCH_LINES ? `
|
|
2910
|
+
\u2026 ${patchLines.length - PATCH_LINES} more lines (pitroom show ${id} --patch)` : ""}` : void 0,
|
|
2911
|
+
info: {
|
|
2912
|
+
worker: (m.ran ?? m.worker).backend,
|
|
2913
|
+
// the model has its own line
|
|
2914
|
+
model: m.resolvedModel,
|
|
2915
|
+
mode: m.mode,
|
|
2916
|
+
started: m.startedAt,
|
|
2917
|
+
time: elapsed(m),
|
|
2918
|
+
steps: u?.steps,
|
|
2919
|
+
toolCalls: u?.toolCalls,
|
|
2920
|
+
tokens: u?.total,
|
|
2921
|
+
returnedTokens: m.returnedTokens,
|
|
2922
|
+
cost: u?.cost,
|
|
2923
|
+
saved: m.savedUsd,
|
|
2924
|
+
group: m.group,
|
|
2925
|
+
directory: m.dir
|
|
2926
|
+
},
|
|
2927
|
+
attempts: m.attempts ?? [],
|
|
2928
|
+
warnings: m.warnings ?? [],
|
|
2929
|
+
refs: m.refs ? { valid: m.refs.valid, total: m.refs.total, invalid: m.refs.invalid.map((r) => `${r.ref} (${r.reason})`) } : void 0,
|
|
2930
|
+
verify: m.verifyResult && m.verify ? { command: m.verify, ok: m.verifyResult.ok, tail: m.verifyResult.tail } : void 0,
|
|
2931
|
+
error: m.error,
|
|
2932
|
+
report: isActive(m.state) ? progress(m) : formatReport(m, void 0, 120)
|
|
2933
|
+
};
|
|
2227
2934
|
}
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2935
|
+
var HEADERS = {
|
|
2936
|
+
"cache-control": "no-store",
|
|
2937
|
+
"x-content-type-options": "nosniff",
|
|
2938
|
+
"referrer-policy": "no-referrer",
|
|
2939
|
+
// Own files only, plus the hash of the one inline theme script; styles may be inline (the components position popups with them).
|
|
2940
|
+
"content-security-policy": `default-src 'none'; script-src 'self' 'sha256-${crypto5.createHash("sha256").update(THEME_SCRIPT).digest("base64")}'; style-src 'self' 'unsafe-inline'; img-src data:; connect-src 'self'; base-uri 'none'; form-action 'none'`
|
|
2941
|
+
};
|
|
2942
|
+
var ASSETS = { "/assets/app.js": "text/javascript; charset=utf-8", "/assets/app.css": "text/css; charset=utf-8" };
|
|
2943
|
+
var assetsDir = () => fileURLToPath2(new URL("./ui/", import.meta.url));
|
|
2944
|
+
var assetCache = /* @__PURE__ */ new Map();
|
|
2945
|
+
function readAsset(route) {
|
|
2946
|
+
const hit = assetCache.get(route);
|
|
2947
|
+
if (hit) return hit;
|
|
2948
|
+
try {
|
|
2949
|
+
const data = fs18.readFileSync(path15.join(assetsDir(), path15.basename(route)));
|
|
2950
|
+
assetCache.set(route, data);
|
|
2951
|
+
return data;
|
|
2952
|
+
} catch {
|
|
2953
|
+
return void 0;
|
|
2954
|
+
}
|
|
2232
2955
|
}
|
|
2233
|
-
function
|
|
2234
|
-
const
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2956
|
+
function handler(touch) {
|
|
2957
|
+
const send = (res, code, type, body) => {
|
|
2958
|
+
res.writeHead(code, { ...HEADERS, "content-type": type });
|
|
2959
|
+
res.end(body);
|
|
2960
|
+
};
|
|
2961
|
+
const json = (res, code, value) => send(res, code, "application/json; charset=utf-8", JSON.stringify(value));
|
|
2962
|
+
return (req, res) => {
|
|
2963
|
+
touch();
|
|
2964
|
+
if (!LOCAL_HOST.test(req.headers.host ?? "")) return json(res, 403, { error: "forbidden host" });
|
|
2965
|
+
if (req.method !== "GET" && req.method !== "HEAD") return json(res, 405, { error: "read-only" });
|
|
2966
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
2967
|
+
if (url.pathname === "/") return send(res, 200, "text/html; charset=utf-8", readAsset("/assets/app.js") ? PAGE : MISSING);
|
|
2968
|
+
const type = ASSETS[url.pathname];
|
|
2969
|
+
if (type) {
|
|
2970
|
+
const data = readAsset(url.pathname);
|
|
2971
|
+
if (!data) return json(res, 404, { error: "dashboard files missing" });
|
|
2972
|
+
res.writeHead(200, { ...HEADERS, "content-type": type });
|
|
2973
|
+
return void res.end(req.method === "HEAD" ? void 0 : data);
|
|
2974
|
+
}
|
|
2975
|
+
if (url.pathname === "/api/state") {
|
|
2976
|
+
const group = url.searchParams.get("group") ?? void 0;
|
|
2977
|
+
const limit = Number(url.searchParams.get("limit") ?? 40);
|
|
2978
|
+
return json(res, 200, dashState({ group: group && group.length <= 100 ? group : void 0, limit: Number.isFinite(limit) ? limit : 40 }));
|
|
2979
|
+
}
|
|
2980
|
+
if (url.pathname === "/api/history") {
|
|
2981
|
+
const q = url.searchParams;
|
|
2982
|
+
const days = Number(q.get("days") ?? 0);
|
|
2983
|
+
return json(res, 200, listHistory({
|
|
2984
|
+
text: (q.get("q") ?? "").slice(0, 200),
|
|
2985
|
+
model: q.get("model") || void 0,
|
|
2986
|
+
backend: q.get("backend") || void 0,
|
|
2987
|
+
state: q.get("state") || void 0,
|
|
2988
|
+
group: q.get("group") || void 0,
|
|
2989
|
+
sinceMs: days > 0 ? Date.now() - days * 864e5 : void 0,
|
|
2990
|
+
beforeId: RUN_ID2.test(q.get("before") ?? "") ? q.get("before") : void 0,
|
|
2991
|
+
limit: Number(q.get("limit") ?? 30) || 30
|
|
2992
|
+
}));
|
|
2993
|
+
}
|
|
2994
|
+
if (url.pathname === "/api/stats") {
|
|
2995
|
+
const days = Number(url.searchParams.get("days") ?? 30);
|
|
2996
|
+
return json(res, 200, historyStats(days > 0 ? Date.now() - days * 864e5 : void 0));
|
|
2997
|
+
}
|
|
2998
|
+
const m = /^\/api\/run\/([^/]+)$/.exec(url.pathname);
|
|
2999
|
+
if (m) {
|
|
3000
|
+
const d = runDetail(m[1]);
|
|
3001
|
+
return d ? json(res, 200, d) : json(res, 404, { error: "no such run" });
|
|
3002
|
+
}
|
|
3003
|
+
return json(res, 404, { error: "not found" });
|
|
3004
|
+
};
|
|
3005
|
+
}
|
|
3006
|
+
function startDash(opts) {
|
|
3007
|
+
let lastRequest = Date.now();
|
|
3008
|
+
const server = http.createServer(handler(() => lastRequest = Date.now()));
|
|
3009
|
+
return new Promise((resolve2, reject) => {
|
|
3010
|
+
server.once("error", reject);
|
|
3011
|
+
server.listen(opts.port, "127.0.0.1", () => {
|
|
3012
|
+
const port = server.address().port;
|
|
3013
|
+
let done;
|
|
3014
|
+
const closed = new Promise((r) => done = r);
|
|
3015
|
+
let timer;
|
|
3016
|
+
const close = () => {
|
|
3017
|
+
if (timer) clearInterval(timer);
|
|
3018
|
+
server.closeAllConnections?.();
|
|
3019
|
+
return new Promise((r) => server.close(() => (done(), r())));
|
|
3020
|
+
};
|
|
3021
|
+
if (opts.idleMs && opts.idleMs > 0) {
|
|
3022
|
+
timer = setInterval(() => Date.now() - lastRequest > opts.idleMs && void close(), Math.min(6e4, opts.idleMs));
|
|
3023
|
+
timer.unref();
|
|
2251
3024
|
}
|
|
2252
|
-
|
|
3025
|
+
resolve2({ port, url: `http://127.0.0.1:${port}/`, closed, close });
|
|
3026
|
+
});
|
|
3027
|
+
});
|
|
3028
|
+
}
|
|
3029
|
+
var registryFile = () => path15.join(home(), "dash.json");
|
|
3030
|
+
async function ping(port) {
|
|
3031
|
+
try {
|
|
3032
|
+
const r = await fetch(`http://127.0.0.1:${port}/api/state?limit=1`, { signal: AbortSignal.timeout(1500) });
|
|
3033
|
+
return r.ok;
|
|
3034
|
+
} catch {
|
|
3035
|
+
return false;
|
|
2253
3036
|
}
|
|
2254
|
-
|
|
2255
|
-
|
|
3037
|
+
}
|
|
3038
|
+
async function runningDash() {
|
|
3039
|
+
try {
|
|
3040
|
+
const r = JSON.parse(fs18.readFileSync(registryFile(), "utf8"));
|
|
3041
|
+
if (isAlive(r.pid) && await ping(r.port)) return { ...r, url: `http://127.0.0.1:${r.port}/` };
|
|
3042
|
+
} catch {
|
|
2256
3043
|
}
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
3044
|
+
fs18.rmSync(registryFile(), { force: true });
|
|
3045
|
+
return void 0;
|
|
3046
|
+
}
|
|
3047
|
+
function openBrowser(url) {
|
|
3048
|
+
const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
3049
|
+
try {
|
|
3050
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => void 0).unref();
|
|
3051
|
+
} catch {
|
|
2263
3052
|
}
|
|
2264
|
-
return out;
|
|
2265
3053
|
}
|
|
2266
|
-
function
|
|
2267
|
-
const
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
3054
|
+
async function dashCommand(o) {
|
|
3055
|
+
const existing = await runningDash();
|
|
3056
|
+
if (o.stop) {
|
|
3057
|
+
if (!existing) throw new UserError("pitroom dash is not running");
|
|
3058
|
+
process.kill(existing.pid, "SIGTERM");
|
|
3059
|
+
fs18.rmSync(registryFile(), { force: true });
|
|
3060
|
+
o.log(`stopped pitroom dash (${existing.url})`);
|
|
3061
|
+
return 0;
|
|
3062
|
+
}
|
|
3063
|
+
if (existing && !o.serve) {
|
|
3064
|
+
o.log(existing.url);
|
|
3065
|
+
if (o.open) openBrowser(existing.url);
|
|
3066
|
+
return 0;
|
|
3067
|
+
}
|
|
3068
|
+
if (o.detach) {
|
|
3069
|
+
const child = spawn(process.execPath, [process.argv[1], "dash", "--serve", ...o.port !== void 0 ? ["--port", String(o.port)] : [], "--idle", "4h"], {
|
|
3070
|
+
detached: true,
|
|
3071
|
+
stdio: "ignore",
|
|
3072
|
+
env: process.env
|
|
3073
|
+
});
|
|
3074
|
+
child.on("error", () => void 0);
|
|
3075
|
+
child.unref();
|
|
3076
|
+
for (let i = 0; i < 60; i++) {
|
|
3077
|
+
await new Promise((r2) => setTimeout(r2, 100));
|
|
3078
|
+
const r = await runningDash();
|
|
3079
|
+
if (r && r.pid === child.pid) {
|
|
3080
|
+
o.log(r.url);
|
|
3081
|
+
if (o.open) openBrowser(r.url);
|
|
3082
|
+
return 0;
|
|
3083
|
+
}
|
|
2279
3084
|
}
|
|
3085
|
+
throw new UserError("pitroom dash did not start (is the port taken? try --port 0)");
|
|
2280
3086
|
}
|
|
2281
|
-
|
|
3087
|
+
importRuns();
|
|
3088
|
+
const explicit = o.port !== void 0;
|
|
3089
|
+
let dash;
|
|
3090
|
+
try {
|
|
3091
|
+
dash = await startDash({ port: o.port ?? DEFAULT_PORT, idleMs: o.idleMs });
|
|
3092
|
+
} catch (e) {
|
|
3093
|
+
if (explicit || e.code !== "EADDRINUSE") throw new UserError(`pitroom dash: ${e.message}`);
|
|
3094
|
+
dash = await startDash({ port: 0, idleMs: o.idleMs });
|
|
3095
|
+
}
|
|
3096
|
+
fs18.mkdirSync(home(), { recursive: true });
|
|
3097
|
+
fs18.writeFileSync(registryFile(), JSON.stringify({ pid: process.pid, port: dash.port }));
|
|
3098
|
+
const stop = () => void dash.close();
|
|
3099
|
+
process.once("SIGINT", stop);
|
|
3100
|
+
process.once("SIGTERM", stop);
|
|
3101
|
+
if (!o.serve) o.log(`${dash.url}
|
|
3102
|
+
(read-only, this machine only; Ctrl-C stops it)`);
|
|
3103
|
+
if (o.open) openBrowser(dash.url);
|
|
3104
|
+
await dash.closed;
|
|
3105
|
+
try {
|
|
3106
|
+
if (JSON.parse(fs18.readFileSync(registryFile(), "utf8")).pid === process.pid) fs18.rmSync(registryFile(), { force: true });
|
|
3107
|
+
} catch {
|
|
3108
|
+
}
|
|
3109
|
+
return 0;
|
|
2282
3110
|
}
|
|
2283
3111
|
|
|
2284
3112
|
// src/core/run.ts
|
|
2285
|
-
import { spawn as
|
|
2286
|
-
import
|
|
2287
|
-
import
|
|
3113
|
+
import { spawn as spawn3, spawnSync as spawnSync5 } from "node:child_process";
|
|
3114
|
+
import fs23 from "node:fs";
|
|
3115
|
+
import path19 from "node:path";
|
|
2288
3116
|
|
|
2289
3117
|
// src/core/chain.ts
|
|
2290
3118
|
function resolveChain(flags = {}) {
|
|
@@ -2297,10 +3125,28 @@ function resolveChain(flags = {}) {
|
|
|
2297
3125
|
}
|
|
2298
3126
|
const eff = effective({ worker: spec, model: flags.model });
|
|
2299
3127
|
const models = eff.models.value;
|
|
2300
|
-
const withDefault = (t) =>
|
|
3128
|
+
const withDefault = (t) => {
|
|
3129
|
+
const pinned = models[t.backend];
|
|
3130
|
+
if (!pinned) return t;
|
|
3131
|
+
if (!t.model) return { ...t, model: pinned };
|
|
3132
|
+
return t.model.startsWith("#") ? { ...t, model: `${pinned}${t.model}` } : t;
|
|
3133
|
+
};
|
|
2301
3134
|
let worker = parseTarget(eff.worker.value, DEFAULT_BACKEND);
|
|
2302
3135
|
if (eff.model.value) worker = { ...worker, model: eff.model.value };
|
|
2303
3136
|
worker = withDefault(worker);
|
|
3137
|
+
if (flags.effort) {
|
|
3138
|
+
const base2 = (worker.model ?? "").split("#")[0];
|
|
3139
|
+
if (worker.backend === "opencode" && !base2) {
|
|
3140
|
+
warnings.push("--effort needs a model for OpenCode (provider/model#variant): set one in the config or pass -m; ignored");
|
|
3141
|
+
} else {
|
|
3142
|
+
worker = { ...worker, model: `${base2}#${flags.effort}` };
|
|
3143
|
+
}
|
|
3144
|
+
}
|
|
3145
|
+
if ((worker.backend === "codex" || worker.backend === "claude") && !(worker.model ?? "").replace(/#.*$/, "")) {
|
|
3146
|
+
warnings.push(
|
|
3147
|
+
`${worker.backend} has no pinned model, so it runs its own default (which can change and cost more): set "models": {"${worker.backend}": "<model>"} in the pitroom config, or pass -W ${worker.backend}:<model>`
|
|
3148
|
+
);
|
|
3149
|
+
}
|
|
2304
3150
|
const fallback = [];
|
|
2305
3151
|
if (!flags.noFallback) {
|
|
2306
3152
|
for (const s of eff.fallback.value) {
|
|
@@ -2317,12 +3163,12 @@ function resolveChain(flags = {}) {
|
|
|
2317
3163
|
}
|
|
2318
3164
|
|
|
2319
3165
|
// src/core/process.ts
|
|
2320
|
-
import { spawn } from "node:child_process";
|
|
2321
|
-
import
|
|
3166
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
3167
|
+
import fs20 from "node:fs";
|
|
2322
3168
|
|
|
2323
3169
|
// src/vcs/guard.ts
|
|
2324
|
-
import
|
|
2325
|
-
import
|
|
3170
|
+
import fs19 from "node:fs";
|
|
3171
|
+
import path16 from "node:path";
|
|
2326
3172
|
var VERSION = 1;
|
|
2327
3173
|
var SHIM = `#!/bin/sh
|
|
2328
3174
|
# pitroom git guard v${VERSION}. Blocks git commands that change history, refs, the
|
|
@@ -2397,15 +3243,15 @@ esac
|
|
|
2397
3243
|
exec "$real" "$@"
|
|
2398
3244
|
`;
|
|
2399
3245
|
function shimDir() {
|
|
2400
|
-
return
|
|
3246
|
+
return path16.join(home(), "shim", `v${VERSION}`);
|
|
2401
3247
|
}
|
|
2402
3248
|
function findRealGit(skip) {
|
|
2403
|
-
for (const dir of (process.env.PATH ?? "").split(
|
|
2404
|
-
if (
|
|
2405
|
-
const p =
|
|
3249
|
+
for (const dir of (process.env.PATH ?? "").split(path16.delimiter).filter(Boolean)) {
|
|
3250
|
+
if (path16.resolve(dir) === skip) continue;
|
|
3251
|
+
const p = path16.join(dir, "git");
|
|
2406
3252
|
try {
|
|
2407
|
-
|
|
2408
|
-
if (
|
|
3253
|
+
fs19.accessSync(p, fs19.constants.X_OK);
|
|
3254
|
+
if (fs19.statSync(p).isFile()) return p;
|
|
2409
3255
|
} catch {
|
|
2410
3256
|
}
|
|
2411
3257
|
}
|
|
@@ -2418,13 +3264,13 @@ var REF_HOOK = `#!/bin/sh
|
|
|
2418
3264
|
echo "pitroom: git ref updates (commit, reset, branch, tag, stash, rebase, merge) are blocked for workers" >&2
|
|
2419
3265
|
exit 1
|
|
2420
3266
|
`;
|
|
2421
|
-
var hooksDir = () =>
|
|
3267
|
+
var hooksDir = () => path16.join(home(), "git-hooks", `v${HOOK_VERSION}`);
|
|
2422
3268
|
function writeIfChanged(file, content) {
|
|
2423
|
-
if (
|
|
2424
|
-
|
|
3269
|
+
if (fs19.existsSync(file) && fs19.readFileSync(file, "utf8") === content) return;
|
|
3270
|
+
fs19.mkdirSync(path16.dirname(file), { recursive: true });
|
|
2425
3271
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
2426
|
-
|
|
2427
|
-
|
|
3272
|
+
fs19.writeFileSync(tmp, content, { mode: 493 });
|
|
3273
|
+
fs19.renameSync(tmp, file);
|
|
2428
3274
|
}
|
|
2429
3275
|
function withGitConfig(env, entries) {
|
|
2430
3276
|
const out = { ...env };
|
|
@@ -2446,7 +3292,7 @@ function guardEnv(env) {
|
|
|
2446
3292
|
PAGER: "cat"
|
|
2447
3293
|
};
|
|
2448
3294
|
if (process.platform === "win32") return { ...env, ...quiet };
|
|
2449
|
-
writeIfChanged(
|
|
3295
|
+
writeIfChanged(path16.join(hooksDir(), "reference-transaction"), REF_HOOK);
|
|
2450
3296
|
env = withGitConfig(env, [
|
|
2451
3297
|
["core.hooksPath", hooksDir()],
|
|
2452
3298
|
["url.pitroom-push-blocked://.pushInsteadOf", ""]
|
|
@@ -2454,16 +3300,16 @@ function guardEnv(env) {
|
|
|
2454
3300
|
const dir = shimDir();
|
|
2455
3301
|
const real = findRealGit(dir);
|
|
2456
3302
|
if (!real) return { ...env, ...quiet };
|
|
2457
|
-
writeIfChanged(
|
|
2458
|
-
return { ...env, ...quiet, PITROOM_REAL_GIT: real, PATH: `${dir}${
|
|
3303
|
+
writeIfChanged(path16.join(dir, "git"), SHIM);
|
|
3304
|
+
return { ...env, ...quiet, PITROOM_REAL_GIT: real, PATH: `${dir}${path16.delimiter}${env.PATH ?? ""}` };
|
|
2459
3305
|
}
|
|
2460
3306
|
|
|
2461
3307
|
// src/core/process.ts
|
|
2462
3308
|
async function spawnWorker(inv, opts) {
|
|
2463
|
-
const out =
|
|
2464
|
-
const err =
|
|
3309
|
+
const out = fs20.openSync(opts.stdoutFile, "w");
|
|
3310
|
+
const err = fs20.openSync(opts.stderrFile, "w");
|
|
2465
3311
|
const res = { code: null, timedOut: false, stopped: false };
|
|
2466
|
-
const child =
|
|
3312
|
+
const child = spawn2(inv.command, inv.args, {
|
|
2467
3313
|
cwd: opts.cwd,
|
|
2468
3314
|
stdio: ["ignore", out, err],
|
|
2469
3315
|
// OpenCode v2 takes its workspace from $PWD, not the process cwd: without this a worker
|
|
@@ -2493,8 +3339,8 @@ async function spawnWorker(inv, opts) {
|
|
|
2493
3339
|
if (killer) clearTimeout(killer);
|
|
2494
3340
|
process.off("SIGINT", stop);
|
|
2495
3341
|
process.off("SIGTERM", stop);
|
|
2496
|
-
|
|
2497
|
-
|
|
3342
|
+
fs20.closeSync(out);
|
|
3343
|
+
fs20.closeSync(err);
|
|
2498
3344
|
return res;
|
|
2499
3345
|
}
|
|
2500
3346
|
|
|
@@ -2519,11 +3365,11 @@ function parseVerdict(text) {
|
|
|
2519
3365
|
}
|
|
2520
3366
|
|
|
2521
3367
|
// src/core/slots.ts
|
|
2522
|
-
import
|
|
2523
|
-
import
|
|
2524
|
-
import
|
|
2525
|
-
var slotsDir = () =>
|
|
2526
|
-
var locksDir = () =>
|
|
3368
|
+
import crypto6 from "node:crypto";
|
|
3369
|
+
import fs21 from "node:fs";
|
|
3370
|
+
import path17 from "node:path";
|
|
3371
|
+
var slotsDir = () => path17.join(home(), "slots");
|
|
3372
|
+
var locksDir = () => path17.join(home(), "locks");
|
|
2527
3373
|
var STARTUP_GRACE_MS = 3e4;
|
|
2528
3374
|
function holderActive(runId, self) {
|
|
2529
3375
|
if (!runId || runId === self) return false;
|
|
@@ -2537,23 +3383,23 @@ function holderActive(runId, self) {
|
|
|
2537
3383
|
}
|
|
2538
3384
|
}
|
|
2539
3385
|
function tryClaim(file, runId) {
|
|
2540
|
-
|
|
3386
|
+
fs21.mkdirSync(path17.dirname(file), { recursive: true });
|
|
2541
3387
|
try {
|
|
2542
|
-
|
|
3388
|
+
fs21.writeFileSync(file, runId, { flag: "wx" });
|
|
2543
3389
|
return true;
|
|
2544
3390
|
} catch (e) {
|
|
2545
3391
|
if (e.code !== "EEXIST") throw e;
|
|
2546
3392
|
}
|
|
2547
3393
|
let owner = "";
|
|
2548
3394
|
try {
|
|
2549
|
-
owner =
|
|
3395
|
+
owner = fs21.readFileSync(file, "utf8").trim();
|
|
2550
3396
|
} catch {
|
|
2551
3397
|
}
|
|
2552
3398
|
if (owner === runId) return true;
|
|
2553
3399
|
if (holderActive(owner, runId)) return false;
|
|
2554
|
-
|
|
3400
|
+
fs21.rmSync(file, { force: true });
|
|
2555
3401
|
try {
|
|
2556
|
-
|
|
3402
|
+
fs21.writeFileSync(file, runId, { flag: "wx" });
|
|
2557
3403
|
return true;
|
|
2558
3404
|
} catch {
|
|
2559
3405
|
return false;
|
|
@@ -2562,25 +3408,25 @@ function tryClaim(file, runId) {
|
|
|
2562
3408
|
function releaseIfOwner(file, runId) {
|
|
2563
3409
|
if (!file) return;
|
|
2564
3410
|
try {
|
|
2565
|
-
if (
|
|
3411
|
+
if (fs21.readFileSync(file, "utf8").trim() === runId) fs21.rmSync(file, { force: true });
|
|
2566
3412
|
} catch {
|
|
2567
3413
|
}
|
|
2568
3414
|
}
|
|
2569
3415
|
function tryAcquireSlot(runId, maxParallel) {
|
|
2570
3416
|
for (let n = 0; n < Math.max(1, maxParallel); n++) {
|
|
2571
|
-
const file =
|
|
3417
|
+
const file = path17.join(slotsDir(), `slot-${n}`);
|
|
2572
3418
|
if (tryClaim(file, runId)) return file;
|
|
2573
3419
|
}
|
|
2574
3420
|
return void 0;
|
|
2575
3421
|
}
|
|
2576
3422
|
var releaseSlot = (file, runId) => releaseIfOwner(file, runId);
|
|
2577
|
-
var lockFile = (repoRoot2) =>
|
|
3423
|
+
var lockFile = (repoRoot2) => path17.join(locksDir(), `write-${crypto6.createHash("sha1").update(path17.resolve(repoRoot2)).digest("hex").slice(0, 16)}`);
|
|
2578
3424
|
function acquireWriteLock(repoRoot2, runId) {
|
|
2579
3425
|
const file = lockFile(repoRoot2);
|
|
2580
3426
|
if (tryClaim(file, runId)) return;
|
|
2581
3427
|
let owner = "";
|
|
2582
3428
|
try {
|
|
2583
|
-
owner =
|
|
3429
|
+
owner = fs21.readFileSync(file, "utf8").trim();
|
|
2584
3430
|
} catch {
|
|
2585
3431
|
}
|
|
2586
3432
|
throw new UserError(`another --write run (${owner}) is active in this repo; use --isolate for parallel changes`, 3);
|
|
@@ -2627,8 +3473,8 @@ ${task}`;
|
|
|
2627
3473
|
}
|
|
2628
3474
|
|
|
2629
3475
|
// src/core/refs.ts
|
|
2630
|
-
import
|
|
2631
|
-
import
|
|
3476
|
+
import fs22 from "node:fs";
|
|
3477
|
+
import path18 from "node:path";
|
|
2632
3478
|
var EXTENSIONLESS = "Makefile|Dockerfile|Containerfile|Gemfile|Rakefile|Procfile|Justfile|Vagrantfile|BUILD|WORKSPACE";
|
|
2633
3479
|
var REF = new RegExp(
|
|
2634
3480
|
String.raw`(?<![\w/:.-])(\.{0,2}/?(?:[\w@.+-]+/)*(?:[\w@+-][\w@.+-]*\.[A-Za-z][A-Za-z0-9]{0,7}|${EXTENSIONLESS})):(\d+)(?:[-–](\d+))?`,
|
|
@@ -2674,13 +3520,13 @@ function extractRefs(answer) {
|
|
|
2674
3520
|
return [...seen.values()];
|
|
2675
3521
|
}
|
|
2676
3522
|
function verifyRefs(refs, dirs) {
|
|
2677
|
-
const roots = [...new Set(dirs.filter(Boolean).map((d) =>
|
|
3523
|
+
const roots = [...new Set(dirs.filter(Boolean).map((d) => path18.resolve(d)))];
|
|
2678
3524
|
const invalid = [];
|
|
2679
3525
|
const lineCache = /* @__PURE__ */ new Map();
|
|
2680
3526
|
const load = (file) => {
|
|
2681
3527
|
if (!lineCache.has(file)) {
|
|
2682
|
-
const size =
|
|
2683
|
-
const lines = size > MAX_BYTES ? null :
|
|
3528
|
+
const size = fs22.statSync(file).size;
|
|
3529
|
+
const lines = size > MAX_BYTES ? null : fs22.readFileSync(file, "utf8").split("\n");
|
|
2684
3530
|
if (lines && lines[lines.length - 1] === "") lines.pop();
|
|
2685
3531
|
lineCache.set(file, lines);
|
|
2686
3532
|
}
|
|
@@ -2689,7 +3535,7 @@ function verifyRefs(refs, dirs) {
|
|
|
2689
3535
|
for (const ref of refs) {
|
|
2690
3536
|
const file = resolve(ref.file, roots);
|
|
2691
3537
|
if (!file) {
|
|
2692
|
-
invalid.push({ ref: ref.text, reason:
|
|
3538
|
+
invalid.push({ ref: ref.text, reason: path18.isAbsolute(ref.file) && !inside(ref.file, roots) ? "outside the project" : "file not found" });
|
|
2693
3539
|
continue;
|
|
2694
3540
|
}
|
|
2695
3541
|
const lines = load(file);
|
|
@@ -2716,10 +3562,10 @@ function enclosedBy(lines, ref) {
|
|
|
2716
3562
|
return lines.slice(Math.max(0, ref.start - 1 - DEFINITION_LOOKBACK), ref.start).some((l) => def.test(l));
|
|
2717
3563
|
}
|
|
2718
3564
|
function resolve(ref, roots) {
|
|
2719
|
-
const candidates =
|
|
3565
|
+
const candidates = path18.isAbsolute(ref) ? inside(ref, roots) ? [ref] : [] : roots.map((r) => path18.join(r, ref));
|
|
2720
3566
|
return candidates.find((c) => {
|
|
2721
3567
|
try {
|
|
2722
|
-
return
|
|
3568
|
+
return fs22.statSync(c).isFile() && inside(c, roots);
|
|
2723
3569
|
} catch {
|
|
2724
3570
|
return false;
|
|
2725
3571
|
}
|
|
@@ -2727,14 +3573,14 @@ function resolve(ref, roots) {
|
|
|
2727
3573
|
}
|
|
2728
3574
|
function inside(file, roots) {
|
|
2729
3575
|
return roots.some((r) => {
|
|
2730
|
-
const rel =
|
|
2731
|
-
return rel !== "" && !rel.startsWith("..") && !
|
|
3576
|
+
const rel = path18.relative(r, path18.resolve(file));
|
|
3577
|
+
return rel !== "" && !rel.startsWith("..") && !path18.isAbsolute(rel);
|
|
2732
3578
|
});
|
|
2733
3579
|
}
|
|
2734
3580
|
var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2735
3581
|
|
|
2736
3582
|
// src/core/run.ts
|
|
2737
|
-
var VERSION2 = true ? "0.6.
|
|
3583
|
+
var VERSION2 = true ? "0.6.6" : "0.0.0-dev";
|
|
2738
3584
|
function planWork(o) {
|
|
2739
3585
|
if (!o.plan) return { task: o.task.trim(), tier: o.tier };
|
|
2740
3586
|
if (o.continueFrom) throw new UserError("a follow-up continues its parent's task; drop --plan/--step");
|
|
@@ -2764,16 +3610,16 @@ function prepareRun(o) {
|
|
|
2764
3610
|
let fallback;
|
|
2765
3611
|
if (o.continueFrom) {
|
|
2766
3612
|
parent = readMeta(o.continueFrom);
|
|
2767
|
-
if (o.worker || o.tier) throw new UserError("a follow-up runs on the same worker as its parent; drop --worker/--tier");
|
|
3613
|
+
if (o.worker || o.tier || o.effort) throw new UserError("a follow-up runs on the same worker as its parent; drop --worker/--tier/--effort");
|
|
2768
3614
|
if (!parent.sessionId) throw new UserError(`run ${parent.id} has no worker session to continue`, 3);
|
|
2769
|
-
if (parent.mode === "isolate" && (!parent.worktree || !
|
|
3615
|
+
if (parent.mode === "isolate" && (!parent.worktree || !fs23.existsSync(parent.worktree))) {
|
|
2770
3616
|
throw new UserError(`the isolated copy of run ${parent.id} is gone (applied or discarded)`, 3);
|
|
2771
3617
|
}
|
|
2772
3618
|
const ran = parent.ran ?? parent.worker;
|
|
2773
3619
|
worker = o.model ? { ...ran, model: o.model } : ran;
|
|
2774
3620
|
fallback = o.noFallback ? [] : parent.fallback.filter((t) => t.backend === ran.backend);
|
|
2775
3621
|
} else {
|
|
2776
|
-
const chain = resolveChain({ worker: o.worker, model: o.model, tier: work.tier, noFallback: o.noFallback });
|
|
3622
|
+
const chain = resolveChain({ worker: o.worker, model: o.model, tier: work.tier, effort: o.effort, noFallback: o.noFallback });
|
|
2777
3623
|
({ worker, fallback } = chain);
|
|
2778
3624
|
warnings.push(...chain.warnings);
|
|
2779
3625
|
}
|
|
@@ -2785,15 +3631,15 @@ function prepareRun(o) {
|
|
|
2785
3631
|
throw new UserError(`the ${backend.name} worker cannot attach files; put the content in the task`);
|
|
2786
3632
|
}
|
|
2787
3633
|
const mode = parent?.mode ?? o.mode;
|
|
2788
|
-
const dir =
|
|
2789
|
-
if (!
|
|
3634
|
+
const dir = path19.resolve(parent?.dir ?? o.dir);
|
|
3635
|
+
if (!fs23.existsSync(dir) || !fs23.statSync(dir).isDirectory()) throw new UserError(`not a directory: ${dir}`);
|
|
2790
3636
|
const root = repoRoot(dir);
|
|
2791
3637
|
if (mode === "isolate" && !root) throw new UserError("--isolate needs a git repository", 3);
|
|
2792
3638
|
if (mode === "write" && !root && !o.allowNonGit) {
|
|
2793
3639
|
throw new UserError("--write outside a git repo cannot be tracked or reverted; pass --allow-non-git to accept that", 3);
|
|
2794
3640
|
}
|
|
2795
|
-
const files = o.files.map((f) =>
|
|
2796
|
-
for (const f of files) if (!
|
|
3641
|
+
const files = o.files.map((f) => path19.resolve(f));
|
|
3642
|
+
for (const f of files) if (!fs23.existsSync(f)) throw new UserError(`file not found: ${f}`);
|
|
2797
3643
|
const meta = {
|
|
2798
3644
|
id: newRunId(),
|
|
2799
3645
|
version: VERSION2,
|
|
@@ -2828,13 +3674,13 @@ function prepareRun(o) {
|
|
|
2828
3674
|
try {
|
|
2829
3675
|
acquireWriteLock(root, meta.id);
|
|
2830
3676
|
} catch (e) {
|
|
2831
|
-
|
|
3677
|
+
fs23.rmSync(runDir(meta.id), { recursive: true, force: true });
|
|
2832
3678
|
throw e;
|
|
2833
3679
|
}
|
|
2834
3680
|
}
|
|
2835
|
-
|
|
3681
|
+
fs23.writeFileSync(runFile(meta.id, "task.md"), `${meta.task}
|
|
2836
3682
|
`);
|
|
2837
|
-
if (work.brief)
|
|
3683
|
+
if (work.brief) fs23.writeFileSync(runFile(meta.id, "brief.md"), work.brief);
|
|
2838
3684
|
return meta;
|
|
2839
3685
|
}
|
|
2840
3686
|
var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -2862,7 +3708,7 @@ async function waitForSlot(meta) {
|
|
|
2862
3708
|
function startInBackground(meta) {
|
|
2863
3709
|
const script = process.argv[1];
|
|
2864
3710
|
if (!script) throw new Error("cannot locate the pitroom executable");
|
|
2865
|
-
const child =
|
|
3711
|
+
const child = spawn3(process.execPath, [script, "__exec", meta.id], {
|
|
2866
3712
|
detached: true,
|
|
2867
3713
|
stdio: "ignore",
|
|
2868
3714
|
cwd: meta.dir,
|
|
@@ -2907,7 +3753,7 @@ async function execute(meta) {
|
|
|
2907
3753
|
(meta.attempts ??= []).push({ target: describeTarget(target), error: why.message });
|
|
2908
3754
|
for (const f of ["events.jsonl", "stderr.log"]) {
|
|
2909
3755
|
const from = runFile(meta.id, f);
|
|
2910
|
-
if (
|
|
3756
|
+
if (fs23.existsSync(from)) fs23.renameSync(from, runFile(meta.id, f.replace(".", `.attempt-${i + 1}.`)));
|
|
2911
3757
|
}
|
|
2912
3758
|
writeMeta(meta);
|
|
2913
3759
|
}
|
|
@@ -2927,9 +3773,9 @@ function prepareTree(meta) {
|
|
|
2927
3773
|
if (root && meta.mode === "write") meta.baseTree = snapshotTree(root);
|
|
2928
3774
|
if (root && meta.mode === "isolate" && !meta.worktree) {
|
|
2929
3775
|
meta.baseTree = snapshotTree(root);
|
|
2930
|
-
meta.worktree =
|
|
3776
|
+
meta.worktree = path19.join(worktreesDir(), meta.id);
|
|
2931
3777
|
createIsolatedCopy(root, meta.baseTree, meta.worktree);
|
|
2932
|
-
meta.cwd =
|
|
3778
|
+
meta.cwd = path19.join(meta.worktree, path19.relative(root, meta.dir));
|
|
2933
3779
|
const linked = linkIntoWorktree(root, meta.worktree, meta.link);
|
|
2934
3780
|
if (linked.length) meta.warnings.push(`linked into the isolated copy (shared with your tree): ${linked.join(", ")}`);
|
|
2935
3781
|
}
|
|
@@ -2952,7 +3798,7 @@ function attempt(meta, backend, target) {
|
|
|
2952
3798
|
timeoutSec: meta.timeoutSec
|
|
2953
3799
|
});
|
|
2954
3800
|
}
|
|
2955
|
-
var read = (f) =>
|
|
3801
|
+
var read = (f) => fs23.existsSync(f) ? fs23.readFileSync(f, "utf8") : "";
|
|
2956
3802
|
function retryableFailure(meta, backend, res) {
|
|
2957
3803
|
const run = backend.parse(read(runFile(meta.id, "events.jsonl")));
|
|
2958
3804
|
if (run.usage.steps > 0) return void 0;
|
|
@@ -2971,12 +3817,12 @@ function finalize(meta, res) {
|
|
|
2971
3817
|
const run = backend.parse(read(runFile(meta.id, "events.jsonl")));
|
|
2972
3818
|
meta.sessionId = run.sessionId ?? meta.sessionId;
|
|
2973
3819
|
meta.usage = run.usage;
|
|
2974
|
-
|
|
3820
|
+
fs23.writeFileSync(runFile(meta.id, "summary.md"), `${run.finalText}
|
|
2975
3821
|
`);
|
|
2976
3822
|
if (meta.plan && !meta.reviewOf) meta.taskStatus = parseStatus(run.finalText);
|
|
2977
3823
|
if (meta.reviewOf) {
|
|
2978
3824
|
meta.verdict = parseVerdict(run.finalText);
|
|
2979
|
-
if (meta.packageFile)
|
|
3825
|
+
if (meta.packageFile) fs23.rmSync(meta.packageFile, { force: true });
|
|
2980
3826
|
}
|
|
2981
3827
|
if (!res.timedOut && !res.stopped && !meta.error) {
|
|
2982
3828
|
const f = backend.failure(run, read(runFile(meta.id, "stderr.log")), res.code);
|
|
@@ -3018,7 +3864,7 @@ function captureChanges(meta) {
|
|
|
3018
3864
|
const d = diffTrees(target, meta.baseTree, meta.afterTree);
|
|
3019
3865
|
meta.changes = d.changes;
|
|
3020
3866
|
meta.stats = d.stats;
|
|
3021
|
-
|
|
3867
|
+
fs23.writeFileSync(runFile(meta.id, "changes.patch"), d.patch);
|
|
3022
3868
|
}
|
|
3023
3869
|
function runVerify(meta) {
|
|
3024
3870
|
const r = spawnSync5(meta.verify, {
|
|
@@ -3031,7 +3877,7 @@ function runVerify(meta) {
|
|
|
3031
3877
|
env: { ...process.env, PWD: meta.cwd, PITROOM_ACTIVE: "1" }
|
|
3032
3878
|
});
|
|
3033
3879
|
const output = `${r.stdout ?? ""}${r.stderr ?? ""}`;
|
|
3034
|
-
|
|
3880
|
+
fs23.writeFileSync(runFile(meta.id, "verify.log"), output);
|
|
3035
3881
|
return { ok: r.status === 0, code: r.status, tail: output.trimEnd().split("\n").slice(-25).join("\n") };
|
|
3036
3882
|
}
|
|
3037
3883
|
function applyRun(meta, allowDelete = false) {
|
|
@@ -3076,7 +3922,7 @@ function discardRun(meta) {
|
|
|
3076
3922
|
return `discarded the isolated copy of ${meta.id}; the patch stays in ${runFile(meta.id, "changes.patch")}`;
|
|
3077
3923
|
}
|
|
3078
3924
|
function cleanupWorktree(meta) {
|
|
3079
|
-
if (meta.worktree &&
|
|
3925
|
+
if (meta.worktree && fs23.existsSync(meta.worktree)) removeIsolatedCopy(meta.worktree, worktreesDir());
|
|
3080
3926
|
}
|
|
3081
3927
|
|
|
3082
3928
|
// src/cli/commands.ts
|
|
@@ -3117,7 +3963,7 @@ async function cmdReview(p) {
|
|
|
3117
3963
|
group: flag(p, "group") ?? job.group,
|
|
3118
3964
|
review: { of: job.of, kind: job.kind, packageFile, plan: job.plan }
|
|
3119
3965
|
});
|
|
3120
|
-
const automatic = !range && !flag(p, "worker") && !flag(p, "tier");
|
|
3966
|
+
const automatic = !range && !flag(p, "worker") && !flag(p, "tier") && !effective().tiers.value.review;
|
|
3121
3967
|
if (automatic && job.implementer && meta.worker.backend === job.implementer.backend) {
|
|
3122
3968
|
meta.warnings.push(
|
|
3123
3969
|
`reviewer runs on the same backend as the implementer (${job.implementer.backend}); configure tiers "standard" or "capable" for a second model`
|
|
@@ -3125,10 +3971,10 @@ async function cmdReview(p) {
|
|
|
3125
3971
|
writeMeta(meta);
|
|
3126
3972
|
}
|
|
3127
3973
|
} catch (e) {
|
|
3128
|
-
|
|
3974
|
+
fs24.rmSync(packageFile, { force: true });
|
|
3129
3975
|
throw e;
|
|
3130
3976
|
}
|
|
3131
|
-
|
|
3977
|
+
fs24.writeFileSync(runFile(meta.id, "package.md"), job.package);
|
|
3132
3978
|
return launch(p, meta);
|
|
3133
3979
|
}
|
|
3134
3980
|
function cmdPlan(p) {
|
|
@@ -3148,7 +3994,7 @@ function cmdPlan(p) {
|
|
|
3148
3994
|
}
|
|
3149
3995
|
function cmdCrew(p) {
|
|
3150
3996
|
const file = flag(p, "task-file");
|
|
3151
|
-
const tasks = (file ?
|
|
3997
|
+
const tasks = (file ? fs24.readFileSync(file, "utf8").split(/^\s*---\s*$/m) : p.positional).map((t) => t.trim()).filter(Boolean);
|
|
3152
3998
|
if (!tasks.length) throw new UserError('crew needs tasks: pitroom crew -g NAME "task 1" "task 2" (or --task-file with --- separators)');
|
|
3153
3999
|
if (has(p, "write") && tasks.length > 1) {
|
|
3154
4000
|
throw new UserError("parallel --write runs would edit the same tree; use --isolate (each worker gets its own isolated copy)");
|
|
@@ -3216,8 +4062,10 @@ async function cmdWatch(p) {
|
|
|
3216
4062
|
console.log(group ? `no runs in group "${group}"` : "nothing is running");
|
|
3217
4063
|
return 0;
|
|
3218
4064
|
}
|
|
4065
|
+
const brief2 = has(p, "brief");
|
|
3219
4066
|
const { metas, timedOut } = await watch(group ? () => groupIds(group) : () => initial, {
|
|
3220
|
-
json: has(p, "json") || !process.stdout.isTTY,
|
|
4067
|
+
json: !brief2 && (has(p, "json") || !process.stdout.isTTY),
|
|
4068
|
+
brief: brief2,
|
|
3221
4069
|
intervalMs: parseDuration(flag(p, "interval") ?? "2") * 1e3,
|
|
3222
4070
|
timeoutMs: flag(p, "timeout") ? parseDuration(flag(p, "timeout")) * 1e3 : 0,
|
|
3223
4071
|
write: (s) => process.stdout.write(s)
|
|
@@ -3225,11 +4073,62 @@ async function cmdWatch(p) {
|
|
|
3225
4073
|
if (timedOut) return 75;
|
|
3226
4074
|
return metas.some((m) => m.state !== "done") ? 1 : 0;
|
|
3227
4075
|
}
|
|
4076
|
+
async function cmdDash(p) {
|
|
4077
|
+
const port = flag(p, "port");
|
|
4078
|
+
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)");
|
|
4079
|
+
return dashCommand({
|
|
4080
|
+
port: port === void 0 ? void 0 : Number(port),
|
|
4081
|
+
idleMs: flag(p, "idle") ? parseDuration(flag(p, "idle")) * 1e3 : void 0,
|
|
4082
|
+
detach: has(p, "detach"),
|
|
4083
|
+
stop: has(p, "stop"),
|
|
4084
|
+
open: has(p, "open"),
|
|
4085
|
+
serve: has(p, "serve"),
|
|
4086
|
+
log: (s) => console.log(s)
|
|
4087
|
+
});
|
|
4088
|
+
}
|
|
4089
|
+
var when = (iso) => new Date(iso).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
|
4090
|
+
var secs = (s) => s == null ? "-" : s >= 3600 ? `${Math.floor(s / 3600)}h${String(Math.floor(s % 3600 / 60)).padStart(2, "0")}m` : s >= 60 ? `${Math.floor(s / 60)}m${String(Math.round(s % 60)).padStart(2, "0")}s` : `${Math.round(s)}s`;
|
|
4091
|
+
function cmdHistory(p) {
|
|
4092
|
+
if (!openDb()) throw new UserError("the history needs Node.js 22.13+ (node:sqlite)", 3);
|
|
4093
|
+
const sub = p.positional[0];
|
|
4094
|
+
if (sub === "import") {
|
|
4095
|
+
const r = importRuns();
|
|
4096
|
+
console.log(`imported ${r.imported} run(s); ${r.known} were already in the history (${historyFile()})`);
|
|
4097
|
+
return 0;
|
|
4098
|
+
}
|
|
4099
|
+
importRuns();
|
|
4100
|
+
const since = sinceMs(flag(p, "since"));
|
|
4101
|
+
if (sub === "stats") {
|
|
4102
|
+
const s = historyStats(since);
|
|
4103
|
+
if (has(p, "json")) return console.log(JSON.stringify(s, null, 2)), 0;
|
|
4104
|
+
const t = s.totals;
|
|
4105
|
+
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`);
|
|
4106
|
+
const rows2 = s.byWorker.map((w) => [`${w.backend}${w.model ? `:${w.model}` : ""}`, String(w.runs), `${Math.round(100 * w.ok / w.runs)}%`, secs(w.avgSeconds), w.avgTokens ? `${Math.round(w.avgTokens / 1e3)}k` : "-", `~${usd(w.saved)}`]);
|
|
4107
|
+
const head2 = ["WORKER", "RUNS", "OK", "AVG TIME", "AVG TOKENS", "SAVED"];
|
|
4108
|
+
const widths2 = head2.map((h, i) => Math.max(h.length, ...rows2.map((r) => r[i].length)));
|
|
4109
|
+
const fmt2 = (r) => r.map((c, i) => i === 0 ? c.padEnd(widths2[i]) : c.padStart(widths2[i])).join(" ");
|
|
4110
|
+
if (rows2.length) console.log(`
|
|
4111
|
+
${[fmt2(head2), ...rows2.map(fmt2)].join("\n")}`);
|
|
4112
|
+
return 0;
|
|
4113
|
+
}
|
|
4114
|
+
const limit = flag(p, "limit") ? Number(flag(p, "limit")) : 20;
|
|
4115
|
+
const { rows, total } = listHistory({ text: p.positional.join(" "), model: flag(p, "model"), state: flag(p, "state"), group: flag(p, "group"), sinceMs: since, limit });
|
|
4116
|
+
if (has(p, "json")) return console.log(JSON.stringify({ total, rows }, null, 2)), 0;
|
|
4117
|
+
if (!rows.length) return console.log(total ? "nothing on this page" : "no matching runs"), 0;
|
|
4118
|
+
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]);
|
|
4119
|
+
const head = ["RUN", "WHEN", "STATE", "WORKER", "TIME", "TASK"];
|
|
4120
|
+
const widths = head.map((h, i) => Math.max(h.length, ...body.map((r) => r[i].length)));
|
|
4121
|
+
const fmt = (r) => r.map((c, i) => i === r.length - 1 ? c : c.padEnd(widths[i])).join(" ");
|
|
4122
|
+
console.log([fmt(head), ...body.map(fmt)].join("\n"));
|
|
4123
|
+
if (total > rows.length) console.log(`
|
|
4124
|
+
${rows.length} of ${total} \xB7 --limit N for more \xB7 pitroom show <run> for one`);
|
|
4125
|
+
return 0;
|
|
4126
|
+
}
|
|
3228
4127
|
function cmdShow(p) {
|
|
3229
4128
|
const meta = freshMeta(resolveRun(p.positional[0]));
|
|
3230
4129
|
const dump = (name) => {
|
|
3231
|
-
const
|
|
3232
|
-
process.stdout.write(
|
|
4130
|
+
const text = readRunFile(meta.id, name) ?? (name === "changes.patch" ? archivedRun(meta.id)?.patch : void 0);
|
|
4131
|
+
process.stdout.write(text ?? `(no ${name}${name === "events.jsonl" ? ": the raw stream was removed, the history keeps the steps" : ""})
|
|
3233
4132
|
`);
|
|
3234
4133
|
};
|
|
3235
4134
|
if (has(p, "patch")) dump("changes.patch");
|
|
@@ -3293,7 +4192,7 @@ function sinceMs(s) {
|
|
|
3293
4192
|
if (!m) throw new UserError("--since takes 7d, 30d, \u2026 or all");
|
|
3294
4193
|
return Date.now() - Number(m[1]) * 864e5;
|
|
3295
4194
|
}
|
|
3296
|
-
var readStdin = () => process.stdin.isTTY ? "" :
|
|
4195
|
+
var readStdin = () => process.stdin.isTTY ? "" : fs24.readFileSync(0, "utf8");
|
|
3297
4196
|
function cmdStatusline(p) {
|
|
3298
4197
|
const input = readStdin();
|
|
3299
4198
|
const lines = [];
|
|
@@ -3313,14 +4212,42 @@ function cmdStatusline(p) {
|
|
|
3313
4212
|
function cmdHookCard() {
|
|
3314
4213
|
try {
|
|
3315
4214
|
const cards = hookCards(readStdin());
|
|
3316
|
-
if (cards)
|
|
4215
|
+
if (cards) {
|
|
4216
|
+
console.log(
|
|
4217
|
+
JSON.stringify({
|
|
4218
|
+
systemMessage: cards,
|
|
4219
|
+
hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: `Pitroom, for the user:
|
|
4220
|
+
${cards}` }
|
|
4221
|
+
})
|
|
4222
|
+
);
|
|
4223
|
+
}
|
|
3317
4224
|
} catch {
|
|
3318
4225
|
}
|
|
3319
4226
|
return 0;
|
|
3320
4227
|
}
|
|
4228
|
+
function cmdModels(p) {
|
|
4229
|
+
const backend = p.positional[0];
|
|
4230
|
+
const table2 = modelTable({ backend, all: has(p, "all") });
|
|
4231
|
+
console.log(has(p, "json") ? JSON.stringify(table2, null, 2) : formatModels(table2));
|
|
4232
|
+
return 0;
|
|
4233
|
+
}
|
|
3321
4234
|
function cmdSavings(p) {
|
|
3322
4235
|
const since = flag(p, "since") ?? "all";
|
|
3323
4236
|
const t = totals(readLedger(sinceMs(since)));
|
|
4237
|
+
if (has(p, "models")) {
|
|
4238
|
+
const by = /* @__PURE__ */ new Map();
|
|
4239
|
+
for (const e of readLedger(sinceMs(since))) {
|
|
4240
|
+
const key = `${e.backend ?? "?"} ${e.model ?? "(default model)"}`;
|
|
4241
|
+
const a = by.get(key) ?? { runs: 0, tokens: 0, returned: 0, cost: 0, saved: 0 };
|
|
4242
|
+
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 });
|
|
4243
|
+
}
|
|
4244
|
+
const width = Math.max(12, ...[...by.keys()].map((k) => k.length));
|
|
4245
|
+
console.log(`${"worker model".padEnd(width)} ${"runs".padStart(5)} ${"processed".padStart(10)} ${"returned".padStart(9)} ${"cost".padStart(8)} ${"saved".padStart(8)}`);
|
|
4246
|
+
for (const [key, a] of [...by].sort((x, y) => y[1].runs - x[1].runs)) {
|
|
4247
|
+
console.log(`${key.padEnd(width)} ${String(a.runs).padStart(5)} ${compact(a.tokens).padStart(10)} ${compact(a.returned).padStart(9)} ${usd(a.cost).padStart(8)} ${usd(a.saved).padStart(8)}`);
|
|
4248
|
+
}
|
|
4249
|
+
return 0;
|
|
4250
|
+
}
|
|
3324
4251
|
const period = since === "all" ? "all time" : `last ${since.replace("d", " days")}`;
|
|
3325
4252
|
if (has(p, "json")) {
|
|
3326
4253
|
console.log(JSON.stringify({ period, ...t, price: primaryPrice() }, null, 2));
|
|
@@ -3334,8 +4261,8 @@ function cmdSavings(p) {
|
|
|
3334
4261
|
est. saved ${usd(t.saved)} (vs ${primaryPrice().name} list prices)`);
|
|
3335
4262
|
const out = flag(p, "card");
|
|
3336
4263
|
if (out) {
|
|
3337
|
-
|
|
3338
|
-
console.log(`card written to ${
|
|
4264
|
+
fs24.writeFileSync(out, card(t, period));
|
|
4265
|
+
console.log(`card written to ${path20.resolve(out)}`);
|
|
3339
4266
|
}
|
|
3340
4267
|
if (has(p, "badge")) console.log(`})`);
|
|
3341
4268
|
return 0;
|
|
@@ -3355,7 +4282,7 @@ function cmdConfig(p) {
|
|
|
3355
4282
|
console.log(JSON.stringify({ path: configPath(), settings: eff, warnings }, null, 2));
|
|
3356
4283
|
return 0;
|
|
3357
4284
|
}
|
|
3358
|
-
console.log(`config file: ${configPath()}${
|
|
4285
|
+
console.log(`config file: ${configPath()}${fs24.existsSync(configPath()) ? "" : " (not present)"}`);
|
|
3359
4286
|
for (const [key, s] of Object.entries(eff)) {
|
|
3360
4287
|
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);
|
|
3361
4288
|
console.log(` ${key.padEnd(15)} ${v} (${s.source})`);
|
|
@@ -3378,9 +4305,10 @@ function cmdClean(p) {
|
|
|
3378
4305
|
for (const id of old) {
|
|
3379
4306
|
const m = readMeta(id);
|
|
3380
4307
|
if (m.mode === "isolate" && !m.discarded && !m.applied) discardRun(m);
|
|
3381
|
-
|
|
4308
|
+
recordRun(m);
|
|
4309
|
+
fs24.rmSync(runDir(id), { recursive: true, force: true });
|
|
3382
4310
|
}
|
|
3383
|
-
console.log(`removed ${old.length} run(s); the savings ledger
|
|
4311
|
+
console.log(`removed ${old.length} run(s); the history and the savings ledger are kept`);
|
|
3384
4312
|
return 0;
|
|
3385
4313
|
}
|
|
3386
4314
|
var cmdRevert = (p) => (console.log(revertRun(freshMeta(resolveRun(p.positional[0])))), 0);
|
|
@@ -3388,9 +4316,9 @@ var cmdDiscard = (p) => (console.log(discardRun(freshMeta(resolveRun(p.positiona
|
|
|
3388
4316
|
|
|
3389
4317
|
// src/core/doctor.ts
|
|
3390
4318
|
import { spawnSync as spawnSync7 } from "node:child_process";
|
|
3391
|
-
import
|
|
4319
|
+
import fs25 from "node:fs";
|
|
3392
4320
|
import os9 from "node:os";
|
|
3393
|
-
import
|
|
4321
|
+
import path21 from "node:path";
|
|
3394
4322
|
var MARK = { ok: "\u2714", warn: "!", fail: "\u2718" };
|
|
3395
4323
|
var READ_ONLY_HOW = {
|
|
3396
4324
|
"permission-rules": "per-run permission rules",
|
|
@@ -3404,7 +4332,7 @@ function doctor4(probe) {
|
|
|
3404
4332
|
add("ok", `pitroom ${VERSION2} \xB7 node ${process.versions.node} \xB7 state in ${home()}`);
|
|
3405
4333
|
add(gitAvailable() ? "ok" : "warn", gitAvailable() ? "git available" : "git not found: --write/--isolate tracking disabled");
|
|
3406
4334
|
const cfg = loadConfig();
|
|
3407
|
-
add(cfg.warnings.length ? "warn" : "ok", `config: ${configPath()}${
|
|
4335
|
+
add(cfg.warnings.length ? "warn" : "ok", `config: ${configPath()}${fs25.existsSync(configPath()) ? "" : " (not present, defaults in use)"}`);
|
|
3408
4336
|
for (const w of cfg.warnings) add("warn", w);
|
|
3409
4337
|
if (process.platform !== "win32") {
|
|
3410
4338
|
const guarded = guardEnv(process.env).PATH?.startsWith(shimDir());
|
|
@@ -3430,6 +4358,16 @@ function doctor4(probe) {
|
|
|
3430
4358
|
}
|
|
3431
4359
|
}
|
|
3432
4360
|
if (tierNames.length) add("ok", `tiers: ${tierNames.map((name, i) => `${name}=${describeTarget(tierTargets[i])}`).join(", ")}`);
|
|
4361
|
+
const costs = effective().costs.value;
|
|
4362
|
+
if (Object.keys(costs).length) {
|
|
4363
|
+
for (const t of [...chain, ...tierTargets]) {
|
|
4364
|
+
const key = `${t.backend}:${(t.model ?? "").split("#")[0]}`;
|
|
4365
|
+
const mine = costs[key];
|
|
4366
|
+
if (mine === void 0) continue;
|
|
4367
|
+
const cheaper = Object.entries(costs).filter(([k, v]) => k.startsWith(`${t.backend}:`) && v < mine).sort((a, b) => a[1] - b[1])[0];
|
|
4368
|
+
add("ok", `cost: ${key} = ${mine}${cheaper ? `; you priced ${cheaper[0]} cheaper (${cheaper[1]}): is the dearer one needed?` : ""}`);
|
|
4369
|
+
}
|
|
4370
|
+
}
|
|
3433
4371
|
const byBackend = /* @__PURE__ */ new Map();
|
|
3434
4372
|
for (const t of [...chain, ...tierTargets]) byBackend.set(t.backend, [...byBackend.get(t.backend) ?? [], t.model]);
|
|
3435
4373
|
for (const [id, models] of byBackend) {
|
|
@@ -3472,11 +4410,11 @@ function liveProbe(target) {
|
|
|
3472
4410
|
maxBuffer: 64 * 1024 * 1024,
|
|
3473
4411
|
env: guardEnv({ ...process.env, ...inv.env, PWD: process.cwd(), PITROOM_ACTIVE: "1" })
|
|
3474
4412
|
});
|
|
3475
|
-
const
|
|
4413
|
+
const secs2 = ((Date.now() - t0) / 1e3).toFixed(1);
|
|
3476
4414
|
const run = backend.parse(r.stdout ?? "");
|
|
3477
|
-
if (run.finalText.includes("PONG")) return { level: "ok", message: `live probe (${describeTarget(target)}) answered in ${
|
|
4415
|
+
if (run.finalText.includes("PONG")) return { level: "ok", message: `live probe (${describeTarget(target)}) answered in ${secs2}s` };
|
|
3478
4416
|
const why = backend.failure(run, r.stderr ?? "", r.status)?.message ?? run.finalText.slice(0, 200);
|
|
3479
|
-
return { level: "fail", message: `live probe (${describeTarget(target)}) failed after ${
|
|
4417
|
+
return { level: "fail", message: `live probe (${describeTarget(target)}) failed after ${secs2}s: ${why}` };
|
|
3480
4418
|
}
|
|
3481
4419
|
function skillChecks() {
|
|
3482
4420
|
const checks = [];
|
|
@@ -3489,19 +4427,19 @@ function skillChecks() {
|
|
|
3489
4427
|
message: `superpowers is installed too (${superpowers.join(", ")}): two bootstraps compete for the same work; keep one (Pitroom includes the superpowers workflow)`
|
|
3490
4428
|
});
|
|
3491
4429
|
}
|
|
3492
|
-
for (const { base, names } of installedSkills()) {
|
|
3493
|
-
if (names.length === all.length) checks.push({ level: "ok", message: `skills in ${
|
|
3494
|
-
else if (names.length) checks.push({ level: "warn", message: `skills in ${
|
|
3495
|
-
else if (!viaPlugin) checks.push({ level: "warn", message: `no Pitroom skills in ${
|
|
4430
|
+
for (const { base: base2, names } of installedSkills()) {
|
|
4431
|
+
if (names.length === all.length) checks.push({ level: "ok", message: `skills in ${base2}: ${names.join(", ")}` });
|
|
4432
|
+
else if (names.length) checks.push({ level: "warn", message: `skills in ${base2}: only ${names.join(", ")} of ${all.length}; run \`pitroom install\`` });
|
|
4433
|
+
else if (!viaPlugin) checks.push({ level: "warn", message: `no Pitroom skills in ${base2}; run \`pitroom install\`` });
|
|
3496
4434
|
}
|
|
3497
4435
|
if (viaPlugin) {
|
|
3498
|
-
const linked = installedSkills().some((i) => i.base.includes(`${
|
|
4436
|
+
const linked = installedSkills().some((i) => i.base.includes(`${path21.sep}.claude${path21.sep}`) && i.names.length);
|
|
3499
4437
|
checks.push(
|
|
3500
4438
|
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)" }
|
|
3501
4439
|
);
|
|
3502
4440
|
}
|
|
3503
4441
|
const launcher = launcherPath();
|
|
3504
|
-
if (
|
|
4442
|
+
if (fs25.existsSync(launcher)) {
|
|
3505
4443
|
const r = spawnSync7(launcher, ["--version"], { encoding: "utf8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"] });
|
|
3506
4444
|
checks.push(
|
|
3507
4445
|
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)}` }
|
|
@@ -3513,8 +4451,8 @@ function skillChecks() {
|
|
|
3513
4451
|
}
|
|
3514
4452
|
function pluginInstalled() {
|
|
3515
4453
|
try {
|
|
3516
|
-
const f =
|
|
3517
|
-
const plugins = JSON.parse(
|
|
4454
|
+
const f = path21.join(os9.homedir(), ".claude", "plugins", "installed_plugins.json");
|
|
4455
|
+
const plugins = JSON.parse(fs25.readFileSync(f, "utf8")).plugins ?? {};
|
|
3518
4456
|
return Object.keys(plugins).some((k) => k.startsWith("pitroom@"));
|
|
3519
4457
|
} catch {
|
|
3520
4458
|
return false;
|
|
@@ -3531,24 +4469,24 @@ function superpowersActive() {
|
|
|
3531
4469
|
for (const p of openCodePlugins()) {
|
|
3532
4470
|
if (/superpowers/i.test(p)) found.push(`OpenCode plugin ${p}`);
|
|
3533
4471
|
}
|
|
3534
|
-
for (const
|
|
3535
|
-
const dir =
|
|
3536
|
-
if (
|
|
4472
|
+
for (const base2 of [path21.join(os9.homedir(), ".agents", "skills"), path21.join(os9.homedir(), ".claude", "skills")]) {
|
|
4473
|
+
const dir = path21.join(base2, "using-superpowers");
|
|
4474
|
+
if (fs25.existsSync(path21.join(dir, "SKILL.md"))) found.push(dir);
|
|
3537
4475
|
}
|
|
3538
4476
|
return found;
|
|
3539
4477
|
}
|
|
3540
4478
|
function claudePlugins() {
|
|
3541
4479
|
try {
|
|
3542
|
-
const f =
|
|
3543
|
-
return Object.keys(JSON.parse(
|
|
4480
|
+
const f = path21.join(os9.homedir(), ".claude", "plugins", "installed_plugins.json");
|
|
4481
|
+
return Object.keys(JSON.parse(fs25.readFileSync(f, "utf8")).plugins ?? {});
|
|
3544
4482
|
} catch {
|
|
3545
4483
|
return [];
|
|
3546
4484
|
}
|
|
3547
4485
|
}
|
|
3548
4486
|
function claudePluginEnabled(key) {
|
|
3549
4487
|
try {
|
|
3550
|
-
const f =
|
|
3551
|
-
return JSON.parse(
|
|
4488
|
+
const f = path21.join(os9.homedir(), ".claude", "settings.json");
|
|
4489
|
+
return JSON.parse(fs25.readFileSync(f, "utf8")).enabledPlugins?.[key] !== false;
|
|
3552
4490
|
} catch {
|
|
3553
4491
|
return true;
|
|
3554
4492
|
}
|
|
@@ -3557,7 +4495,7 @@ function codexPlugins() {
|
|
|
3557
4495
|
const plugins = /* @__PURE__ */ new Map();
|
|
3558
4496
|
let text;
|
|
3559
4497
|
try {
|
|
3560
|
-
text =
|
|
4498
|
+
text = fs25.readFileSync(path21.join(process.env.CODEX_HOME ?? path21.join(os9.homedir(), ".codex"), "config.toml"), "utf8");
|
|
3561
4499
|
} catch {
|
|
3562
4500
|
return plugins;
|
|
3563
4501
|
}
|
|
@@ -3575,11 +4513,11 @@ function codexPlugins() {
|
|
|
3575
4513
|
return plugins;
|
|
3576
4514
|
}
|
|
3577
4515
|
function openCodePlugins() {
|
|
3578
|
-
const dir =
|
|
4516
|
+
const dir = path21.join(process.env.XDG_CONFIG_HOME ?? path21.join(os9.homedir(), ".config"), "opencode");
|
|
3579
4517
|
const plugins = [];
|
|
3580
4518
|
for (const name of ["opencode.json", "opencode.jsonc"]) {
|
|
3581
4519
|
try {
|
|
3582
|
-
const text =
|
|
4520
|
+
const text = fs25.readFileSync(path21.join(dir, name), "utf8").replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, "");
|
|
3583
4521
|
const list2 = JSON.parse(text).plugin;
|
|
3584
4522
|
if (Array.isArray(list2)) plugins.push(...list2.filter((p) => typeof p === "string"));
|
|
3585
4523
|
} catch {
|
|
@@ -3587,7 +4525,7 @@ function openCodePlugins() {
|
|
|
3587
4525
|
}
|
|
3588
4526
|
for (const folder of ["plugin", "plugins"]) {
|
|
3589
4527
|
try {
|
|
3590
|
-
for (const f of
|
|
4528
|
+
for (const f of fs25.readdirSync(path21.join(dir, folder))) plugins.push(path21.join(dir, folder, f));
|
|
3591
4529
|
} catch {
|
|
3592
4530
|
}
|
|
3593
4531
|
}
|
|
@@ -3595,6 +4533,7 @@ function openCodePlugins() {
|
|
|
3595
4533
|
}
|
|
3596
4534
|
|
|
3597
4535
|
// src/cli.ts
|
|
4536
|
+
useArchive({ onFinished: recordRun, meta: (id) => archivedRun(id)?.meta, id: archivedId });
|
|
3598
4537
|
var HELP = `pitroom ${VERSION2} \u2014 a free pit crew for your expensive coding agent.
|
|
3599
4538
|
Delegates bounded tasks to a worker agent CLI (${backendIds().join(", ")}), using the
|
|
3600
4539
|
worker's own default model, and returns a compact answer, exact changes and a receipt.
|
|
@@ -3611,7 +4550,14 @@ Usage
|
|
|
3611
4550
|
pitroom status [run | -g NAME] state / live progress (default: latest run)
|
|
3612
4551
|
pitroom wait [run\u2026 | -g NAME] [--any] [--brief] [--timeout 540]
|
|
3613
4552
|
block until all (or any) are done, then print reports
|
|
3614
|
-
pitroom watch [run\u2026 | -g NAME] [--json] [--interval 2]
|
|
4553
|
+
pitroom watch [run\u2026 | -g NAME] [--json | --brief] [--interval 2]
|
|
4554
|
+
--brief: one card line per start and end (made for Claude Code's Monitor tool)
|
|
4555
|
+
pitroom history [TEXT] [--model M] [--state S] [--since 30d] [--limit N] [--json]
|
|
4556
|
+
every finished run, searchable (SQLite); "history stats" per worker and model,
|
|
4557
|
+
"history import" takes in runs from before the history existed
|
|
4558
|
+
pitroom dash [--detach] [--port N] [--open] [--stop]
|
|
4559
|
+
a live page of the runs on 127.0.0.1 (read-only): open it in any browser
|
|
4560
|
+
or an agent app's browser pane; --detach runs it in the background
|
|
3615
4561
|
live table (TTY) or one JSON line per change, until done
|
|
3616
4562
|
pitroom show [run] [--patch|--events|--full|--json]
|
|
3617
4563
|
pitroom ls [--running] [-g NAME] recent runs
|
|
@@ -3621,7 +4567,9 @@ Usage
|
|
|
3621
4567
|
pitroom discard [run] drop an --isolate run's copy (the patch is kept)
|
|
3622
4568
|
pitroom revert [run] undo the changes of a --write run (checked first)
|
|
3623
4569
|
pitroom stop [run | -g NAME] stop running or queued workers
|
|
3624
|
-
pitroom savings [--since 7d|30d|all] [--card file.svg] [--badge]
|
|
4570
|
+
pitroom savings [--since 7d|30d|all] [--models] [--card file.svg] [--badge]
|
|
4571
|
+
pitroom models [worker] [--all] [--json]
|
|
4572
|
+
models each worker offers, with effort levels, your costs and usage
|
|
3625
4573
|
pitroom statusline [--then CMD] status-bar line: running workers, savings this week (after CMD's)
|
|
3626
4574
|
pitroom hook-card PostToolUse hook: a card after each Bash \`pitroom\` command
|
|
3627
4575
|
pitroom doctor [--probe] check workers, models, permissions, skills
|
|
@@ -3640,6 +4588,7 @@ Run options
|
|
|
3640
4588
|
-W, --worker T worker target "backend[:model]" (default: config "worker", else opencode)
|
|
3641
4589
|
-m, --model M model for that worker (default: the worker CLI's own default)
|
|
3642
4590
|
--tier NAME a worker from the config's "tiers" (e.g. cheap, standard, capable); -W wins
|
|
4591
|
+
--effort LEVEL reasoning effort for the worker: low, medium, high, xhigh, \u2026 (model#level)
|
|
3643
4592
|
--plan PLAN with --step N: implement Task N of a plan (-i or -w); the task text is your notes
|
|
3644
4593
|
--step N the plan task for --plan
|
|
3645
4594
|
-t, --timeout DUR e.g. 900, 20m, 1h (default 30m, or PITROOM_TIMEOUT)
|
|
@@ -3654,7 +4603,7 @@ Run options
|
|
|
3654
4603
|
--task-file PATH read the task from a file (crew: tasks separated by --- lines)
|
|
3655
4604
|
--allow-non-git allow --write outside a git repository (no tracking/revert)
|
|
3656
4605
|
|
|
3657
|
-
Parallel: at most maxParallel workers (default
|
|
4606
|
+
Parallel: at most maxParallel workers (default 20, up to 30; PITROOM_MAX_PARALLEL) run at once; others queue.
|
|
3658
4607
|
One --write run per repository; use --isolate for parallel changes.
|
|
3659
4608
|
Exit codes: 0 ok \xB7 1 worker failed \xB7 2 usage \xB7 3 refused/setup \xB7 4 timeout
|
|
3660
4609
|
5 read-only violation \xB7 6 verify failed \xB7 75 still running (wait again)
|
|
@@ -3671,6 +4620,8 @@ var COMMANDS = {
|
|
|
3671
4620
|
status: cmdStatus,
|
|
3672
4621
|
wait: cmdWait,
|
|
3673
4622
|
watch: cmdWatch,
|
|
4623
|
+
dash: cmdDash,
|
|
4624
|
+
history: cmdHistory,
|
|
3674
4625
|
show: cmdShow,
|
|
3675
4626
|
ls: cmdLs,
|
|
3676
4627
|
list: cmdLs,
|
|
@@ -3679,6 +4630,7 @@ var COMMANDS = {
|
|
|
3679
4630
|
discard: cmdDiscard,
|
|
3680
4631
|
stop: cmdStop,
|
|
3681
4632
|
savings: cmdSavings,
|
|
4633
|
+
models: cmdModels,
|
|
3682
4634
|
statusline: cmdStatusline,
|
|
3683
4635
|
"hook-card": cmdHookCard,
|
|
3684
4636
|
doctor: (p) => doctor4(has(p, "probe")),
|