pitroom 0.24.0 → 0.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +5 -0
- package/dist/lib.mjs +123 -16
- package/dist/pitroom.mjs +131 -23
- package/docs/backends.md +1 -0
- package/gemini-extension.json +1 -1
- package/package.json +1 -1
|
@@ -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.
|
|
4
|
+
"version": "0.24.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TahaAtas"
|
|
7
7
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pitroom",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.1",
|
|
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,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.24.1
|
|
4
|
+
|
|
5
|
+
### Workers
|
|
6
|
+
- **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.
|
|
7
|
+
|
|
3
8
|
## 0.24.0
|
|
4
9
|
|
|
5
10
|
### 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
|
-
|
|
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) && !
|
|
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) ?
|
|
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() &&
|
|
2450
|
+
return fs12.statSync(c).isFile() && inside2(c, roots);
|
|
2344
2451
|
} catch {
|
|
2345
2452
|
return false;
|
|
2346
2453
|
}
|
|
2347
2454
|
});
|
|
2348
2455
|
}
|
|
2349
|
-
function
|
|
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
|
|
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 =
|
|
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 =
|
|
2987
|
-
if (!got.length) got =
|
|
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
|
-
|
|
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) && !
|
|
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) ?
|
|
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() &&
|
|
5149
|
+
return fs30.statSync(c).isFile() && inside2(c, roots);
|
|
5043
5150
|
} catch {
|
|
5044
5151
|
return false;
|
|
5045
5152
|
}
|
|
5046
5153
|
});
|
|
5047
5154
|
}
|
|
5048
|
-
function
|
|
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.
|
|
5164
|
+
var VERSION2 = true ? "0.24.1" : "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,
|
|
@@ -7696,7 +7804,7 @@ function answerParts(answer) {
|
|
|
7696
7804
|
const summary = s ? s[1] : text.split(/^\s*(?:DETAILS|FILES CHANGED|VERIFICATION|OPEN ISSUES):/m)[0];
|
|
7697
7805
|
return { summary: summary.trim(), details: (d?.[1] ?? "").trim() };
|
|
7698
7806
|
}
|
|
7699
|
-
function
|
|
7807
|
+
function pathsIn2(text) {
|
|
7700
7808
|
const found = text.replace(/\\/g, "/").match(/[A-Za-z0-9_.@\-/]+\.[A-Za-z0-9]{1,8}(?::\d+)?/g) ?? [];
|
|
7701
7809
|
return [...new Set(found.map((p) => clean(p.replace(/:\d+$/, ""))))];
|
|
7702
7810
|
}
|
|
@@ -7708,7 +7816,7 @@ function scoreAnswer(q, truth, answer, root) {
|
|
|
7708
7816
|
if ("line" in truth) {
|
|
7709
7817
|
const flat = summary.replace(/\s+/g, " ");
|
|
7710
7818
|
if (new RegExp(`(^|[^\\w./-])\\.?/?${escape2(truth.path)}:${truth.line}(?!\\d)`).test(flat)) return { score: 1, got: `${truth.path}:${truth.line}` };
|
|
7711
|
-
const named =
|
|
7819
|
+
const named = pathsIn2(flat).includes(truth.path);
|
|
7712
7820
|
return { score: named ? 0.5 : 0, got: flat.slice(0, 120) };
|
|
7713
7821
|
}
|
|
7714
7822
|
if ("count" in truth) {
|
|
@@ -7716,8 +7824,8 @@ function scoreAnswer(q, truth, answer, root) {
|
|
|
7716
7824
|
const got2 = n ? Number(n[0].replace(/,/g, "")) : void 0;
|
|
7717
7825
|
return { score: got2 === truth.count ? 1 : 0, got: got2 === void 0 ? "(no number)" : String(got2) };
|
|
7718
7826
|
}
|
|
7719
|
-
let got =
|
|
7720
|
-
if (!got.length) got =
|
|
7827
|
+
let got = pathsIn2(summary);
|
|
7828
|
+
if (!got.length) got = pathsIn2(details);
|
|
7721
7829
|
const hit = got.filter((p) => truth.files.includes(p)).length;
|
|
7722
7830
|
const precision = got.length ? hit / got.length : 0;
|
|
7723
7831
|
const recall = truth.files.length ? hit / truth.files.length : got.length ? 0 : 1;
|
|
@@ -7880,9 +7988,9 @@ function writePackage(job) {
|
|
|
7880
7988
|
const root = repoRoot(job.dir);
|
|
7881
7989
|
if (!g || !root) throw new UserError(`not a git repository: ${job.dir}`);
|
|
7882
7990
|
const name = `review-${crypto13.randomBytes(4).toString("hex")}.md`;
|
|
7883
|
-
const
|
|
7884
|
-
const file2 =
|
|
7885
|
-
if (!
|
|
7991
|
+
const inside3 = !path35.relative(fs40.realpathSync(root), fs40.realpathSync(g)).startsWith("..");
|
|
7992
|
+
const file2 = inside3 ? path35.join(g, "pitroom", name) : path35.join(root, ".pitroom", name);
|
|
7993
|
+
if (!inside3) ignoreInGit(root, "/.pitroom/");
|
|
7886
7994
|
fs40.mkdirSync(path35.dirname(file2), { recursive: true });
|
|
7887
7995
|
fs40.writeFileSync(file2, job.package);
|
|
7888
7996
|
return file2;
|
|
@@ -8615,9 +8723,9 @@ function doctor8(probe2) {
|
|
|
8615
8723
|
section2("Worker chain");
|
|
8616
8724
|
const chain = [];
|
|
8617
8725
|
try {
|
|
8618
|
-
const
|
|
8619
|
-
chain.push(
|
|
8620
|
-
for (const w of
|
|
8726
|
+
const resolved2 = resolveChain();
|
|
8727
|
+
chain.push(resolved2.worker, ...resolved2.fallback);
|
|
8728
|
+
for (const w of resolved2.warnings) add2("warn", w);
|
|
8621
8729
|
} catch (e) {
|
|
8622
8730
|
add2("fail", e.message);
|
|
8623
8731
|
}
|
package/docs/backends.md
CHANGED
|
@@ -182,6 +182,7 @@ How a worker is started, and why:
|
|
|
182
182
|
- **Read runs read a snapshot of their own** (`readOnly: 'snapshot'`). agy has no read-only mode for `-p` runs (issue 45 of google-antigravity/antigravity-cli), and `--mode plan` is reported to still write in them (issue 1181: the file is created in the same turn). So the core never lets Antigravity read the project: right before its attempt it makes the run a snapshot of its own (`snapshots/<tree>-<run id>`, made like the shared clean snapshot: from git objects, uncommitted and untracked files included, secret-looking and ignored files left out, files read-only), whatever `readIn` says. Not the shared one: directories there stay writable, so a new or replaced file would be seen by every later read run on the same state. Its follow-ups read the same snapshot. After the attempt, whatever git sees newly changed there is reported as `READ-ONLY VIOLATION` (the stream need not mention it; a follow-up is not blamed for its parent's writes), and the answer is not cached. A worker after it in the chain reads where it was meant to (the project or the shared snapshot), never Antigravity's, and a second Antigravity target gets a fresh snapshot if the first wrote in its own. As a fallback it costs nothing until it runs. These snapshots are full checkouts: pruning keeps the newest few of them apart from the shared ones, so a burst of Antigravity runs does not push those out, and a follow-up whose snapshot was pruned gets it made again from the same tree. If the snapshot cannot be made, Antigravity is skipped like a failed attempt (the next worker runs, or the run fails: as the worker before it failed, a rate limit staying one, or, when no worker ran at all, without counting against any worker's record); it never falls back to the project. `--in-place`, reviews (whose package lives in the git folder) and folders outside git read the directory itself, with a warning. `pitroom doctor --probe` runs it in an empty temporary folder. `--mode plan` is passed anyway; write and isolate use `--mode accept-edits`. Never `--dangerously-skip-permissions` (the contract tests reject it).
|
|
183
183
|
- **`--sandbox`** in every mode: shell commands run in agy's terminal sandbox (the workspace writable, its `.git` read-only, `.env` and `~/.ssh` unreadable, no network). The sandbox and the permission rules are for macOS and Linux; on Windows agy still uses its previous permission system, and `doctor` says to use `-i` there.
|
|
184
184
|
- **A home of its own per workspace.** agy reads its settings only from `~/.gemini/antigravity-cli/settings.json` (and its permissions from `~/.gemini/config`); `ANTIGRAVITY_APP_DATA_DIR` and every other variable tried leave its data folder where it is (its log says `CLI app data directory: …`). So a worker runs with `HOME` set to `<pitroom home>/antigravity-home/<id>`, one per workspace (the run's snapshot, its isolated copy, or the project for `-w`), where Pitroom writes `settings.json` (atomically: a parallel worker never reads half a file). The sign-in is linked in: on macOS agy finds it in the login keychain through `$HOME/Library/Keychains`, which is linked to yours; elsewhere it is the file `antigravity-oauth-token`, linked from `~/.gemini/antigravity-cli` (and a dangling link is repaired). Conversations stay in that home, so a follow-up, which runs in the same workspace, finds its own; homes not used for two weeks are removed. The worker's commands see that home too: your dotfiles are out of their reach, your own agy settings, plugins and MCP servers do not apply, and agy picks its default model unless `-W antigravity:<model>` names one. The settings: `enableTerminalSandbox`, `toolPermission: proceed-in-sandbox` and the grant `command(*)` (a headless run cannot answer a prompt: an ungranted command is refused, and the turn may end without an answer), `artifactReviewPolicy: always-proceed`, `allowNonWorkspaceAccess: false`, `write_file` for its own `brain` folder (plan mode writes its plan there, outside the workspace), `read_file` for the folders of your PATH under your home (the sandbox sees only the system and what is granted: without it an nvm `node` is `Operation not permitted`; a `bin` in a versioned install brings the install along, other folders only themselves, as `~/.cargo` holds credentials), and deny rules (deny beats allow): the workspace's secret-looking files and the secrets in your home (`.ssh`, `.aws`, `.gnupg`, `.netrc`, `.config/gh`, …) by absolute path, the shared forbidden commands (`src/backends/deny.ts`), `mcp(*)`, `read_url(*)` and `execute_url(*)`.
|
|
185
|
+
- **What the task names outside the workspace** is granted read-only: the folders and files it gives by absolute path (or `~/…`) that exist, with every secret-looking file in them denied (looked for at any depth and in every folder; a folder of more than 20,000 entries, with a link leading out of it or nowhere, or with a folder that cannot be read, is not granted, as its secrets could not all be found; a named path counts where its links lead); without a grant a headless agy refuses the read and ends the turn without an answer. Never the root or a top-level folder (`/tmp`), your home itself or a folder above it, a folder holding the workspace or the project (it would open the project around a snapshot, secrets included), a dot-folder in your home (`~/.config`, `~/.npmrc`) or `~/Library` (apps keep session tokens there), Pitroom's own state or a secret-looking file; and nothing on Windows, where agy compares paths without their drive letter, so a grant would open the same path on every drive. In a snapshot or an isolated copy, the project's own paths in the task are rewritten to the copy's (compared with links resolved), so the worker works on its copy rather than being refused at the project, which it may not read or write.
|
|
185
186
|
- **How agy matches the rules**, as seen in 1.3.5 (its docs say only "prefix, word by word"): `read_file` takes an absolute path or one relative to the workspace, as a folder prefix, and no pattern; a `regex:` target is not read, and a relative path breaks the sandbox (`sandbox configuration error: … non-absolute file path`) without denying anything, so secret files are listed one by one. A `command` rule is compared word by word with the start of each command in the line, after agy has split it at `;`, `&&`, `||` and `|` and taken off wrappers such as `env -i` and `timeout 60` (but not `xargs`, and not what find runs with `-exec`). A `regex:` command rule is split at spaces too, each word a regex for one word (`.*` is exactly one word), and backslashes are dropped. A line with `{}` in it is not split: a rule is matched against the whole line, start to end. The rules are built for that: each forbidden command as it is and by path, git behind its global options, a recursive `rm` wherever its option stands, `sh -c` and `eval`, find with `-exec`, `-ok` or `-delete` refused outright (a rule for what it runs only holds when it spans the whole line), xargs running a forbidden command, and, for lines with `{}`, `git <subcommand>`, `rm -r`, xargs and find anywhere in them. `test/backends.test.mjs` checks them with that matching; they remain pattern rules, **a layer on top, not a guarantee**: other spellings of a command can get past them. The git guard is what holds for git whatever the spelling (`.git` is read-only in the sandbox too), and a read run's snapshot or an isolated copy is what holds for your files: use `-i` for changes rather than `-w`, which lets a worker write (and delete) in the project itself. One settings file serves every mode, since a follow-up may run in another: so **`--web` does not apply** to Antigravity workers.
|
|
186
187
|
- **`--print-timeout 24h`**: agy's docs give its own limit as 5 minutes (1.3.5's help says none), and a run that hits it ends with partial output; Pitroom's timeout governs instead.
|
|
187
188
|
- **Model**: `-W antigravity:<model>` passes `--model` (`agy models` lists yours), and `#level` passes `--effort`. Without one, agy picks. Its quota is per Google account and can run out for hours: give it a fallback.
|
package/gemini-extension.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pitroom",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.1",
|
|
4
4
|
"description": "A free pit crew for your expensive coding agent: hand reading, fixes, tests and reviews to cheap worker agents and get back the answer, the exact diff and a receipt. Fourteen skills adapt a full development workflow. Needs the pitroom command (npm i -g pitroom).",
|
|
5
5
|
"contextFileName": "gemini-context.md"
|
|
6
6
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pitroom",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.1",
|
|
4
4
|
"mcpName": "io.github.ATASTECH/pitroom",
|
|
5
5
|
"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, Claude Code or Gemini CLI workers, with receipts that prove the savings.",
|
|
6
6
|
"type": "module",
|