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/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 usage = {
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
- usage.toolCalls++;
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)) usage.denied++;
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
- usage.input += num(u.input_tokens);
129
- usage.cacheRead += num(u.cache_read_input_tokens);
130
- usage.cacheWrite += num(u.cache_creation_input_tokens);
131
- usage.reasoning += num(u.output_tokens_details?.thinking_tokens);
132
- usage.output += Math.max(0, num(u.output_tokens) - num(u.output_tokens_details?.thinking_tokens));
133
- usage.total += num(u.input_tokens) + num(u.cache_read_input_tokens) + num(u.cache_creation_input_tokens) + num(u.output_tokens);
134
- usage.cost = (usage.cost ?? 0) + num(e.total_cost_usd);
135
- usage.denied = Math.max(usage.denied, Array.isArray(e.permission_denials) ? e.permission_denials.length : 0);
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
- usage.steps = steps.size;
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
- if (req.model) args.push("--model", req.model);
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 usage = {
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
- usage.steps++;
366
+ usage2.steps++;
342
367
  if (TOOLS.has(it.type)) {
343
- usage.toolCalls++;
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 ?? ""))) usage.denied++;
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 usage.denied++;
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 cached2 = num2(u.cached_input_tokens);
390
+ const cached3 = num2(u.cached_input_tokens);
363
391
  const reasoning = num2(u.reasoning_output_tokens);
