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
@@ -11,6 +11,16 @@ export type FileExists = (filePath: string) => boolean;
11
11
 
12
12
  const requireFromHere = createRequire(import.meta.url);
13
13
 
14
+ // Node introduced --experimental-strip-types in v22.6.0
15
+ const STRIP_TYPES_MIN_MAJOR = 22;
16
+ const STRIP_TYPES_MIN_MINOR = 6;
17
+
18
+ export type LoaderSpec =
19
+ | { kind: "jiti"; path: string }
20
+ | { kind: "strip-types" };
21
+
22
+ type LoaderInput = LoaderSpec | string | false | undefined;
23
+
14
24
  function packageRootFromRuntime(): string {
15
25
  return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
16
26
  }
@@ -20,23 +30,87 @@ function jitiRegisterPathFromPackageJson(packageJsonPath: string): string {
20
30
  }
21
31
 
22
32
  export function resolveJitiRegisterPath(packageRoot = packageRootFromRuntime(), exists: FileExists = fs.existsSync): string | undefined {
23
- const candidates = [
24
- path.join(packageRoot, "node_modules", "jiti", "lib", "jiti-register.mjs"),
25
- path.join(packageRoot, "..", "..", "node_modules", "jiti", "lib", "jiti-register.mjs"),
26
- ];
33
+ // Walk upward from packageRoot looking for node_modules/jiti/lib/jiti-register.mjs
34
+ let current = path.resolve(packageRoot);
35
+ const root = path.parse(current).root;
36
+ while (true) {
37
+ const candidate = path.join(current, "node_modules", "jiti", "lib", "jiti-register.mjs");
38
+ if (exists(candidate)) return candidate;
39
+ if (current === root) break;
40
+ const parent = path.dirname(current);
41
+ if (parent === current) break;
42
+ current = parent;
43
+ }
44
+ // Fallback: require resolution (handles global installs or isolated stores)
27
45
  try {
28
- candidates.push(jitiRegisterPathFromPackageJson(requireFromHere.resolve("jiti/package.json")));
46
+ const pkgPath = requireFromHere.resolve("jiti/package.json");
47
+ const candidates = [
48
+ jitiRegisterPathFromPackageJson(pkgPath),
49
+ path.join(path.dirname(pkgPath), "register.mjs"),
50
+ path.join(path.dirname(pkgPath), "dist", "register.mjs"),
51
+ ];
52
+ for (const c of candidates) if (exists(c)) return c;
29
53
  } catch {
30
- // Fall through to explicit candidate checks.
54
+ // Fall through.
31
55
  }
32
- return [...new Set(candidates)].find((candidate) => exists(candidate));
56
+ return undefined;
57
+ }
58
+
59
+ export function nodeSupportsStripTypes(version = process.version): boolean {
60
+ const match = /^v?(\d+)\.(\d+)/.exec(version);
61
+ if (!match) return false;
62
+ const major = Number(match[1]);
63
+ const minor = Number(match[2]);
64
+ if (major > STRIP_TYPES_MIN_MAJOR) return true;
65
+ if (major === STRIP_TYPES_MIN_MAJOR && minor >= STRIP_TYPES_MIN_MINOR) return true;
66
+ return false;
67
+ }
68
+
69
+ export interface ResolveLoaderOptions {
70
+ packageRoot?: string;
71
+ exists?: FileExists;
72
+ nodeVersion?: string;
33
73
  }
34
74
 
