pitroom 0.24.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pitroom",
3
3
  "description": "A free pit crew for your expensive coding agent: the superpowers development workflow, run by cheap workers. Delegate research, fixes, tests, reviews and whole plans from Claude Code, Codex or any agent to OpenCode, Codex or Claude Code workers, with receipts that prove the savings.",
4
- "version": "0.24.0",
4
+ "version": "0.25.0",
5
5
  "author": {
6
6
  "name": "TahaAtas"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pitroom",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "A free pit crew for your expensive coding agent: delegate research, fixes, tests, reviews and parallel investigations to cheap worker agents running your own models, with verified answers, exact diffs and cost receipts.",
5
5
  "author": {
6
6
  "name": "TahaAtas"
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.25.0
4
+
5
+ ### Dashboard
6
+ - **"Finished" no longer goes down as you start runs.** It counted the finished runs among the ones on the page (the newest 40), so each new run pushed a finished one off and the number dropped. It now counts every finished run of the period, from the ledger the savings come from (a picked group's from its own runs); audits are left out, as in the savings.
7
+ - **A period on the Finished and Est. saved cards**: 24 hours, 7 days (the default), 30 days, 6 months or all time, picked on each card and remembered in the browser.
8
+
9
+ ## 0.24.1
10
+
11
+ ### Workers
12
+ - **Antigravity: a task that names files outside the project no longer stops halfway.** agy's file tools stay in the workspace, and a headless agy cannot ask for more, so a read outside it ("copy the components from /tmp/ui-source") was refused and ended the turn without an answer. The folders and files a task names by absolute path (or `~/…`) that exist outside the workspace are now granted read-only (the sandbox mounts them read-only too), with every secret-looking file in them denied (found at any depth and in every folder; a folder too big to check, with a link leading out of it or nowhere, or with a folder that cannot be read, is not granted, and a named path counts where its links lead). Never the root or a top-level folder such as `/tmp`, your home itself or a folder above it, a folder holding the workspace or the project, a dot-folder in your home (`~/.config` and the like) or `~/Library`, Pitroom's own state or a secret-looking file, and nothing on Windows (agy compares paths there without their drive letter); the secrets in your home stay denied. In a snapshot or an isolated copy the project's own paths in the task are turned into the copy's (links resolved, so `/tmp/…` and `/private/tmp/…` alike): the worker reads and edits its copy instead of being refused at the project.
13
+
3
14
  ## 0.24.0
4
15
 
5
16
  ### Workers
package/dist/lib.mjs CHANGED
@@ -542,10 +542,10 @@ function denyCommandRules() {
542
542
  "command(eval)"
543
543
  ];
544
544
  }
545
- function workerSettings(workspace, workerHome3) {
545
+ function workerSettings(workspace, workerHome3, reads = []) {
546
546
  const real = (p) => {
547
547
  try {
548
- return fs6.realpathSync(p);
548
+ return fs6.realpathSync.native(p);
549
549
  } catch {
550
550
  return p;
551
551
  }
@@ -553,6 +553,7 @@ function workerSettings(workspace, workerHome3) {
553
553
  const both = (p) => [.../* @__PURE__ */ new Set([p, real(p)])];
554
554
  const secrets = [
555
555
  ...findSecretFiles(workspace).map((f) => path5.join(workspace, f)),
556
+ ...reads.flatMap((r) => r.secrets),
556
557
  ...HOME_SECRETS.map((f) => path5.join(os3.homedir(), f)).filter((p) => fs6.existsSync(p)),
557
558
  // the keychain link of the worker's own home
558
559
  path5.join(workerHome3, "Library")
@@ -565,7 +566,9 @@ function workerSettings(workspace, workerHome3) {
565
566
  allow: [
566
567
  "command(*)",
567
568
  ...both(path5.join(dataDir(workerHome3), "brain")).map((p) => `write_file(${rulePath(p)})`),
568
- ...toolchains().flatMap(both).map((p) => `read_file(${rulePath(p)})`)
569
+ ...toolchains().flatMap(both).map((p) => `read_file(${rulePath(p)})`),
570
+ // what the task names outside the workspace: a headless agy cannot ask, and would end the turn unanswered
571
+ ...reads.map((r) => r.path).flatMap(both).map((p) => `read_file(${rulePath(p)})`)
569
572
  ],
570
573
  deny: [
571
574
  ...secrets.flatMap(both).map((p) => `read_file(${rulePath(p)})`),
@@ -588,6 +591,96 @@ function toolchains() {
588
591
  const install = (d) => path5.basename(d) === "bin" && /[\\/](versions|toolchains)[\\/]/.test(d) ? path5.dirname(d) : d;
589
592
  return [...new Set(dirs.map(install))].filter((d) => !inHome(home()) || path5.relative(home(), d).startsWith(".."));
590
593
  }
594
+ var isDir = (p) => {
595
+ try {
596
+ return fs6.statSync(p).isDirectory();
597
+ } catch {
598
+ return false;
599
+ }
600
+ };
601
+ var MAX_READS = 20;
602
+ function pathsIn(prompt) {
603
+ const out = [];
604
+ for (const m of prompt.matchAll(/(^|[\s"'`(\[<{=])((?:~|[A-Za-z]:)?[\\/][^\s"'`)\]>}]*)/g)) {
605
+ const raw = m[2].replace(/[.,;:!?]+$/, "");
606
+ const abs = raw.startsWith("~") ? path5.join(os3.homedir(), raw.slice(1)) : raw;
607
+ if (raw && path5.isAbsolute(abs)) out.push({ raw, at: m.index + m[1].length, abs });
608
+ }
609
+ return out;
610
+ }
611
+ var realOf = (p) => {
612
+ try {
613
+ return fs6.realpathSync.native(p);
614
+ } catch {
615
+ return void 0;
616
+ }
617
+ };
618
+ function resolved(p) {
619
+ let dir = path5.resolve(p);
620
+ const rest = [];
621
+ for (; ; ) {
622
+ const r = realOf(dir);
623
+ if (r) return path5.join(r, ...rest);
624
+ const up = path5.dirname(dir);
625
+ if (up === dir) return path5.resolve(p);
626
+ rest.unshift(path5.basename(dir));
627
+ dir = up;
628
+ }
629
+ }
630
+ var inside = (p, dir) => {
631
+ const rel = path5.relative(dir, p);
632
+ return !rel || !rel.startsWith("..") && !path5.isAbsolute(rel);
633
+ };
634
+ var MAX_SCANNED = 2e4;
635
+ function allSecrets(dir) {
636
+ const root = realOf(dir) ?? dir;
637
+ const found = [];
638
+ let seen = 0;
639
+ const stack = [dir];
640
+ while (stack.length) {
641
+ const d = stack.pop();
642
+ let entries;
643
+ try {
644
+ entries = fs6.readdirSync(d, { withFileTypes: true });
645
+ } catch {
646
+ return void 0;
647
+ }
648
+ for (const e of entries) {
649
+ if (++seen > MAX_SCANNED) return void 0;
650
+ const p = path5.join(d, e.name);
651
+ if (e.isSymbolicLink()) {
652
+ const target = realOf(p);
653
+ if (!target || !inside(target, root)) return void 0;
654
+ if (looksSecret(e.name)) found.push(p);
655
+ } else if (e.isDirectory()) stack.push(p);
656
+ else if (looksSecret(e.name)) found.push(p);
657
+ }
658
+ }
659
+ return found;
660
+ }
661
+ function outsideReads(prompt, workspace, project) {
662
+ if (process.platform === "win32") return [];
663
+ const homeDir = realOf(os3.homedir()) ?? os3.homedir();
664
+ const ws = realOf(workspace) ?? path5.resolve(workspace);
665
+ const proj = project ? resolved(project) : void 0;
666
+ const own = realOf(home()) ?? home();
667
+ const found = /* @__PURE__ */ new Map();
668
+ for (const { abs } of pathsIn(prompt)) {
669
+ const p = realOf(abs);
670
+ if (!p || found.has(p) || inside(p, ws) || inside(p, own)) continue;
671
+ if (inside(homeDir, p) || inside(ws, p) || proj && inside(proj, p)) continue;
672
+ if (p.split(/[\\/]/).filter(Boolean).length < 2) continue;
673
+ if (inside(p, homeDir) && /^(\.|Library([\\/]|$))/.test(path5.relative(homeDir, p))) continue;
674
+ if (!isDir(p)) {
675
+ if (!looksSecret(path5.basename(p))) found.set(p, { path: p, secrets: [] });
676
+ } else {
677
+ const secrets = allSecrets(p);
678
+ if (secrets) found.set(p, { path: p, secrets });
679
+ }
680
+ if (found.size >= MAX_READS) break;
681
+ }
682
+ return [...found.values()];
683
+ }
591
684
  function baseSettings() {
592
685
  return {
593
686
  enableTerminalSandbox: true,
@@ -598,12 +691,12 @@ function baseSettings() {
598
691
  }
599
692
  var rulePath = (p) => p.replace(/^[A-Za-z]:(?=[\\/])/, "").replace(/\\/g, "/");
600
693
  var HOMES_KEPT_DAYS = 14;
601
- function workerHome(workspace) {
694
+ function workerHome(workspace, reads = []) {
602
695
  const root = path5.join(home(), "antigravity-home");
603
696
  const id2 = crypto3.createHash("sha256").update(path5.resolve(workspace)).digest("hex").slice(0, 16);
604
697
  const dir = path5.join(root, id2);
605
698
  fs6.mkdirSync(dataDir(dir), { recursive: true });
606
- writeIfChanged(path5.join(dataDir(dir), "settings.json"), `${JSON.stringify(workerSettings(workspace, dir), null, 2)}
699
+ writeIfChanged(path5.join(dataDir(dir), "settings.json"), `${JSON.stringify(workerSettings(workspace, dir, reads), null, 2)}
607
700
  `);
608
701
  if (process.platform === "darwin") {
609
702
  fs6.mkdirSync(path5.join(dir, "Library"), { recursive: true });
@@ -627,11 +720,25 @@ function pruneHomes(root, keep) {
627
720
  } catch {
628
721
  }
629
722
  }
723
+ function inCopy(prompt, project) {
724
+ if (!project || project.root === project.copy) return prompt;
725
+ const root = resolved(project.root);
726
+ let out = "";
727
+ let from = 0;
728
+ for (const { raw, at, abs } of pathsIn(prompt)) {
729
+ const p = resolved(abs);
730
+ if (!inside(p, root)) continue;
731
+ out += prompt.slice(from, at) + path5.join(project.copy, path5.relative(root, p));
732
+ from = at + raw.length;
733
+ }
734
+ return out + prompt.slice(from);
735
+ }
630
736
  function invocation(req) {
737
+ const prompt = inCopy(req.prompt, req.project);
631
738
  const args = [
632
739
  // -p takes the prompt as its value; one that starts with "-" would be read as an option: a leading space keeps it a value.
633
740
  "-p",
634
- req.prompt.startsWith("-") ? ` ${req.prompt}` : req.prompt,
741
+ prompt.startsWith("-") ? ` ${prompt}` : prompt,
635
742
  "--output-format",
636
743
  "stream-json",
637
744
  "--mode",
@@ -646,7 +753,7 @@ function invocation(req) {
646
753
  if (model) args.push("--model", model);
647
754
  if (effort) args.push("--effort", effort);
648
755
  const { command, prefix } = resolveCommand(binary());
649
- const workerDir = workerHome(req.cwd);
756
+ const workerDir = workerHome(req.cwd, outsideReads(prompt, req.cwd, req.project?.root));
650
757
  const env = { HOME: workerDir };
651
758
  if (process.platform === "win32") env.USERPROFILE = workerDir;
652
759
  return { command, args: [...prefix, ...args], env };
@@ -2287,7 +2394,7 @@ function verifyRefs(refs, dirs) {
2287
2394
  if (!files.length) {
2288
2395
  if (!ref.file.includes("/") && !isFileName(ref.file)) continue;
2289
2396
  total++;
2290
- invalid.push({ ref: ref.text, reason: path12.isAbsolute(ref.file) && !inside(ref.file, roots) ? "outside the project" : "file not found" });
2397
+ invalid.push({ ref: ref.text, reason: path12.isAbsolute(ref.file) && !inside2(ref.file, roots) ? "outside the project" : "file not found" });
2291
2398
  continue;
2292
2399
  }
2293
2400
  total++;
@@ -2337,16 +2444,16 @@ function indexFiles(roots) {
2337
2444
  return index;
2338
2445
  }
2339
2446
  function resolve(ref, roots) {
2340
- const candidates = path12.isAbsolute(ref) ? inside(ref, roots) ? [ref] : [] : roots.map((r) => path12.join(r, ref));
2447
+ const candidates = path12.isAbsolute(ref) ? inside2(ref, roots) ? [ref] : [] : roots.map((r) => path12.join(r, ref));
2341
2448
  return candidates.find((c) => {
2342
2449
  try {
2343
- return fs12.statSync(c).isFile() && inside(c, roots);
2450
+ return fs12.statSync(c).isFile() && inside2(c, roots);
2344
2451
  } catch {
2345
2452
  return false;
2346
2453
  }
2347
2454
  });
2348
2455
  }
2349
- function inside(file, roots) {
2456
+ function inside2(file, roots) {
2350
2457
  return roots.some((r) => {
2351
2458
  const rel = path12.relative(r, path12.resolve(file));
2352
2459
  return rel !== "" && !rel.startsWith("..") && !path12.isAbsolute(rel);
@@ -2963,7 +3070,7 @@ function answerParts(answer) {
2963
3070
  const summary = s ? s[1] : text.split(/^\s*(?:DETAILS|FILES CHANGED|VERIFICATION|OPEN ISSUES):/m)[0];
2964
3071
  return { summary: summary.trim(), details: (d?.[1] ?? "").trim() };
2965
3072
  }
2966
- function pathsIn(text) {
3073
+ function pathsIn2(text) {
2967
3074
  const found = text.replace(/\\/g, "/").match(/[A-Za-z0-9_.@\-/]+\.[A-Za-z0-9]{1,8}(?::\d+)?/g) ?? [];
2968
3075
  return [...new Set(found.map((p) => clean(p.replace(/:\d+$/, ""))))];
2969
3076
  }
@@ -2975,7 +3082,7 @@ function scoreAnswer(q, truth, answer, root) {
2975
3082
  if ("line" in truth) {
2976
3083
  const flat = summary.replace(/\s+/g, " ");
2977
3084
  if (new RegExp(`(^|[^\\w./-])\\.?/?${escape2(truth.path)}:${truth.line}(?!\\d)`).test(flat)) return { score: 1, got: `${truth.path}:${truth.line}` };
2978
- const named = pathsIn(flat).includes(truth.path);
3085
+ const named = pathsIn2(flat).includes(truth.path);
2979
3086
  return { score: named ? 0.5 : 0, got: flat.slice(0, 120) };
2980
3087
  }
2981
3088
  if ("count" in truth) {
@@ -2983,8 +3090,8 @@ function scoreAnswer(q, truth, answer, root) {
2983
3090
  const got2 = n ? Number(n[0].replace(/,/g, "")) : void 0;
2984
3091
  return { score: got2 === truth.count ? 1 : 0, got: got2 === void 0 ? "(no number)" : String(got2) };
2985
3092
  }
2986
- let got = pathsIn(summary);
2987
- if (!got.length) got = pathsIn(details);
3093
+ let got = pathsIn2(summary);
3094
+ if (!got.length) got = pathsIn2(details);
2988
3095
  const hit = got.filter((p) => truth.files.includes(p)).length;
2989
3096
  const precision = got.length ? hit / got.length : 0;
2990
3097
  const recall = truth.files.length ? hit / truth.files.length : got.length ? 0 : 1;
@@ -3355,7 +3462,7 @@ export {
3355
3462
  parseStatus,
3356
3463
  parseTarget,
3357
3464
  parseVerdict,
3358
- pathsIn,
3465
+ pathsIn2 as pathsIn,
3359
3466
  planName,
3360
3467
  planTask,
3361
3468
  primaryPrice,
package/dist/pitroom.mjs CHANGED
@@ -558,10 +558,10 @@ function denyCommandRules() {
558
558
  "command(eval)"
559
559
  ];
560
560
  }
561
- function workerSettings(workspace, workerHome3) {
561
+ function workerSettings(workspace, workerHome3, reads = []) {
562
562
  const real = (p) => {
563
563
  try {
564
- return fs6.realpathSync(p);
564
+ return fs6.realpathSync.native(p);
565
565
  } catch {
566
566
  return p;
567
567
  }
@@ -569,6 +569,7 @@ function workerSettings(workspace, workerHome3) {
569
569
  const both = (p) => [.../* @__PURE__ */ new Set([p, real(p)])];
570
570
  const secrets = [
571
571
  ...findSecretFiles(workspace).map((f) => path5.join(workspace, f)),
572
+ ...reads.flatMap((r) => r.secrets),
572
573
  ...HOME_SECRETS.map((f) => path5.join(os3.homedir(), f)).filter((p) => fs6.existsSync(p)),
573
574
  // the keychain link of the worker's own home
574
575
  path5.join(workerHome3, "Library")
@@ -581,7 +582,9 @@ function workerSettings(workspace, workerHome3) {
581
582
  allow: [
582
583
  "command(*)",
583
584
  ...both(path5.join(dataDir(workerHome3), "brain")).map((p) => `write_file(${rulePath(p)})`),
584
- ...toolchains().flatMap(both).map((p) => `read_file(${rulePath(p)})`)
585
+ ...toolchains().flatMap(both).map((p) => `read_file(${rulePath(p)})`),
586
+ // what the task names outside the workspace: a headless agy cannot ask, and would end the turn unanswered
587
+ ...reads.map((r) => r.path).flatMap(both).map((p) => `read_file(${rulePath(p)})`)
585
588
  ],
586
589
  deny: [
587
590
  ...secrets.flatMap(both).map((p) => `read_file(${rulePath(p)})`),
@@ -604,6 +607,96 @@ function toolchains() {
604
607
  const install2 = (d) => path5.basename(d) === "bin" && /[\\/](versions|toolchains)[\\/]/.test(d) ? path5.dirname(d) : d;
605
608
  return [...new Set(dirs.map(install2))].filter((d) => !inHome(home()) || path5.relative(home(), d).startsWith(".."));
606
609
  }
610
+ var isDir = (p) => {
611
+ try {
612
+ return fs6.statSync(p).isDirectory();
613
+ } catch {
614
+ return false;
615
+ }
616
+ };
617
+ var MAX_READS = 20;
618
+ function pathsIn(prompt) {
619
+ const out = [];
620
+ for (const m of prompt.matchAll(/(^|[\s"'`(\[<{=])((?:~|[A-Za-z]:)?[\\/][^\s"'`)\]>}]*)/g)) {
621
+ const raw = m[2].replace(/[.,;:!?]+$/, "");
622
+ const abs = raw.startsWith("~") ? path5.join(os3.homedir(), raw.slice(1)) : raw;
623
+ if (raw && path5.isAbsolute(abs)) out.push({ raw, at: m.index + m[1].length, abs });
624
+ }
625
+ return out;
626
+ }
627
+ var realOf = (p) => {
628
+ try {
629
+ return fs6.realpathSync.native(p);
630
+ } catch {
631
+ return void 0;
632
+ }
633
+ };
634
+ function resolved(p) {
635
+ let dir = path5.resolve(p);
636
+ const rest = [];
637
+ for (; ; ) {
638
+ const r = realOf(dir);
639
+ if (r) return path5.join(r, ...rest);
640
+ const up = path5.dirname(dir);
641
+ if (up === dir) return path5.resolve(p);
642
+ rest.unshift(path5.basename(dir));
643
+ dir = up;
644
+ }
645
+ }
646
+ var inside = (p, dir) => {
647
+ const rel = path5.relative(dir, p);
648
+ return !rel || !rel.startsWith("..") && !path5.isAbsolute(rel);
649
+ };
650
+ var MAX_SCANNED = 2e4;
651
+ function allSecrets(dir) {
652
+ const root = realOf(dir) ?? dir;
653
+ const found = [];
654
+ let seen = 0;
655
+ const stack = [dir];
656
+ while (stack.length) {
657
+ const d = stack.pop();
658
+ let entries;
659
+ try {
660
+ entries = fs6.readdirSync(d, { withFileTypes: true });
661
+ } catch {
662
+ return void 0;
663
+ }
664
+ for (const e of entries) {
665
+ if (++seen > MAX_SCANNED) return void 0;
666
+ const p = path5.join(d, e.name);
667
+ if (e.isSymbolicLink()) {
668
+ const target = realOf(p);
669
+ if (!target || !inside(target, root)) return void 0;
670
+ if (looksSecret(e.name)) found.push(p);
671
+ } else if (e.isDirectory()) stack.push(p);
672
+ else if (looksSecret(e.name)) found.push(p);
673
+ }
674
+ }
675
+ return found;
676
+ }
677
+ function outsideReads(prompt, workspace, project) {
678
+ if (process.platform === "win32") return [];
679
+ const homeDir = realOf(os3.homedir()) ?? os3.homedir();
680
+ const ws = realOf(workspace) ?? path5.resolve(workspace);
681
+ const proj = project ? resolved(project) : void 0;
682
+ const own = realOf(home()) ?? home();
683
+ const found = /* @__PURE__ */ new Map();
684
+ for (const { abs } of pathsIn(prompt)) {
685
+ const p = realOf(abs);
686
+ if (!p || found.has(p) || inside(p, ws) || inside(p, own)) continue;
687
+ if (inside(homeDir, p) || inside(ws, p) || proj && inside(proj, p)) continue;
688
+ if (p.split(/[\\/]/).filter(Boolean).length < 2) continue;
689
+ if (inside(p, homeDir) && /^(\.|Library([\\/]|$))/.test(path5.relative(homeDir, p))) continue;
690
+ if (!isDir(p)) {
691
+ if (!looksSecret(path5.basename(p))) found.set(p, { path: p, secrets: [] });
692
+ } else {
693
+ const secrets = allSecrets(p);
694
+ if (secrets) found.set(p, { path: p, secrets });
695
+ }
696
+ if (found.size >= MAX_READS) break;
697
+ }
698
+ return [...found.values()];
699
+ }
607
700
  function baseSettings() {
608
701
  return {
609
702
  enableTerminalSandbox: true,
@@ -614,12 +707,12 @@ function baseSettings() {
614
707
  }
615
708
  var rulePath = (p) => p.replace(/^[A-Za-z]:(?=[\\/])/, "").replace(/\\/g, "/");
616
709
  var HOMES_KEPT_DAYS = 14;
617
- function workerHome(workspace) {
710
+ function workerHome(workspace, reads = []) {
618
711
  const root = path5.join(home(), "antigravity-home");
619
712
  const id2 = crypto3.createHash("sha256").update(path5.resolve(workspace)).digest("hex").slice(0, 16);
620
713
  const dir = path5.join(root, id2);
621
714
  fs6.mkdirSync(dataDir(dir), { recursive: true });
622
- writeIfChanged(path5.join(dataDir(dir), "settings.json"), `${JSON.stringify(workerSettings(workspace, dir), null, 2)}
715
+ writeIfChanged(path5.join(dataDir(dir), "settings.json"), `${JSON.stringify(workerSettings(workspace, dir, reads), null, 2)}
623
716
  `);
624
717
  if (process.platform === "darwin") {
625
718
  fs6.mkdirSync(path5.join(dir, "Library"), { recursive: true });
@@ -643,11 +736,25 @@ function pruneHomes(root, keep) {
643
736
  } catch {
644
737
  }
645
738
  }
739
+ function inCopy(prompt, project) {
740
+ if (!project || project.root === project.copy) return prompt;
741
+ const root = resolved(project.root);
742
+ let out = "";
743
+ let from = 0;
744
+ for (const { raw, at, abs } of pathsIn(prompt)) {
745
+ const p = resolved(abs);
746
+ if (!inside(p, root)) continue;
747
+ out += prompt.slice(from, at) + path5.join(project.copy, path5.relative(root, p));
748
+ from = at + raw.length;
749
+ }
750
+ return out + prompt.slice(from);
751
+ }
646
752
  function invocation(req) {
753
+ const prompt = inCopy(req.prompt, req.project);
647
754
  const args = [
648
755
  // -p takes the prompt as its value; one that starts with "-" would be read as an option: a leading space keeps it a value.
649
756
  "-p",
650
- req.prompt.startsWith("-") ? ` ${req.prompt}` : req.prompt,
757
+ prompt.startsWith("-") ? ` ${prompt}` : prompt,
651
758
  "--output-format",
652
759
  "stream-json",
653
760
  "--mode",
@@ -662,7 +769,7 @@ function invocation(req) {
662
769
  if (model) args.push("--model", model);
663
770
  if (effort) args.push("--effort", effort);
664
771
  const { command, prefix } = resolveCommand(binary());
665
- const workerDir = workerHome(req.cwd);
772
+ const workerDir = workerHome(req.cwd, outsideReads(prompt, req.cwd, req.project?.root));
666
773
  const env = { HOME: workerDir };
667
774
  if (process.platform === "win32") env.USERPROFILE = workerDir;
668
775
  return { command, args: [...prefix, ...args], env };
@@ -4986,7 +5093,7 @@ function verifyRefs(refs, dirs) {
4986
5093
  if (!files.length) {
4987
5094
  if (!ref.file.includes("/") && !isFileName(ref.file)) continue;
4988
5095
  total++;
4989
- invalid.push({ ref: ref.text, reason: path26.isAbsolute(ref.file) && !inside(ref.file, roots) ? "outside the project" : "file not found" });
5096
+ invalid.push({ ref: ref.text, reason: path26.isAbsolute(ref.file) && !inside2(ref.file, roots) ? "outside the project" : "file not found" });
4990
5097
  continue;
4991
5098
  }
4992
5099
  total++;
@@ -5036,16 +5143,16 @@ function indexFiles(roots) {
5036
5143
  return index;
5037
5144
  }
5038
5145
  function resolve(ref, roots) {
5039
- const candidates = path26.isAbsolute(ref) ? inside(ref, roots) ? [ref] : [] : roots.map((r) => path26.join(r, ref));
5146
+ const candidates = path26.isAbsolute(ref) ? inside2(ref, roots) ? [ref] : [] : roots.map((r) => path26.join(r, ref));
5040
5147
  return candidates.find((c) => {
5041
5148
  try {
5042
- return fs30.statSync(c).isFile() && inside(c, roots);
5149
+ return fs30.statSync(c).isFile() && inside2(c, roots);
5043
5150
  } catch {
5044
5151
  return false;
5045
5152
  }
5046
5153
  });
5047
5154
  }
5048
- function inside(file2, roots) {
5155
+ function inside2(file2, roots) {
5049
5156
  return roots.some((r) => {
5050
5157
  const rel = path26.relative(r, path26.resolve(file2));
5051
5158
  return rel !== "" && !rel.startsWith("..") && !path26.isAbsolute(rel);
@@ -5054,7 +5161,7 @@ function inside(file2, roots) {
5054
5161
  var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5055
5162
 
5056
5163
  // src/core/run.ts
5057
- var VERSION2 = true ? "0.24.0" : "0.0.0-dev";
5164
+ var VERSION2 = true ? "0.25.0" : "0.0.0-dev";
5058
5165
  function planWork(o) {
5059
5166
  if (!o.plan) return { task: o.task.trim(), tier: o.tier };
5060
5167
  if (o.continueFrom) throw new UserError("a follow-up continues its parent's task; drop --plan/--step");
@@ -5471,7 +5578,8 @@ function attempt(meta, backend, target) {
5471
5578
  sessionId: meta.sessionId,
5472
5579
  files: meta.files,
5473
5580
  web: !!meta.web,
5474
- title: `pitroom ${meta.mode}: ${meta.task.replace(/\s+/g, " ").slice(0, 60)}`
5581
+ title: `pitroom ${meta.mode}: ${meta.task.replace(/\s+/g, " ").slice(0, 60)}`,
5582
+ project: meta.repoRoot && (meta.snapshot?.dir ?? meta.worktree) && meta.cwd !== meta.dir ? { root: meta.repoRoot, copy: meta.snapshot?.dir ?? meta.worktree } : void 0
5475
5583
  });
5476
5584
  return spawnWorker(inv, {
5477
5585
  cwd: meta.cwd,
@@ -6587,7 +6695,8 @@ var MISSING = `<!doctype html><meta charset="utf-8"><title>Pitroom</title><body
6587
6695
 
6588
6696
  // src/core/dash.ts
6589
6697
  var DEFAULT_PORT = 7878;
6590
- var WEEK_MS2 = 7 * 24 * 3600 * 1e3;
6698
+ var DAY_MS = 24 * 3600 * 1e3;
6699
+ var PERIODS = { day: 1, week: 7, month: 30, half: 182, all: Infinity };
6591
6700
  var SCAN = 400;
6592
6701
  var RUN_ID3 = /^\d{8}-\d{6}-[0-9a-f]{4}$/;
6593
6702
  var LOCAL_HOST = /^(127\.0\.0\.1|localhost|\[::1\]):\d+$/i;
@@ -6659,10 +6768,17 @@ function dashState(opts = {}) {
6659
6768
  }
6660
6769
  const whole = new Set(metas.slice(0, limit).flatMap((m) => m.group ? [m.group] : []));
6661
6770
  const runs = metas.filter((m, i) => i < limit || m.group && whole.has(m.group)).map(toRun);
6771
+ const now = Date.now();
6772
+ const ledger = readLedger();
6773
+ const within = (at, days2) => !!at && Date.parse(at) >= now - days2 * DAY_MS;
6774
+ const per = (f) => Object.fromEntries(Object.entries(PERIODS).map(([p, days2]) => [p, f(days2)]));
6662
6775
  return {
6663
6776
  price: primaryPrice().name,
6664
6777
  running: runs.filter((r) => r.state === "running" || r.state === "queued").length,
6665
- saved: totals(readLedger(Date.now() - WEEK_MS2)).saved,
6778
+ finished: per(
6779
+ (days2) => opts.group ? metas.filter((m) => m.state === "done" && !m.auditOf && within(m.endedAt, days2)).length : ledger.filter((e) => e.state === "done" && within(e.at, days2)).length
6780
+ ),
6781
+ saved: per((days2) => totals(ledger.filter((e) => within(e.at, days2))).saved),
6666
6782
  groups: [...groups].sort(),
6667
6783
  runs
6668
6784
  };
@@ -7696,7 +7812,7 @@ function answerParts(answer) {
7696
7812
  const summary = s ? s[1] : text.split(/^\s*(?:DETAILS|FILES CHANGED|VERIFICATION|OPEN ISSUES):/m)[0];
7697
7813
  return { summary: summary.trim(), details: (d?.[1] ?? "").trim() };
7698
7814
  }
7699
- function pathsIn(text) {
7815
+ function pathsIn2(text) {
7700
7816
  const found = text.replace(/\\/g, "/").match(/[A-Za-z0-9_.@\-/]+\.[A-Za-z0-9]{1,8}(?::\d+)?/g) ?? [];
7701
7817
  return [...new Set(found.map((p) => clean(p.replace(/:\d+$/, ""))))];
7702
7818
  }
@@ -7708,7 +7824,7 @@ function scoreAnswer(q, truth, answer, root) {
7708
7824
  if ("line" in truth) {
7709
7825
  const flat = summary.replace(/\s+/g, " ");
7710
7826
  if (new RegExp(`(^|[^\\w./-])\\.?/?${escape2(truth.path)}:${truth.line}(?!\\d)`).test(flat)) return { score: 1, got: `${truth.path}:${truth.line}` };
7711
- const named = pathsIn(flat).includes(truth.path);
7827
+ const named = pathsIn2(flat).includes(truth.path);
7712
7828
  return { score: named ? 0.5 : 0, got: flat.slice(0, 120) };
7713
7829
  }
7714
7830
  if ("count" in truth) {
@@ -7716,8 +7832,8 @@ function scoreAnswer(q, truth, answer, root) {
7716
7832
  const got2 = n ? Number(n[0].replace(/,/g, "")) : void 0;
7717
7833
  return { score: got2 === truth.count ? 1 : 0, got: got2 === void 0 ? "(no number)" : String(got2) };
7718
7834
  }
7719
- let got = pathsIn(summary);
7720
- if (!got.length) got = pathsIn(details);
7835
+ let got = pathsIn2(summary);
7836
+ if (!got.length) got = pathsIn2(details);
7721
7837
  const hit = got.filter((p) => truth.files.includes(p)).length;
7722
7838
  const precision = got.length ? hit / got.length : 0;
7723
7839
  const recall = truth.files.length ? hit / truth.files.length : got.length ? 0 : 1;
@@ -7880,9 +7996,9 @@ function writePackage(job) {
7880
7996
  const root = repoRoot(job.dir);
7881
7997
  if (!g || !root) throw new UserError(`not a git repository: ${job.dir}`);
7882
7998
  const name = `review-${crypto13.randomBytes(4).toString("hex")}.md`;
7883
- const inside2 = !path35.relative(fs40.realpathSync(root), fs40.realpathSync(g)).startsWith("..");
7884
- const file2 = inside2 ? path35.join(g, "pitroom", name) : path35.join(root, ".pitroom", name);
7885
- if (!inside2) ignoreInGit(root, "/.pitroom/");
7999
+ const inside3 = !path35.relative(fs40.realpathSync(root), fs40.realpathSync(g)).startsWith("..");
8000
+ const file2 = inside3 ? path35.join(g, "pitroom", name) : path35.join(root, ".pitroom", name);
8001
+ if (!inside3) ignoreInGit(root, "/.pitroom/");
7886
8002
  fs40.mkdirSync(path35.dirname(file2), { recursive: true });
7887
8003
  fs40.writeFileSync(file2, job.package);
7888
8004
  return file2;
@@ -8615,9 +8731,9 @@ function doctor8(probe2) {
8615
8731
  section2("Worker chain");
8616
8732
  const chain = [];
8617
8733
  try {
8618
- const resolved = resolveChain();
8619
- chain.push(resolved.worker, ...resolved.fallback);
8620
- for (const w of resolved.warnings) add2("warn", w);
8734
+ const resolved2 = resolveChain();
8735
+ chain.push(resolved2.worker, ...resolved2.fallback);
8736
+ for (const w of resolved2.warnings) add2("warn", w);
8621
8737
  } catch (e) {
8622
8738
  add2("fail", e.message);
8623
8739
  }