pi-crew 0.2.1 → 0.2.3
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/AGENTS.md +1 -1
- package/CHANGELOG.md +35 -0
- package/docs/code-review-2026-05-11.md +592 -0
- package/docs/followup-plan-2026-05-12.md +463 -0
- package/docs/followup-review-2026-05-12.md +297 -0
- package/docs/followup-review-round3-2026-05-12.md +342 -0
- package/package.json +3 -2
- package/src/extension/cross-extension-rpc.ts +1 -0
- package/src/extension/registration/subagent-tools.ts +1 -0
- package/src/extension/registration/team-tool.ts +1 -0
- package/src/extension/team-manager-command.ts +1 -0
- package/src/extension/team-tool/run.ts +1 -0
- package/src/extension/team-tool.ts +344 -332
- package/src/runtime/async-runner.ts +89 -15
- package/src/runtime/background-runner.ts +1 -0
- package/src/runtime/child-pi.ts +2 -4
- package/src/runtime/iteration-hooks.ts +5 -2
- package/src/runtime/live-session-runtime.ts +1 -0
- package/src/runtime/post-checks.ts +5 -2
- package/src/runtime/runtime-resolver.ts +1 -0
- package/src/runtime/subagent-manager.ts +5 -0
- package/src/runtime/task-runner.ts +1 -0
- package/src/runtime/yield-handler.ts +1 -0
- package/src/schema/team-tool-schema.ts +1 -0
- package/src/state/artifact-store.ts +2 -2
- package/src/state/atomic-write.ts +21 -4
- package/src/state/event-log.ts +110 -47
- package/src/state/locks.ts +12 -14
- package/src/ui/run-action-dispatcher.ts +1 -0
- package/src/utils/env-filter.ts +30 -0
- package/src/utils/redaction.ts +1 -1
- package/src/utils/resolve-shell.ts +34 -0
- package/src/utils/sleep.ts +2 -1
- package/src/worktree/cleanup.ts +5 -2
- package/src/worktree/worktree-manager.ts +47 -5
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the bash executable path, with Windows fallbacks for Git Bash.
|
|
5
|
+
*/
|
|
6
|
+
export function resolveBashCmd(): string {
|
|
7
|
+
if (process.platform !== "win32") return "bash";
|
|
8
|
+
const candidates = [
|
|
9
|
+
process.env.SHELL,
|
|
10
|
+
"C:\\Program Files\\Git\\bin\\bash.exe",
|
|
11
|
+
"C:\\Program Files (x86)\\Git\\bin\\bash.exe",
|
|
12
|
+
];
|
|
13
|
+
for (const cand of candidates) {
|
|
14
|
+
if (cand && fs.existsSync(cand)) return cand;
|
|
15
|
+
}
|
|
16
|
+
return "bash";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Choose the right shell command and arguments for a script path.
|
|
21
|
+
* On Windows, powershell (.ps1) and batch (.cmd/.bat) run natively.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveShellForScript(scriptPath: string): { command: string; args: string[] } {
|
|
24
|
+
if (process.platform === "win32") {
|
|
25
|
+
if (scriptPath.endsWith(".ps1")) {
|
|
26
|
+
return { command: "powershell", args: ["-File", scriptPath] };
|
|
27
|
+
}
|
|
28
|
+
if (scriptPath.endsWith(".cmd") || scriptPath.endsWith(".bat")) {
|
|
29
|
+
// Node >= 20 blocks direct spawn of .bat/.cmd without shell (CVE-2024-27980)
|
|
30
|
+
return { command: process.env.ComSpec ?? "cmd.exe", args: ["/d", "/s", "/c", scriptPath] };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { command: resolveBashCmd(), args: [scriptPath] };
|
|
34
|
+
}
|
package/src/utils/sleep.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Synchronous sleep using Atomics.wait (non-busy) with low-CPU fallback.
|
|
3
5
|
*
|
|
@@ -14,7 +16,6 @@ export function sleepSync(ms: number): void {
|
|
|
14
16
|
// On Unix, try spawning the `sleep` command to avoid CPU busy-wait.
|
|
15
17
|
if (process.platform !== "win32" && ms >= 10) {
|
|
16
18
|
try {
|
|
17
|
-
const { execFileSync } = require("node:child_process") as typeof import("node:child_process");
|
|
18
19
|
execFileSync("sleep", [(ms / 1000).toFixed(3)], { timeout: ms + 1000, stdio: "pipe" });
|
|
19
20
|
return;
|
|
20
21
|
} catch {
|
package/src/worktree/cleanup.ts
CHANGED
|
@@ -13,7 +13,7 @@ export interface WorktreeCleanupResult {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
function git(cwd: string, args: string[]): string {
|
|
16
|
-
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
16
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" } }).trim();
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
function isDirty(worktreePath: string): boolean {
|
|
@@ -54,8 +54,11 @@ export function cleanupRunWorktrees(manifest: TeamRunManifest, options: { force?
|
|
|
54
54
|
result.preserved.push({ path: worktreePath, reason: "dirty worktree preserved" });
|
|
55
55
|
continue;
|
|
56
56
|
}
|
|
57
|
+
const args = ["worktree", "remove"];
|
|
58
|
+
if (options.force) args.push("--force");
|
|
59
|
+
args.push(worktreePath);
|
|
57
60
|
try {
|
|
58
|
-
git(manifest.cwd,
|
|
61
|
+
git(manifest.cwd, args);
|
|
59
62
|
result.removed.push(worktreePath);
|
|
60
63
|
} catch (error) {
|
|
61
64
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -4,6 +4,8 @@ import * as path from "node:path";
|
|
|
4
4
|
import { loadConfig } from "../config/config.ts";
|
|
5
5
|
import { projectCrewRoot } from "../utils/paths.ts";
|
|
6
6
|
import { DEFAULT_PATHS } from "../config/defaults.ts";
|
|
7
|
+
import { logInternalError } from "../utils/internal-error.ts";
|
|
8
|
+
import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
|
|
7
9
|
import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
|
|
8
10
|
|
|
9
11
|
export interface PreparedTaskWorkspace {
|
|
@@ -23,7 +25,7 @@ export interface WorktreeDiffStat {
|
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
function git(cwd: string, args: string[]): string {
|
|
26
|
-
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
28
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, LANG: "C", LC_ALL: "C" } }).trim();
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
function sanitizeBranchPart(value: string): string {
|
|
@@ -44,7 +46,10 @@ export function assertCleanLeader(repoRoot: string): void {
|
|
|
44
46
|
function linkNodeModulesIfPresent(repoRoot: string, worktreePath: string): boolean {
|
|
45
47
|
const source = path.join(repoRoot, "node_modules");
|
|
46
48
|
const target = path.join(worktreePath, "node_modules");
|
|
47
|
-
|
|
49
|
+
let sourceStat: fs.Stats;
|
|
50
|
+
try { sourceStat = fs.statSync(source); } catch { return false; }
|
|
51
|
+
if (!sourceStat.isDirectory()) return false;
|
|
52
|
+
if (fs.existsSync(target)) return false;
|
|
48
53
|
try {
|
|
49
54
|
fs.symlinkSync(source, target, process.platform === "win32" ? "junction" : "dir");
|
|
50
55
|
return true;
|
|
@@ -72,6 +77,9 @@ function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot:
|
|
|
72
77
|
input: JSON.stringify({ version: 1, repoRoot, worktreePath, agentCwd: worktreePath, branch, runId: manifest.runId, taskId: task.id, agent: task.agent }),
|
|
73
78
|
timeout: cfg.setupHookTimeoutMs ?? 30_000,
|
|
74
79
|
shell: false,
|
|
80
|
+
env: sanitizeEnvSecrets(process.env, {
|
|
81
|
+
allowList: ["PATH", "HOME", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "PI_*"],
|
|
82
|
+
}),
|
|
75
83
|
});
|
|
76
84
|
if (result.error) throw new Error(`worktree setup hook failed: ${result.error.message}`);
|
|
77
85
|
if (result.status !== 0) throw new Error(`worktree setup hook failed with exit code ${result.status}: ${result.stderr || result.stdout || "no output"}`);
|
|
@@ -84,12 +92,29 @@ function runSetupHook(manifest: TeamRunManifest, task: TeamTaskState, repoRoot:
|
|
|
84
92
|
const parsed = JSON.parse(lastLine) as { syntheticPaths?: unknown };
|
|
85
93
|
if (!Array.isArray(parsed.syntheticPaths)) return [];
|
|
86
94
|
return [...new Set(parsed.syntheticPaths.filter((entry): entry is string => typeof entry === "string").map((entry) => normalizeSyntheticPath(worktreePath, entry)))];
|
|
87
|
-
} catch {
|
|
88
|
-
|
|
95
|
+
} catch (error) {
|
|
96
|
+
logInternalError("worktree.setupHook.parse", error, `lastLine=${(trimmed.split(/\r?\n/).pop() ?? "").slice(0, 200)}`);
|
|
89
97
|
return [];
|
|
90
98
|
}
|
|
91
99
|
}
|
|
92
100
|
|
|
101
|
+
function branchExists(repoRoot: string, branch: string): { local: boolean; remoteOnly: boolean } {
|
|
102
|
+
let local = false;
|
|
103
|
+
try { git(repoRoot, ["rev-parse", "--verify", `refs/heads/${branch}`]); local = true; } catch {}
|
|
104
|
+
if (local) return { local: true, remoteOnly: false };
|
|
105
|
+
// Check remote-tracking branch
|
|
106
|
+
try {
|
|
107
|
+
const out = execFileSync("git", ["for-each-ref", "--format=%(refname)", `refs/remotes/*/${branch}`],
|
|
108
|
+
{ cwd: repoRoot, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
109
|
+
return { local: false, remoteOnly: out.length > 0 };
|
|
110
|
+
} catch { return { local: false, remoteOnly: false }; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function pruneStaleWorktrees(repoRoot: string): void {
|
|
114
|
+
try { execFileSync("git", ["worktree", "prune"], { cwd: repoRoot, stdio: "ignore" }); }
|
|
115
|
+
catch { /* best-effort */ }
|
|
116
|
+
}
|
|
117
|
+
|
|
93
118
|
export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskState): PreparedTaskWorkspace {
|
|
94
119
|
if (manifest.workspaceMode !== "worktree") return { cwd: task.cwd };
|
|
95
120
|
const repoRoot = findGitRoot(manifest.cwd);
|
|
@@ -111,7 +136,24 @@ export function prepareTaskWorkspace(manifest: TeamRunManifest, task: TeamTaskSt
|
|
|
111
136
|
}
|
|
112
137
|
return { cwd: worktreePath, worktreePath, branch, reused: true };
|
|
113
138
|
}
|
|
114
|
-
|
|
139
|
+
pruneStaleWorktrees(repoRoot);
|
|
140
|
+
const exists = branchExists(repoRoot, branch);
|
|
141
|
+
try {
|
|
142
|
+
if (exists.local) {
|
|
143
|
+
git(repoRoot, ["worktree", "add", worktreePath, branch]);
|
|
144
|
+
} else {
|
|
145
|
+
if (exists.remoteOnly) {
|
|
146
|
+
logInternalError("worktree.branchRemoteOnly", new Error(`Branch '${branch}' exists only on remote; creating local from HEAD instead of tracking remote.`), `branch=${branch}`);
|
|
147
|
+
}
|
|
148
|
+
git(repoRoot, ["worktree", "add", "-b", branch, worktreePath, "HEAD"]);
|
|
149
|
+
}
|
|
150
|
+
} catch (error) {
|
|
151
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
152
|
+
if (/already checked out|is already used by worktree/i.test(msg)) {
|
|
153
|
+
throw new Error(`Branch '${branch}' is checked out at another worktree. Run \`team cleanup runId=${manifest.runId} force=true\` or manually remove the conflicting worktree.`);
|
|
154
|
+
}
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
115
157
|
const syntheticPaths = runSetupHook(manifest, task, repoRoot, worktreePath, branch);
|
|
116
158
|
const nodeModulesLinked = loadedConfig.config.worktree?.linkNodeModules === true ? linkNodeModulesIfPresent(repoRoot, worktreePath) : false;
|
|
117
159
|
return { cwd: worktreePath, worktreePath, branch, reused: false, nodeModulesLinked, syntheticPaths };
|