364
- usage.input += Math.max(0, num2(u.input_tokens) - cached2);
365
- usage.cacheRead += cached2;
366
- usage.cacheWrite += num2(u.cache_write_input_tokens);
367
- usage.output += Math.max(0, num2(u.output_tokens) - reasoning);
368
- usage.reasoning += reasoning;
369
- usage.total += num2(u.input_tokens) + num2(u.output_tokens);
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: m ? `Codex model: ${m} (checked on first use; unsupported models fail over)` : "Codex model: Codex's built-in default (your ~/.codex/config.toml is ignored for workers; pick one with -W codex:<model>)"
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 usage = {
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
- usage.toolCalls++;
602
+ usage2.toolCalls++;
554
603
  tools[name] = (tools[name] ?? 0) + 1;
555
- if (st.status === "error" && DENIED2.test(String(st.error ?? ""))) usage.denied++;
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
- usage.steps++;
565
- usage.input += num3(t.input);
566
- usage.output += num3(t.output);
567
- usage.reasoning += num3(t.reasoning);
568
- usage.cacheRead += num3(t.cache?.read);
569
- usage.cacheWrite += num3(t.cache?.write);
570
- usage.total += num3(t.total) || num3(t.input) + num3(t.output) + num3(t.reasoning) + num3(t.cache?.read);
571
- usage.cost = (usage.cost ?? 0) + num3(p.cost);
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 base = {};
843
+ let base2 = {};
788
844
  if (existing?.trim()) {
789
845
  try {
790
- base = JSON.parse(existing);
846
+ base2 = JSON.parse(existing);
791
847
  } catch {
792
- base = {};
848
+ base2 = {};
793
849
  }
794
850
  }
795
- return JSON.stringify(deepMerge(base, ours));
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 base = process.platform === "win32" ? process.env.APPDATA ?? path5.join(os5.homedir(), "AppData", "Roaming") : process.env.XDG_CONFIG_HOME ?? path5.join(os5.homedir(), ".config");
990
- return path5.join(base, "pitroom", "config.json");
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), 4),
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 stamp = `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
1080
- return `${stamp}-${crypto.randomBytes(2).toString("hex")}`;
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
- return upgrade(JSON.parse(fs6.readFileSync(runFile(id, "meta.json"), "utf8")));
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 (!ids.length) throw new UserError("no runs yet");
1109
- if (!ref || ref === "latest" || ref === "last") return ids[ids.length - 1];
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
- throw new UserError(hits.length ? `ambiguous run "${ref}": ${hits.join(", ")}` : `unknown run "${ref}"`);
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 fs22 from "node:fs";
1282
- import path18 from "node:path";
1394
+ import fs24 from "node:fs";
1395
+ import path20 from "node:path";
1283
1396
 
1284
- // src/core/report.ts
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/plan.ts
1288
- import fs8 from "node:fs";
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 fs9 from "node:fs";
1361
- import path8 from "node:path";
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(usage, returnedTokens, price = primaryPrice()) {
1378
- const wouldCost = (usage.input * price.input + usage.cacheRead * price.cachedInput + (usage.output + usage.reasoning) * price.output) / 1e6;
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 - (usage.cost ?? 0) - readingTheReport);
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
- fs9.mkdirSync(path8.dirname(ledgerFile()), { recursive: true });
1398
- fs9.appendFileSync(ledgerFile(), `${JSON.stringify(entry)}
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 (!fs9.existsSync(ledgerFile())) return [];
1403
- return fs9.readFileSync(ledgerFile(), "utf8").split("\n").filter(Boolean).flatMap((l) => {
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[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
- var esc = (s) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
1436
- function card(t, period) {
1437
- 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>`;
1438
- 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))}">
1439
- <style>
1440
- text{font-family:ui-sans-serif,-apple-system,Segoe UI,Helvetica,Arial,sans-serif;fill:#ede9fe}
1441
- .h{font-size:15px;font-weight:600;fill:#a78bfa;letter-spacing:.08em}
1442
- .big{font-size:54px;font-weight:800;fill:#fff}
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 ICON = {
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 fs10.existsSync(f) ? fs10.readFileSync(f, "utf8").trim() : "";
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 ${ICON[meta.state]} ${meta.state} \xB7 ${meta.mode} \xB7 ${duration(meta)} \xB7 run ${meta.id}`);
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 (!fs10.existsSync(f)) return { steps: 0, toolCalls: 0 };
1554
- const p = getBackend((meta.ran ?? meta.worker).backend).parse(fs10.readFileSync(f, "utf8"));
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 emit = (e) => opts.write(`${JSON.stringify(e)}
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 base = { run: m.id, mode: m.mode, task: oneLine4(m.task, 60) };
1636
- if (!prev) emit({ event: m.state === "queued" ? "queued" : "started", ...base });
1637
- else if (prev.state === "queued" && m.state === "running") emit({ event: "started", ...base });
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 fs11 from "node:fs";
2203
+ import fs13 from "node:fs";
1680
2204
  import os7 from "node:os";
1681
- import path9 from "node:path";
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 path9.resolve(path9.dirname(fileURLToPath(import.meta.url)), "..");
2209
+ return path10.resolve(path10.dirname(fileURLToPath(import.meta.url)), "..");
1686
2210
  }
1687
2211
  function skillNames(root = packageRoot()) {
1688
- const dir = path9.join(root, "skills");
1689
- if (!fs11.existsSync(dir)) return [];
1690
- return fs11.readdirSync(dir).filter((d) => fs11.existsSync(path9.join(dir, d, "SKILL.md"))).sort();
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 = [path9.join(home2, ".agents", "skills")];
1695
- if (fs11.existsSync(path9.join(home2, ".claude"))) targets.push(path9.join(home2, ".claude", "skills"));
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 = () => path9.join(os7.homedir(), ".local", "bin", "pitroom");
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.exit(+process.versions.node.split(".")[0] >= 18 ? 0 : 1)' 2>/dev/null; }
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 18 or newer (set PITROOM_NODE to its path)" >&2
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 = fs11.readFileSync(file, "utf8");
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
- fs11.mkdirSync(path9.dirname(dest), { recursive: true });
1725
- const exists = fs11.lstatSync(dest, { throwIfNoEntry: false });
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
- fs11.renameSync(dest, `${dest}.bak-${Date.now()}`);
2252
+ fs13.renameSync(dest, `${dest}.bak-${Date.now()}`);
1729
2253
  } else if (exists) {
1730
- fs11.rmSync(dest, { force: true });
2254
+ fs13.rmSync(dest, { force: true });
1731
2255
  }
1732
- fs11.writeFileSync(dest, launcherScript(bundle), { mode: 493 });
1733
- return `\u2714 ${dest} \u2192 launcher for ${bundle} (Node 18+)`;
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 = fs11.lstatSync(link, { throwIfNoEntry: false });
2260
+ const st = fs13.lstatSync(link, { throwIfNoEntry: false });
1737
2261
  if (!st?.isSymbolicLink()) return false;
1738
- const target = path9.resolve(path9.dirname(link), fs11.readlinkSync(link));
1739
- const rel = path9.relative(dir, target);
1740
- return !rel.startsWith("..") && !path9.isAbsolute(rel);
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
- fs11.mkdirSync(path9.dirname(dest), { recursive: true });
1744
- const st = fs11.lstatSync(dest, { throwIfNoEntry: false });
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
- fs11.renameSync(dest, `${dest}.bak-${Date.now()}`);
2271
+ fs13.renameSync(dest, `${dest}.bak-${Date.now()}`);
1748
2272
  } else if (st) {
1749
- fs11.unlinkSync(dest);
2273
+ fs13.unlinkSync(dest);
1750
2274
  }
1751
- if (opts.copy) fs11.cpSync(src, dest, { recursive: true });
1752
- else fs11.symlinkSync(src, dest, process.platform === "win32" ? "junction" : fs11.statSync(src).isDirectory() ? "dir" : "file");
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 ${path9.join(root, "skills")}`);
2282
+ if (!skills.length) throw new Error(`no skills found under ${path10.join(root, "skills")}`);
1759
2283
  const out = [];
1760
- for (const base of skillTargets()) {
2284
+ for (const base2 of skillTargets()) {
1761
2285
  for (const legacy of LEGACY) {
1762
- const l = path9.join(base, legacy);
2286
+ const l = path10.join(base2, legacy);
1763
2287
  if (!skills.includes(legacy) && linksInto(l, root)) {
1764
- fs11.unlinkSync(l);
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(path9.join(root, "skills", name), path9.join(base, name), opts));
2292
+ for (const name of skills) out.push(place(path10.join(root, "skills", name), path10.join(base2, name), opts));
1769
2293
  }
1770
- const bundle = path9.join(root, "dist", "pitroom.mjs");
1771
- if (fs11.existsSync(bundle)) {
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(path9.delimiter).some((d) => path9.resolve(d) === path9.dirname(launcherPath()));
1774
- if (!onPath) out.push(`! ${path9.dirname(launcherPath())} is not on PATH; add it, or run ${launcherPath()} directly`);
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 base of skillTargets()) {
1782
- if (!fs11.existsSync(base)) continue;
1783
- for (const name of fs11.readdirSync(base)) {
1784
- const l = path9.join(base, name);
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
- fs11.unlinkSync(l);
2310
+ fs13.unlinkSync(l);
1787
2311
  out.push(`\u2714 removed ${l}`);
1788
2312
  }
1789
2313
  }
1790
2314
  }
1791
2315
  if (isOurLauncher(launcherPath(), root)) {
1792
- fs11.rmSync(launcherPath(), { force: true });
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((base) => ({ base, names: names.filter((n) => fs11.existsSync(path9.join(base, n, "SKILL.md"))) }));
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 fs12 from "node:fs";
1805
- import path10 from "node:path";
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 path10.join(home(), "plans", id, "notes.md");
2332
+ return path11.join(home(), "plans", id, "notes.md");
1809
2333
  }
1810
2334
  function readNotes(plan) {
1811
2335
  const f = notesFile(plan);
1812
- if (!fs12.existsSync(f)) return [];
1813
- return fs12.readFileSync(f, "utf8").split("\n").slice(1).filter(Boolean).map((l) => {
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
- fs12.mkdirSync(path10.dirname(f), { recursive: true });
1822
- if (!fs12.existsSync(f)) fs12.writeFileSync(f, `# plan: ${plan.file}
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
- fs12.appendFileSync(f, `${at} ${text.replace(/\s+/g, " ").trim()}
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 fs14 from "node:fs";
1894
- import path12 from "node:path";
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 fs13 from "node:fs";
2423
+ import fs15 from "node:fs";
1900
2424
  import os8 from "node:os";
1901
- import path11 from "node:path";
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 ? path11.resolve(r.stdout.trim()) : void 0;
2457
+ return r.code === 0 ? path12.resolve(r.stdout.trim()) : void 0;
1934
2458
  }
1935
2459
  function snapshotTree(root, exclude = []) {
1936
- const tmp = path11.join(os8.tmpdir(), `pitroom-index-${process.pid}-${crypto3.randomBytes(4).toString("hex")}`);
1937
- const real = path11.resolve(root, must(root, ["rev-parse", "--git-path", "index"]).trim());
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 (fs13.existsSync(real)) fs13.copyFileSync(real, tmp);
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
- fs13.rmSync(tmp, { force: true });
1946
- fs13.rmSync(`${tmp}.lock`, { force: true });
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 = path11.join(path11.resolve(root, must(root, ["rev-parse", "--git-common-dir"]).trim()), "objects");
1967
- fs13.mkdirSync(path11.dirname(dest), { recursive: true });
1968
- must(path11.dirname(dest), ["init", "--quiet", dest]);
1969
- fs13.writeFileSync(path11.join(dest, ".git", "objects", "info", "alternates"), `${objects}
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 = path11.relative(ownedBy, dest);
1976
- if (!rel || rel.startsWith("..") || path11.isAbsolute(rel)) throw new Error(`refusing to remove ${dest}`);
1977
- fs13.rmSync(dest, { recursive: true, force: true });
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 = path11.join(root, rel);
1983
- const dst = path11.join(worktree, rel);
1984
- if (!fs13.existsSync(src) || fs13.existsSync(dst)) continue;
1985
- fs13.mkdirSync(path11.dirname(dst), { recursive: true });
1986
- fs13.symlinkSync(src, dst, process.platform === "win32" ? "junction" : void 0);
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 fs14.existsSync(f) ? fs14.readFileSync(f, "utf8") : root.task;
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 && fs14.existsSync(where) && from && m.afterTree) return reviewDiff(where, from, m.afterTree);
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 fs14.readFileSync(runFile(m.id, "changes.patch"), "utf8");
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 && fs14.existsSync(m.worktree) ? m.cwd : m.dir;
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 = path12.join(g, "pitroom", `review-${crypto4.randomBytes(4).toString("hex")}.md`);
2147
- fs14.mkdirSync(path12.dirname(file), { recursive: true });
2148
- fs14.writeFileSync(file, job.package);
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 fs15 from "node:fs";
2154
- import path13 from "node:path";
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 = path13.join(root, "skills", FILES[name]);
2163
- if (!fs15.existsSync(file)) throw new UserError(`template missing: ${file} (broken install? run pitroom doctor)`, 3);
2164
- return fs15.readFileSync(file, "utf8").replace(/^\s*<!--[\s\S]*?-->\s*/, "");
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/ui.ts
2173
- import fs16 from "node:fs";
2174
- var WEEK_MS = 7 * 24 * 3600 * 1e3;
2175
- var RECENT = 40;
2176
- function activeRuns() {
2177
- const out = [];
2178
- for (const id of listRunIds().slice(-RECENT)) {
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
- const m = readMeta(id);
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 statusLine() {
2188
- const running = activeRuns();
2189
- const saved = totals(readLedger(Date.now() - WEEK_MS)).saved;
2190
- if (!running.length && !saved) return "";
2191
- const parts = ["\u{1F3C1} pitroom"];
2192
- if (running.length) {
2193
- const who = [...new Set(running.map((m) => (m.ran ?? m.worker).backend))].join(", ");
2194
- parts.push(`${running.length} running (${who})`);
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
- if (saved) parts.push(`~${usd(saved)} saved this week`);
2197
- return parts.join(" \xB7 ");
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
- var RUN_ID = /\b\d{8}-\d{6}-[0-9a-f]{4}\b/g;
2200
- var PITROOM_CALL = /(^|[\s;&|(`])(\S*\/)?pitroom(\.mjs)?(\s|$)/;
2201
- var ICON2 = { done: "\u2714", failed: "\u2718", timeout: "\u23F1", stopped: "\u25A0" };
2202
- function kind(m) {
2203
- if (m.reviewOf) return m.reviewKind === "range" ? "branch review" : m.reviewKind === "fix" ? "re-review" : "review";
2204
- return m.mode === "read" ? "research" : m.mode === "isolate" ? "change (isolated copy)" : "change";
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
- var SUBJECT_MAX = 48;
2207
- function short(text) {
2208
- const s = text.replace(/`/g, "").replace(/^#+\s*/, "").replace(/\s+/g, " ").trim();
2209
- if (s.length <= SUBJECT_MAX) return s;
2210
- const cut = s.slice(0, SUBJECT_MAX);
2211
- const space = cut.lastIndexOf(" ");
2212
- return `${(space > SUBJECT_MAX / 2 ? cut.slice(0, space) : cut).replace(/[\s,.;:·-]+$/, "")}\u2026`;
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 MODEL_MAX = 24;
2215
- function workerName(t) {
2216
- if (!t.model) return t.backend;
2217
- const model = (t.model.split("/").pop() ?? t.model).replace(/-(contributor-)?free$/, "").replace(/-\d+\.\d+$/, "");
2218
- return `${t.backend} (${model.length > MODEL_MAX ? `${model.slice(0, MODEL_MAX - 1)}\u2026` : model})`;
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 what(m) {
2221
- if (m.plan) return short(`Task ${m.plan.step}: ${m.plan.title}`);
2222
- if (m.reviewOf) return `of ${m.reviewOf}`;
2223
- const line = m.task.split("\n").find((l) => l.trim()) ?? "";
2224
- const brief2 = /^You are implementing Task (\d+)\b.*?\bplan\s+(\S+)/i.exec(line);
2225
- if (brief2) return short(`Task ${brief2[1]} \xB7 ${brief2[2].split("/").pop().replace(/\.md\W*$/, "")}`);
2226
- return short(line);
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
- function duration2(m) {
2229
- const s = Math.round((Date.parse(m.endedAt ?? "") - Date.parse(m.startedAt)) / 1e3);
2230
- if (!Number.isFinite(s) || s < 0) return "";
2231
- return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s` : `${s}s`;
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 cardsFor(m) {
2234
- const phases = [
2235
- ["started", () => `\u{1F3C1} Pitroom \u25B6 ${kind(m)} on ${workerName(m.worker)} \xB7 ${what(m)} (${m.id})`]
2236
- ];
2237
- if (!isActive(m.state)) {
2238
- phases.push([
2239
- "ended",
2240
- () => {
2241
- const v = m.verdict;
2242
- const bits = [
2243
- `\u{1F3C1} Pitroom ${ICON2[m.state] ?? "\u2022"} ${kind(m)} ${m.state} on ${workerName(m.ran ?? m.worker)}`,
2244
- what(m),
2245
- duration2(m),
2246
- v && `SPEC ${v.spec.toUpperCase()} \xB7 QUALITY ${v.quality.toUpperCase()}`,
2247
- m.changes?.length ? `${m.changes.length} file${m.changes.length === 1 ? "" : "s"} changed` : "",
2248
- m.savedUsd ? `~${usd(m.savedUsd)} saved` : ""
2249
- ];
2250
- return `${bits.filter(Boolean).join(" \xB7 ")} (${m.id})`;
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
- if (m.applied) {
2255
- phases.push(["applied", () => `\u{1F3C1} Pitroom \u2935 applied ${m.changes?.length ?? 0} file(s) from ${m.id} to your tree`]);
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
- const out = [];
2258
- for (const [phase, text] of phases) {
2259
- const marker = runFile(m.id, `card-${phase}`);
2260
- if (fs16.existsSync(marker)) continue;
2261
- if (!(phase === "started" && !isActive(m.state))) out.push(text());
2262
- fs16.writeFileSync(marker, "");
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 hookCards(input) {
2267
- const event = JSON.parse(input);
2268
- const command = event.tool_input?.command ?? "";
2269
- if (event.tool_name !== "Bash" || !PITROOM_CALL.test(command)) return "";
2270
- const response = event.tool_response;
2271
- const output = typeof response === "string" ? response : [response?.stdout, response?.stderr].filter(Boolean).join("\n");
2272
- const ids = [...new Set(`${command}
2273
- ${output}`.match(RUN_ID) ?? [])].filter((id) => fs16.existsSync(`${runsDir()}/${id}`));
2274
- const cards = [];
2275
- for (const id of ids) {
2276
- try {
2277
- cards.push(...cardsFor(readMeta(id)));
2278
- } catch {
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
- return cards.join("\n");
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 spawn2, spawnSync as spawnSync5 } from "node:child_process";
2286
- import fs21 from "node:fs";
2287
- import path17 from "node:path";
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) => t.model || !models[t.backend] ? t : { ...t, model: models[t.backend] };
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 fs18 from "node:fs";
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 fs17 from "node:fs";
2325
- import path14 from "node:path";
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 path14.join(home(), "shim", `v${VERSION}`);
3246
+ return path16.join(home(), "shim", `v${VERSION}`);
2401
3247
  }
2402
3248
  function findRealGit(skip) {
2403
- for (const dir of (process.env.PATH ?? "").split(path14.delimiter).filter(Boolean)) {
2404
- if (path14.resolve(dir) === skip) continue;
2405
- const p = path14.join(dir, "git");
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
- fs17.accessSync(p, fs17.constants.X_OK);
2408
- if (fs17.statSync(p).isFile()) return p;
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 = () => path14.join(home(), "git-hooks", `v${HOOK_VERSION}`);
3267
+ var hooksDir = () => path16.join(home(), "git-hooks", `v${HOOK_VERSION}`);
2422
3268
  function writeIfChanged(file, content) {
2423
- if (fs17.existsSync(file) && fs17.readFileSync(file, "utf8") === content) return;
2424
- fs17.mkdirSync(path14.dirname(file), { recursive: true });
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
- fs17.writeFileSync(tmp, content, { mode: 493 });
2427
- fs17.renameSync(tmp, file);
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(path14.join(hooksDir(), "reference-transaction"), REF_HOOK);
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(path14.join(dir, "git"), SHIM);
2458
- return { ...env, ...quiet, PITROOM_REAL_GIT: real, PATH: `${dir}${path14.delimiter}${env.PATH ?? ""}` };
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 = fs18.openSync(opts.stdoutFile, "w");
2464
- const err = fs18.openSync(opts.stderrFile, "w");
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 = spawn(inv.command, inv.args, {
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
- fs18.closeSync(out);
2497
- fs18.closeSync(err);
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 crypto5 from "node:crypto";
2523
- import fs19 from "node:fs";
2524
- import path15 from "node:path";
2525
- var slotsDir = () => path15.join(home(), "slots");
2526
- var locksDir = () => path15.join(home(), "locks");
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
- fs19.mkdirSync(path15.dirname(file), { recursive: true });
3386
+ fs21.mkdirSync(path17.dirname(file), { recursive: true });
2541
3387
  try {
2542
- fs19.writeFileSync(file, runId, { flag: "wx" });
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 = fs19.readFileSync(file, "utf8").trim();
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
- fs19.rmSync(file, { force: true });
3400
+ fs21.rmSync(file, { force: true });
2555
3401
  try {
2556
- fs19.writeFileSync(file, runId, { flag: "wx" });
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 (fs19.readFileSync(file, "utf8").trim() === runId) fs19.rmSync(file, { force: true });
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 = path15.join(slotsDir(), `slot-${n}`);
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) => path15.join(locksDir(), `write-${crypto5.createHash("sha1").update(path15.resolve(repoRoot2)).digest("hex").slice(0, 16)}`);
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 = fs19.readFileSync(file, "utf8").trim();
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 fs20 from "node:fs";
2631
- import path16 from "node:path";
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) => path16.resolve(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 = fs20.statSync(file).size;
2683
- const lines = size > MAX_BYTES ? null : fs20.readFileSync(file, "utf8").split("\n");
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: path16.isAbsolute(ref.file) && !inside(ref.file, roots) ? "outside the project" : "file not found" });
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 = path16.isAbsolute(ref) ? inside(ref, roots) ? [ref] : [] : roots.map((r) => path16.join(r, ref));
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 fs20.statSync(c).isFile() && inside(c, roots);
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 = path16.relative(r, path16.resolve(file));
2731
- return rel !== "" && !rel.startsWith("..") && !path16.isAbsolute(rel);
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.4" : "0.0.0-dev";
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 || !fs21.existsSync(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 = path17.resolve(parent?.dir ?? o.dir);
2789
- if (!fs21.existsSync(dir) || !fs21.statSync(dir).isDirectory()) throw new UserError(`not a directory: ${dir}`);
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) => path17.resolve(f));
2796
- for (const f of files) if (!fs21.existsSync(f)) throw new UserError(`file not found: ${f}`);
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
- fs21.rmSync(runDir(meta.id), { recursive: true, force: true });
3677
+ fs23.rmSync(runDir(meta.id), { recursive: true, force: true });
2832
3678
  throw e;
2833
3679
  }
2834
3680
  }
2835
- fs21.writeFileSync(runFile(meta.id, "task.md"), `${meta.task}
3681
+ fs23.writeFileSync(runFile(meta.id, "task.md"), `${meta.task}
2836
3682
  `);
2837
- if (work.brief) fs21.writeFileSync(runFile(meta.id, "brief.md"), 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 = spawn2(process.execPath, [script, "__exec", meta.id], {
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 (fs21.existsSync(from)) fs21.renameSync(from, runFile(meta.id, f.replace(".", `.attempt-${i + 1}.`)));
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 = path17.join(worktreesDir(), meta.id);
3776
+ meta.worktree = path19.join(worktreesDir(), meta.id);
2931
3777
  createIsolatedCopy(root, meta.baseTree, meta.worktree);
2932
- meta.cwd = path17.join(meta.worktree, path17.relative(root, meta.dir));
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) => fs21.existsSync(f) ? fs21.readFileSync(f, "utf8") : "";
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
- fs21.writeFileSync(runFile(meta.id, "summary.md"), `${run.finalText}
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) fs21.rmSync(meta.packageFile, { force: true });
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
- fs21.writeFileSync(runFile(meta.id, "changes.patch"), d.patch);
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
- fs21.writeFileSync(runFile(meta.id, "verify.log"), output);
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 && fs21.existsSync(meta.worktree)) removeIsolatedCopy(meta.worktree, worktreesDir());
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
- fs22.rmSync(packageFile, { force: true });
3974
+ fs24.rmSync(packageFile, { force: true });
3129
3975
  throw e;
3130
3976
  }
3131
- fs22.writeFileSync(runFile(meta.id, "package.md"), job.package);
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 ? fs22.readFileSync(file, "utf8").split(/^\s*---\s*$/m) : p.positional).map((t) => t.trim()).filter(Boolean);
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 f = runFile(meta.id, name);
3232
- process.stdout.write(fs22.existsSync(f) ? fs22.readFileSync(f, "utf8") : `(no ${name})
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 ? "" : fs22.readFileSync(0, "utf8");
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) console.log(JSON.stringify({ systemMessage: 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
- fs22.writeFileSync(out, card(t, period));
3338
- console.log(`card written to ${path18.resolve(out)}`);
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(`![pitroom](${badgeUrl(t)})`);
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()}${fs22.existsSync(configPath()) ? "" : " (not present)"}`);
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
- fs22.rmSync(runDir(id), { recursive: true, force: true });
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 is kept`);
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 fs23 from "node:fs";
4319
+ import fs25 from "node:fs";
3392
4320
  import os9 from "node:os";
3393
- import path19 from "node:path";
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()}${fs23.existsSync(configPath()) ? "" : " (not present, defaults in use)"}`);
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 secs = ((Date.now() - t0) / 1e3).toFixed(1);
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 ${secs}s` };
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 ${secs}s: ${why}` };
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 ${base}: ${names.join(", ")}` });
3494
- else if (names.length) checks.push({ level: "warn", message: `skills in ${base}: only ${names.join(", ")} of ${all.length}; run \`pitroom install\`` });
3495
- else if (!viaPlugin) checks.push({ level: "warn", message: `no Pitroom skills in ${base}; run \`pitroom install\`` });
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(`${path19.sep}.claude${path19.sep}`) && i.names.length);
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 (fs23.existsSync(launcher)) {
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 = path19.join(os9.homedir(), ".claude", "plugins", "installed_plugins.json");
3517
- const plugins = JSON.parse(fs23.readFileSync(f, "utf8")).plugins ?? {};
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 base of [path19.join(os9.homedir(), ".agents", "skills"), path19.join(os9.homedir(), ".claude", "skills")]) {
3535
- const dir = path19.join(base, "using-superpowers");
3536
- if (fs23.existsSync(path19.join(dir, "SKILL.md"))) found.push(dir);
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 = path19.join(os9.homedir(), ".claude", "plugins", "installed_plugins.json");
3543
- return Object.keys(JSON.parse(fs23.readFileSync(f, "utf8")).plugins ?? {});
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 = path19.join(os9.homedir(), ".claude", "settings.json");
3551
- return JSON.parse(fs23.readFileSync(f, "utf8")).enabledPlugins?.[key] !== false;
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 = fs23.readFileSync(path19.join(process.env.CODEX_HOME ?? path19.join(os9.homedir(), ".codex"), "config.toml"), "utf8");
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 = path19.join(process.env.XDG_CONFIG_HOME ?? path19.join(os9.homedir(), ".config"), "opencode");
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 = fs23.readFileSync(path19.join(dir, name), "utf8").replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, "");
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 fs23.readdirSync(path19.join(dir, folder))) plugins.push(path19.join(dir, folder, f));
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 4, PITROOM_MAX_PARALLEL) run at once; others queue.
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")),