pi-crew 0.2.2 → 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.
Files changed (35) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +35 -0
  3. package/docs/code-review-2026-05-11.md +592 -0
  4. package/docs/followup-plan-2026-05-12.md +463 -0
  5. package/docs/followup-review-2026-05-12.md +297 -0
  6. package/docs/followup-review-round3-2026-05-12.md +342 -0
  7. package/package.json +3 -2
  8. package/src/extension/cross-extension-rpc.ts +1 -0
  9. package/src/extension/registration/subagent-tools.ts +1 -0
  10. package/src/extension/registration/team-tool.ts +1 -0
  11. package/src/extension/team-manager-command.ts +1 -0
  12. package/src/extension/team-tool/run.ts +1 -0
  13. package/src/extension/team-tool.ts +344 -332
  14. package/src/runtime/async-runner.ts +89 -15
  15. package/src/runtime/background-runner.ts +1 -0
  16. package/src/runtime/child-pi.ts +2 -4
  17. package/src/runtime/iteration-hooks.ts +5 -2
  18. package/src/runtime/live-session-runtime.ts +1 -0
  19. package/src/runtime/post-checks.ts +5 -2
  20. package/src/runtime/runtime-resolver.ts +1 -0
  21. package/src/runtime/subagent-manager.ts +5 -0
  22. package/src/runtime/task-runner.ts +1 -0
  23. package/src/runtime/yield-handler.ts +1 -0
  24. package/src/schema/team-tool-schema.ts +1 -0
  25. package/src/state/artifact-store.ts +2 -2
  26. package/src/state/atomic-write.ts +21 -4
  27. package/src/state/event-log.ts +110 -47
  28. package/src/state/locks.ts +12 -14
  29. package/src/ui/run-action-dispatcher.ts +1 -0
  30. package/src/utils/env-filter.ts +30 -0
  31. package/src/utils/redaction.ts +1 -1
  32. package/src/utils/resolve-shell.ts +34 -0
  33. package/src/utils/sleep.ts +2 -1
  34. package/src/worktree/cleanup.ts +5 -2
  35. 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
+ }
@@ -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 {
@@ -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, ["worktree", "remove", options.force ? "--force" : "", worktreePath].filter(Boolean));
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
- if (!fs.existsSync(source) || fs.existsSync(target)) return false;
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
- // Hook output was not valid JSON — treat as no synthetic paths
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
- git(repoRoot, ["worktree", "add", "-b", branch, worktreePath, "HEAD"]);
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 };