35
- export function getBackgroundRunnerCommand(runnerPath: string, cwd: string, runId: string, jitiRegisterPath: string | false | undefined = resolveJitiRegisterPath()): { args: string[]; loader: "jiti" } {
36
- if (!jitiRegisterPath) throw new Error("pi-crew background runner cannot start: jiti loader not found. Reinstall pi-crew (`pi install npm:pi-crew`) or ensure node_modules/jiti is present.");
75
+ export function resolveTypeScriptLoader(opts: ResolveLoaderOptions = {}): LoaderSpec | undefined {
76
+ const jitiPath = resolveJitiRegisterPath(opts.packageRoot, opts.exists);
77
+ if (jitiPath) return { kind: "jiti", path: jitiPath };
78
+ if (nodeSupportsStripTypes(opts.nodeVersion)) return { kind: "strip-types" };
79
+ return undefined;
80
+ }
81
+
82
+ function normalizeLoaderInput(input: LoaderInput): LoaderSpec | undefined {
83
+ if (input === undefined || input === null || input === false || input === "") return undefined;
84
+ if (typeof input === "string") return { kind: "jiti", path: input };
85
+ return input;
86
+ }
87
+
88
+ function buildLoaderUnavailableMessage(searchedFrom: string): string {
89
+ return [
90
+ "pi-crew background runner cannot start: jiti loader not found and Node --experimental-strip-types fallback unavailable.",
91
+ ` - Searched for node_modules/jiti walking upward from: ${searchedFrom}`,
92
+ ` - Node --experimental-strip-types requires >= 22.6 (current: ${process.version})`,
93
+ " - Fix: run 'npm install' in the pi-crew directory, reinstall via 'pi install npm:pi-crew', or upgrade Node.js to >= 22.6.",
94
+ ].join("\n");
95
+ }
96
+
97
+ export function getBackgroundRunnerCommand(
98
+ runnerPath: string,
99
+ cwd: string,
100
+ runId: string,
101
+ loaderInput: LoaderInput = resolveTypeScriptLoader(),
102
+ ): { args: string[]; loader: "jiti" | "strip-types" } {
103
+ const loader = normalizeLoaderInput(loaderInput);
104
+ if (!loader) throw new Error(buildLoaderUnavailableMessage(packageRootFromRuntime()));
105
+ if (loader.kind === "jiti") {
106
+ return {
107
+ args: ["--import", pathToFileURL(loader.path).href, runnerPath, "--cwd", cwd, "--run-id", runId],
108
+ loader: "jiti",
109
+ };
110
+ }
37
111
  return {
38
- args: ["--import", pathToFileURL(jitiRegisterPath).href, runnerPath, "--cwd", cwd, "--run-id", runId],
39
- loader: "jiti",
112
+ args: ["--experimental-strip-types", runnerPath, "--cwd", cwd, "--run-id", runId],
113
+ loader: "strip-types",
40
114
  };
41
115
  }
42
116
 
@@ -61,13 +135,13 @@ export function spawnBackgroundTeamRun(manifest: TeamRunManifest): SpawnBackgrou
61
135
  fs.mkdirSync(manifest.stateRoot, { recursive: true });
62
136
  const logFd = fs.openSync(logPath, "a");
63
137
  try {
64
- const jitiRegisterPath = resolveJitiRegisterPath();
65
- if (!jitiRegisterPath) {
66
- const message = "pi-crew background runner cannot start: jiti loader not found. Reinstall pi-crew (`pi install npm:pi-crew`) or ensure node_modules/jiti is present.";
138
+ const loader = resolveTypeScriptLoader();
139
+ if (!loader) {
140
+ const message = buildLoaderUnavailableMessage(packageRootFromRuntime());
67
141
  appendEvent(manifest.eventsPath, { type: "async.failed", runId: manifest.runId, message });
68
142
  throw new Error(message);
69
143
  }
70
- const command = getBackgroundRunnerCommand(runnerPath, manifest.cwd, manifest.runId, jitiRegisterPath);
144
+ const command = getBackgroundRunnerCommand(runnerPath, manifest.cwd, manifest.runId, loader);
71
145
  fs.appendFileSync(logPath, `[pi-crew] background loader=${command.loader}\n`, "utf-8");
72
146
  const child = spawn(process.execPath, command.args, buildBackgroundSpawnOptions(manifest, logFd));
73
147
  child.unref();
@@ -10,6 +10,7 @@ import type { executeTeamRun as ExecuteTeamRunFn } from "./team-runner.ts";
10
10
  let _cachedExecuteTeamRun: typeof ExecuteTeamRunFn | undefined;
11
11
  async function executeTeamRun(...args: Parameters<typeof ExecuteTeamRunFn>): Promise<Awaited<ReturnType<typeof ExecuteTeamRunFn>>> {
12
12
  if (!_cachedExecuteTeamRun) {
13
+ // LAZY: avoid pulling team-runner into background-runner at module load time.
13
14
  const mod = await import("./team-runner.ts");
14
15
  _cachedExecuteTeamRun = mod.executeTeamRun;
15
16
  }
@@ -9,6 +9,7 @@ import { DEFAULT_CHILD_PI } from "../config/defaults.ts";
9
9
  import { logInternalError } from "../utils/internal-error.ts";
10
10
  import { attachPostExitStdioGuard, trySignalChild } from "./post-exit-stdio-guard.ts";
11
11
  import { redactJsonLine, SECRET_KEY_PATTERN } from "../utils/redaction.ts";
12
+ import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
12
13
 
13
14
  const POST_EXIT_STDIO_GUARD_MS = DEFAULT_CHILD_PI.postExitStdioGuardMs;
14
15
  const FINAL_DRAIN_MS = DEFAULT_CHILD_PI.finalDrainMs;
@@ -111,10 +112,7 @@ export interface ChildPiRunResult {
111
112
 
112
113
  export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv): SpawnOptions {
113
114
  // Filter out env vars whose keys match secret patterns to avoid leaking credentials to child processes
114
- const filteredEnv: Record<string, string> = {};
115
- for (const [key, value] of Object.entries(env)) {
116
- if (value !== undefined && !SECRET_KEY_PATTERN.test(key)) filteredEnv[key] = value;
117
- }
115
+ const filteredEnv = sanitizeEnvSecrets(env);
118
116
  return {
119
117
  cwd,
120
118
  env: { ...filteredEnv, PI_CREW_PARENT_PID: String(process.pid) },
@@ -6,6 +6,8 @@
6
6
  */
7
7
  import { spawn } from "node:child_process";
8
8
  import * as fs from "node:fs";
9
+ import { resolveShellForScript } from "../utils/resolve-shell.ts";
10
+ import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
9
11
  import { DENIED_METRIC_NAMES } from "./metric-parser.ts";
10
12
 
11
13
  /** Hook execution stage. */
@@ -134,9 +136,10 @@ export async function runIterationHook(
134
136
  const stderrChunks: Buffer[] = [];
135
137
 
136
138
  return new Promise<HookResult>((resolve) => {
137
- const child = spawn("bash", [hookScriptPath], {
139
+ const { command, args } = resolveShellForScript(hookScriptPath);
140
+ const child = spawn(command, args, {
138
141
  cwd: payload.cwd,
139
- env: { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: process.env.HOME ?? "/tmp", USER: process.env.USER, LANG: process.env.LANG, PI_CREW_HOOK: "1" },
142
+ env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "ComSpec", "SystemRoot", "PI_*"] }), PI_CREW_HOOK: "1" },
140
143
  stdio: ["pipe", "pipe", "pipe"],
141
144
  });
142
145
 
@@ -308,6 +308,7 @@ export async function runLiveSessionTask(input: LiveSessionSpawnInput): Promise<
308
308
  }
309
309
  const availability = await isLiveSessionRuntimeAvailable();
310
310
  if (!availability.available) return { available: true, exitCode: 1, stdout: "", stderr: availability.reason ?? "Live-session runtime unavailable.", jsonEvents: 0, error: availability.reason };
311
+ // LAZY: optional peer dependency — only loaded when live-session runtime is chosen.
311
312
  const mod = await import("@mariozechner/pi-coding-agent") as LiveSessionModule;
312
313
  if (typeof mod.createAgentSession !== "function") return { available: true, exitCode: 1, stdout: "", stderr: "createAgentSession export is unavailable.", jsonEvents: 0, error: "createAgentSession export is unavailable." };
313
314
  let session: LiveSessionLike | undefined;
@@ -5,6 +5,8 @@
5
5
  * Distilled from pi-autoresearch's post-check / backpressure pattern.
6
6
  */
7
7
  import { execFileSync } from "node:child_process";
8
+ import { resolveShellForScript } from "../utils/resolve-shell.ts";
9
+ import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
8
10
 
9
11
  /** Default timeout for post-check scripts (5 minutes). */
10
12
  const DEFAULT_TIMEOUT_MS = 300_000;
@@ -79,12 +81,13 @@ export async function runPostCheck(config: PostCheckConfig, cwd: string): Promis
79
81
 
80
82
  return new Promise<PostCheckResult>((resolve) => {
81
83
  try {
82
- const output = execFileSync("bash", [scriptPath], {
84
+ const { command, args } = resolveShellForScript(scriptPath);
85
+ const output = execFileSync(command, args, {
83
86
  cwd,
84
87
  timeout: timeoutMs,
85
88
  encoding: "utf-8",
86
89
  maxBuffer: 10 * 1024 * 1024, // 10 MB
87
- env: { PATH: process.env.PATH ?? "/usr/bin:/bin", HOME: process.env.HOME ?? "/tmp", USER: process.env.USER, LANG: process.env.LANG, PI_CREW_POST_CHECK: "1" },
90
+ env: { ...sanitizeEnvSecrets(process.env, { allowList: ["PATH", "HOME", "USER", "USERPROFILE", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "ComSpec", "SystemRoot", "PI_*"] }), PI_CREW_POST_CHECK: "1" },
88
91
  });
89
92
 
90
93
  const durationMs = Date.now() - startTime;
@@ -37,6 +37,7 @@ export async function isLiveSessionRuntimeAvailable(timeoutMs = 1500, env: NodeJ
37
37
  }
38
38
  const probe = async (): Promise<{ available: boolean; reason?: string }> => {
39
39
  try {
40
+ // LAZY: optional peer dependency — probe at runtime to avoid hard dependency.
40
41
  const mod = await import("@mariozechner/pi-coding-agent");
41
42
  const api = mod as Record<string, unknown>;
42
43
  const required = ["createAgentSession", "DefaultResourceLoader", "SessionManager", "SettingsManager"];
@@ -58,7 +58,12 @@ interface QueuedSpawn {
58
58
  signal?: AbortSignal;
59
59
  }
60
60
 
61
+ function isValidSubagentId(id: string): boolean {
62
+ return /^[a-z0-9_]+$/i.test(id) && id.length <= 128;
63
+ }
64
+
61
65
  function persistedSubagentPath(cwd: string, id: string): string {
66
+ if (!isValidSubagentId(id)) throw new Error(`Invalid subagent id: ${id}`);
62
67
  return path.join(projectCrewRoot(cwd), DEFAULT_PATHS.state.subagentsSubdir, `${id}.json`);
63
68
  }
64
69
 
@@ -291,6 +291,7 @@ export async function runTeamTask(input: TaskRunnerInput): Promise<{ manifest: T
291
291
  tasks = updateTask(tasks, task);
292
292
  ({ task, tasks } = checkpointTask(manifest, tasks, task, "artifact-written"));
293
293
  } else if (runtimeKind === "live-session") {
294
+ // LAZY: live-executor is only needed for live-session runtime branches.
294
295
  const { runLiveTask } = await import("./task-runner/live-executor.ts");
295
296
  const live = await runLiveTask({ manifest, tasks, task, step: input.step, agent: input.agent, prompt, signal: input.signal, runtimeConfig: input.runtimeConfig, parentContext: input.parentContext, parentModel: input.parentModel, modelRegistry: input.modelRegistry, modelOverride: input.modelOverride, teamRoleModel: input.teamRoleModel });
296
297
  task = live.task;
@@ -6,6 +6,7 @@ import { subprocessToolRegistry, type SubprocessToolEvent } from "./subprocess-t
6
6
  let _ajv: Awaited<ReturnType<typeof getAjvInternal>> | null | undefined;
7
7
 
8
8
  async function getAjvInternal() {
9
+ // LAZY: AJV is an optional heavy validator — only load on first use.
9
10
  const mod = await import("ajv");
10
11
  const AjvCtor = ("default" in mod ? (mod as Record<string, unknown>).default : mod) as unknown as new (opts: Record<string, unknown>) => { compile: (schema: unknown) => { (data: unknown): boolean; errors?: unknown[] }; errorsText: (errors?: unknown[]) => string };
11
12
  return new AjvCtor({ allErrors: true, strict: false, logger: false });
@@ -24,6 +24,7 @@ export const TeamToolParams = Type.Object({
24
24
  Type.Literal("list"),
25
25
  Type.Literal("get"),
26
26
  Type.Literal("cancel"),
27
+ Type.Literal("retry"),
27
28
  Type.Literal("resume"),
28
29
  Type.Literal("respond"),
29
30
  Type.Literal("create"),
@@ -112,10 +112,10 @@ export function writeArtifact(artifactsRoot: string, options: ArtifactWriteOptio
112
112
  resolveRealContainedPath(path.dirname(artifactsRoot), path.basename(artifactsRoot));
113
113
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
114
114
  resolveRealContainedPath(artifactsRoot, path.dirname(filePath));
115
- // Compute hash on original content for integrity verification.
116
- const contentHash = hashContent(options.content);
117
115
  const content = redactSecretString(options.content);
118
116
  atomicWriteFile(filePath, content);
117
+ // Compute hash on written bytes for integrity verification.
118
+ const contentHash = hashContent(content);
119
119
  const stats = fs.statSync(filePath);
120
120
  return {
121
121
  kind: options.kind,
@@ -51,7 +51,7 @@ function isRetryableRenameError(error: unknown): boolean {
51
51
  return Boolean(error && typeof error === "object" && "code" in error && RETRYABLE_RENAME_CODES.has(String((error as NodeJS.ErrnoException).code)));
52
52
  }
53
53
 
54
- export function __test__renameWithRetry(tempPath: string, filePath: string, retries = 10, rename: (oldPath: string, newPath: string) => void = fs.renameSync): void {
54
+ export function renameWithRetry(tempPath: string, filePath: string, retries = 10, rename: (oldPath: string, newPath: string) => void = fs.renameSync): void {
55
55
  let lastError: unknown;
56
56
  for (let attempt = 0; attempt <= retries; attempt++) {
57
57
  try {
@@ -68,7 +68,10 @@ export function __test__renameWithRetry(tempPath: string, filePath: string, retr
68
68
  throw lastError;
69
69
  }
70
70
 
71
- export async function __test__renameWithRetryAsync(tempPath: string, filePath: string, retries = 10, rename: (oldPath: string, newPath: string) => Promise<void> = (source, destination) => fs.promises.rename(source, destination)): Promise<void> {
71
+ /** Test alias for renameWithRetry. */
72
+ export const __test__renameWithRetry = renameWithRetry;
73
+
74
+ export async function renameWithRetryAsync(tempPath: string, filePath: string, retries = 10, rename: (oldPath: string, newPath: string) => Promise<void> = (source, destination) => fs.promises.rename(source, destination)): Promise<void> {
72
75
  let lastError: unknown;
73
76
  for (let attempt = 0; attempt <= retries; attempt++) {
74
77
  try {
@@ -83,6 +86,9 @@ export async function __test__renameWithRetryAsync(tempPath: string, filePath: s
83
86
  throw lastError;
84
87
  }
85
88
 
89
+ /** Test alias for renameWithRetryAsync. */
90
+ export const __test__renameWithRetryAsync = renameWithRetryAsync;
91
+
86
92
  export function atomicWriteFile(filePath: string, content: string): void {
87
93
  if (!isSymlinkSafePath(filePath)) throw new Error(`Refusing to write: target is a symlink or inside untrusted directory: ${filePath}`);
88
94
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -99,8 +105,19 @@ export function atomicWriteFile(filePath: string, content: string): void {
99
105
  }
100
106
  fs.writeSync(fd, content, undefined, "utf-8");
101
107
  fs.closeSync(fd);
102
- __test__renameWithRetry(tempPath, filePath);
108
+ renameWithRetry(tempPath, filePath);
103
109
  } catch (error) {
110
+ let matches = false;
111
+ try {
112
+ const existing = fs.readFileSync(filePath, "utf-8");
113
+ matches = existing === content;
114
+ } catch {
115
+ /* ignore */
116
+ }
117
+ if (matches) {
118
+ try { fs.rmSync(tempPath, { force: true }); } catch { /* best-effort */ }
119
+ return;
120
+ }
104
121
  try {
105
122
  fs.rmSync(tempPath, { force: true });
106
123
  } catch (cleanupError) {
@@ -127,7 +144,7 @@ export async function atomicWriteFileAsync(filePath: string, content: string): P
127
144
  await fd.writeFile(content, "utf-8");
128
145
  await fd.close();
129
146
  try {
130
- await __test__renameWithRetryAsync(tempPath, filePath);
147
+ await renameWithRetryAsync(tempPath, filePath);
131
148
  } catch (renameError) {
132
149
  let matches = false;
133
150
  try {
@@ -7,6 +7,7 @@ import { emitFromTeamEvent } from "../ui/run-event-bus.ts";
7
7
  import { logInternalError } from "../utils/internal-error.ts";
8
8
  import { readJsonlSince, type IncrementalReadState } from "../utils/incremental-reader.ts";
9
9
  import { redactSecrets } from "../utils/redaction.ts";
10
+ import { sleepSync } from "../utils/sleep.ts";
10
11
  import { needsRotation, compactEventLog } from "./event-log-rotation.ts";
11
12
 
12
13
  export type TeamEventProvenance = "live_worker" | "test" | "healthcheck" | "replay" | "api" | "background" | "team_runner";
@@ -57,8 +58,65 @@ const TERMINAL_EVENT_TYPES = new Set<string>(DEFAULT_EVENT_LOG.terminalEventType
57
58
  const MAX_EVENTS_BYTES = 50 * 1024 * 1024;
58
59
 
59
60
  const sequenceCache = new Map<string, { size: number; mtimeMs: number; seq: number }>();
61
+ const MAX_SEQUENCE_CACHE_ENTRIES = 256;
60
62
  let appendCounter = 0;
61
63
 
64
+ /** Simple cross-process lock for an eventsPath to prevent JSONL interleave on concurrent append.
65
+ * Detects stale locks by checking the owner PID written inside the lock directory.
66
+ */
67
+ function withEventLogLockSync<T>(eventsPath: string, fn: () => T): T {
68
+ const lockDir = `${eventsPath}.lock`;
69
+ const pidFile = path.join(lockDir, "pid");
70
+ const start = Date.now();
71
+ const timeout = 5000;
72
+ const staleMs = 10000;
73
+ let acquired = false;
74
+ while (true) {
75
+ try {
76
+ fs.mkdirSync(lockDir);
77
+ try { fs.writeFileSync(pidFile, String(process.pid), "utf-8"); } catch { /* best-effort */ }
78
+ acquired = true;
79
+ break;
80
+ } catch {
81
+ if (Date.now() - start > timeout) {
82
+ logInternalError("event-log.lock-timeout", new Error(`Event log lock timeout for ${eventsPath}`), `lockDir=${lockDir}`);
83
+ break;
84
+ }
85
+ // Stale detection: if the owning process is dead, remove the stale lock.
86
+ try {
87
+ const raw = fs.readFileSync(pidFile, "utf-8").trim();
88
+ const ownerPid = Number.parseInt(raw, 10);
89
+ if (!Number.isNaN(ownerPid) && ownerPid !== process.pid) {
90
+ let alive = false;
91
+ try { process.kill(ownerPid, 0); alive = true; } catch { /* dead */ }
92
+ if (!alive) {
93
+ try {
94
+ const stat = fs.statSync(lockDir);
95
+ if (Date.now() - stat.mtimeMs > staleMs) {
96
+ fs.rmSync(lockDir, { recursive: true, force: true });
97
+ continue;
98
+ }
99
+ } catch { /* race — let loop sleep */ }
100
+ }
101
+ }
102
+ } catch { /* no pid file — fall through to sleep */ }
103
+ sleepSync(10);
104
+ }
105
+ }
106
+ try {
107
+ return fn();
108
+ } finally {
109
+ if (acquired) {
110
+ try { fs.rmSync(lockDir, { recursive: true, force: true }); } catch { /* best-effort */ }
111
+ }
112
+ }
113
+ }
114
+
115
+ function evictOldestSequenceCacheEntry(): void {
116
+ const first = sequenceCache.keys().next().value;
117
+ if (first !== undefined) sequenceCache.delete(first);
118
+ }
119
+
62
120
  export function sequencePath(eventsPath: string): string {
63
121
  return `${eventsPath}.seq`;
64
122
  }
@@ -117,54 +175,59 @@ export function computeEventFingerprint(event: Pick<TeamEvent, "type" | "runId"
117
175
  }
118
176
 
119
177
  export function appendEvent(eventsPath: string, event: AppendTeamEvent): TeamEvent {
120
- fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
121
- const baseMetadata = event.metadata;
122
- let metadata: TeamEventMetadata = {
123
- seq: baseMetadata?.seq ?? nextSequence(eventsPath),
124
- provenance: baseMetadata?.provenance ?? "team_runner",
125
- ...(baseMetadata?.parentEventId ? { parentEventId: baseMetadata.parentEventId } : {}),
126
- ...(baseMetadata?.attemptId ? { attemptId: baseMetadata.attemptId } : {}),
127
- ...(baseMetadata?.branchId ? { branchId: baseMetadata.branchId } : {}),
128
- ...(baseMetadata?.causationId ? { causationId: baseMetadata.causationId } : {}),
129
- ...(baseMetadata?.correlationId ? { correlationId: baseMetadata.correlationId } : {}),
130
- ...(baseMetadata?.sessionIdentity ? { sessionIdentity: baseMetadata.sessionIdentity } : {}),
131
- ...(baseMetadata?.ownership ? { ownership: baseMetadata.ownership } : {}),
132
- ...(baseMetadata?.nudgeId ? { nudgeId: baseMetadata.nudgeId } : {}),
133
- ...(baseMetadata?.confidence ? { confidence: baseMetadata.confidence } : {}),
134
- };
135
- const fullEvent: TeamEvent = {
136
- time: new Date().toISOString(),
137
- ...event,
138
- metadata,
139
- };
140
- if (baseMetadata?.fingerprint || TERMINAL_EVENT_TYPES.has(fullEvent.type)) {
141
- metadata = { ...metadata, fingerprint: baseMetadata?.fingerprint ?? computeEventFingerprint(fullEvent) };
142
- fullEvent.metadata = metadata;
143
- }
144
- try {
145
- if (fs.existsSync(eventsPath) && fs.statSync(eventsPath).size > MAX_EVENTS_BYTES) {
146
- logInternalError("event-log.size-limit", new Error(`events file ${eventsPath} exceeds ${MAX_EVENTS_BYTES} bytes`), `eventsPath=${eventsPath}`);
147
- return { ...fullEvent, metadata: { ...(fullEvent.metadata ?? { seq: 0, provenance: "team_runner" }), appended: false } };
178
+ return withEventLogLockSync(eventsPath, () => {
179
+ fs.mkdirSync(path.dirname(eventsPath), { recursive: true });
180
+ const baseMetadata = event.metadata;
181
+ let metadata: TeamEventMetadata = {
182
+ seq: baseMetadata?.seq ?? nextSequence(eventsPath),
183
+ provenance: baseMetadata?.provenance ?? "team_runner",
184
+ ...(baseMetadata?.parentEventId ? { parentEventId: baseMetadata.parentEventId } : {}),
185
+ ...(baseMetadata?.attemptId ? { attemptId: baseMetadata.attemptId } : {}),
186
+ ...(baseMetadata?.branchId ? { branchId: baseMetadata.branchId } : {}),
187
+ ...(baseMetadata?.causationId ? { causationId: baseMetadata.causationId } : {}),
188
+ ...(baseMetadata?.correlationId ? { correlationId: baseMetadata.correlationId } : {}),
189
+ ...(baseMetadata?.sessionIdentity ? { sessionIdentity: baseMetadata.sessionIdentity } : {}),
190
+ ...(baseMetadata?.ownership ? { ownership: baseMetadata.ownership } : {}),
191
+ ...(baseMetadata?.nudgeId ? { nudgeId: baseMetadata.nudgeId } : {}),
192
+ ...(baseMetadata?.confidence ? { confidence: baseMetadata.confidence } : {}),
193
+ };
194
+ const fullEvent: TeamEvent = {
195
+ time: new Date().toISOString(),
196
+ ...event,
197
+ metadata,
198
+ };
199
+ if (baseMetadata?.fingerprint || TERMINAL_EVENT_TYPES.has(fullEvent.type)) {
200
+ metadata = { ...metadata, fingerprint: baseMetadata?.fingerprint ?? computeEventFingerprint(fullEvent) };
201
+ fullEvent.metadata = metadata;
148
202
  }
149
- } catch (error) {
150
- logInternalError("event-log.size-check", error, `eventsPath=${eventsPath}`);
151
- }
152
- fs.appendFileSync(eventsPath, `${JSON.stringify(redactSecrets(fullEvent))}\n`, "utf-8");
153
- appendCounter++;
154
- if (appendCounter % 100 === 0 && needsRotation(eventsPath)) {
155
- try { compactEventLog(eventsPath); } catch (error) { logInternalError("event-log.rotation", error, `eventsPath=${eventsPath}`); }
156
- }
157
- // Emit to UI event bus for event-first delivery
158
- try { emitFromTeamEvent(fullEvent); } catch (error) { logInternalError("event-log.emit", error); }
159
- const seq = fullEvent.metadata?.seq ?? 0;
160
- try {
161
- const stat = fs.statSync(eventsPath);
162
- sequenceCache.set(eventsPath, { size: stat.size, mtimeMs: stat.mtimeMs, seq });
163
- persistSequence(eventsPath, seq);
164
- } catch (error) {
165
- logInternalError("event-log.persist-sequence", error, `eventsPath=${eventsPath}`);
166
- }
167
- return fullEvent;
203
+ try {
204
+ if (fs.existsSync(eventsPath) && fs.statSync(eventsPath).size > MAX_EVENTS_BYTES) {
205
+ logInternalError("event-log.size-limit", new Error(`events file ${eventsPath} exceeds ${MAX_EVENTS_BYTES} bytes`), `eventsPath=${eventsPath}`);
206
+ return { ...fullEvent, metadata: { ...(fullEvent.metadata ?? { seq: 0, provenance: "team_runner" }), appended: false } };
207
+ }
208
+ } catch (error) {
209
+ logInternalError("event-log.size-check", error, `eventsPath=${eventsPath}`);
210
+ }
211
+ fs.appendFileSync(eventsPath, `${JSON.stringify(redactSecrets(fullEvent))}\n`, "utf-8");
212
+ appendCounter++;
213
+ if (appendCounter % 100 === 0 && needsRotation(eventsPath)) {
214
+ try { compactEventLog(eventsPath); } catch (error) { logInternalError("event-log.rotation", error, `eventsPath=${eventsPath}`); }
215
+ }
216
+ // Emit to UI event bus for event-first delivery
217
+ try { emitFromTeamEvent(fullEvent); } catch (error) { logInternalError("event-log.emit", error); }
218
+ const seq = fullEvent.metadata?.seq ?? 0;
219
+ try {
220
+ const stat = fs.statSync(eventsPath);
221
+ if (sequenceCache.size >= MAX_SEQUENCE_CACHE_ENTRIES) {
222
+ evictOldestSequenceCacheEntry();
223
+ }
224
+ sequenceCache.set(eventsPath, { size: stat.size, mtimeMs: stat.mtimeMs, seq });
225
+ persistSequence(eventsPath, seq);
226
+ } catch (error) {
227
+ logInternalError("event-log.persist-sequence", error, `eventsPath=${eventsPath}`);
228
+ }
229
+ return fullEvent;
230
+ });
168
231
  }
169
232
 
170
233
  export function readEvents(eventsPath: string): TeamEvent[] {
@@ -40,16 +40,6 @@ function isLockStale(filePath: string, staleMs: number): boolean {
40
40
  }
41
41
  }
42
42
 
43
- function readLockState(filePath: string, staleMs: number): boolean {
44
- if (!isLockStale(filePath, staleMs)) return false;
45
- try {
46
- fs.rmSync(filePath, { force: true });
47
- return true;
48
- } catch {
49
- return false;
50
- }
51
- }
52
-
53
43
  function writeLockFile(filePath: string): void {
54
44
  const fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o644);
55
45
  try {
@@ -69,14 +59,18 @@ function acquireLockWithRetry(filePath: string, staleMs: number): void {
69
59
  } catch (error) {
70
60
  const code = (error as NodeJS.ErrnoException).code;
71
61
  if (code !== "EEXIST") throw error;
72
- if (!readLockState(filePath, staleMs)) {
62
+ if (Date.now() > deadline) {
73
63
  throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
74
64
  }
75
- if (Date.now() > deadline) {
65
+ // If lock is not stale, fail fast (sync should not wait for active locks)
66
+ if (!isLockStale(filePath, staleMs)) {
76
67
  throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
77
68
  }
78
- const delay = Math.min(250, 25 * 2 ** attempt);
79
- sleepSync(delay);
69
+ // Lock is stale — try to clear it, but don't bail on rmSync error — let loop retry
70
+ try {
71
+ fs.rmSync(filePath, { force: true });
72
+ } catch { /* race — let loop retry */ }
73
+ sleepSync(Math.min(250, 25 * 2 ** attempt));
80
74
  attempt++;
81
75
  }
82
76
  }
@@ -107,6 +101,10 @@ async function acquireLockWithRetryAsync(filePath: string, staleMs: number): Pro
107
101
  if (Date.now() > deadline) {
108
102
  throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
109
103
  }
104
+ // If lock is not stale, fail fast (async should not wait for active locks)
105
+ if (!isLockStale(filePath, staleMs)) {
106
+ throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
107
+ }
110
108
  readLockStateAsync(filePath, staleMs);
111
109
  const delay = Math.min(250, 25 * 2 ** attempt);
112
110
  await sleep(delay);
@@ -5,6 +5,7 @@ import type { handleTeamTool as HandleTeamToolFn } from "../extension/team-tool.
5
5
  let _cachedHandleTeamTool: typeof HandleTeamToolFn | undefined;
6
6
  async function handleTeamTool(params: Parameters<typeof HandleTeamToolFn>[0], ctx: Parameters<typeof HandleTeamToolFn>[1]): Promise<Awaited<ReturnType<typeof HandleTeamToolFn>>> {
7
7
  if (!_cachedHandleTeamTool) {
8
+ // LAZY: avoid pulling team-tool.ts (and its entire runtime chain) into module load.
8
9
  const mod = await import("../extension/team-tool.ts");
9
10
  _cachedHandleTeamTool = mod.handleTeamTool;
10
11
  }
@@ -0,0 +1,30 @@
1
+ import { SECRET_KEY_PATTERN } from "./redaction.ts";
2
+
3
+ export interface SanitizeEnvOptions {
4
+ /** Allow-list of env var names to preserve. Supports trailing glob, e.g. `"PI_*"`. */
5
+ allowList?: string[];
6
+ }
7
+
8
+ /**
9
+ * Strip env vars whose keys look like secrets before passing to child processes.
10
+ *
11
+ * Default mode (no allowList): deny-list using SECRET_KEY_PATTERN.
12
+ * When allowList is provided, only keys matching the allow-list are preserved.
13
+ */
14
+ export function sanitizeEnvSecrets(env: NodeJS.ProcessEnv, options?: SanitizeEnvOptions): Record<string, string> {
15
+ const filtered: Record<string, string> = {};
16
+ if (options?.allowList && options.allowList.length > 0) {
17
+ const matchers = options.allowList.map((p) => {
18
+ if (p.endsWith("*")) return (k: string) => k.startsWith(p.slice(0, -1));
19
+ return (k: string) => k === p;
20
+ });
21
+ for (const [key, value] of Object.entries(env)) {
22
+ if (value !== undefined && matchers.some((fn) => fn(key))) filtered[key] = value;
23
+ }
24
+ return filtered;
25
+ }
26
+ for (const [key, value] of Object.entries(env)) {
27
+ if (value !== undefined && !SECRET_KEY_PATTERN.test(key)) filtered[key] = value;
28
+ }
29
+ return filtered;
30
+ }
@@ -2,7 +2,7 @@ export const SECRET_KEY_PATTERN = /(?:^|[_.-])(token|api[-_]?key|password|passwd
2
2
  const INLINE_SECRET_PATTERN = /(^|[\s,{])(([A-Za-z0-9_.-]*(?:api[-_]?key|token|password|passwd|secret|credential|authorization|private[-_]?key)[A-Za-z0-9_.-]*)\s*[=:]\s*)([^\s,;"'}]+)/gi;
3
3
  const AUTH_HEADER_PATTERN = /\b(Authorization\s*:\s*(?:Bearer|Basic|Token)?\s*)([^\r\n]+)/gi;
4
4
  const BEARER_PATTERN = /\b(Bearer\s+)([A-Za-z0-9._~+/=-]{8,})\b/g;
5
- const PEM_PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g;
5
+ const PEM_PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]{0,65536}?-----END [A-Z ]*PRIVATE KEY-----/g;
6
6
 
7
7
  function isRecord(value: unknown): value is Record<string, unknown> {
8
8
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;