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.
- 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
|
@@ -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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
|
54
|
+
// Fall through.
|
|
31
55
|
}
|
|
32
|
-
return
|
|
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
|
|
36
|
-
|
|
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: ["--
|
|
39
|
-
loader: "
|
|
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
|
|
65
|
-
if (!
|
|
66
|
-
const message =
|
|
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,
|
|
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
|
}
|
package/src/runtime/child-pi.ts
CHANGED
|
@@ -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
|
|
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
|
|
139
|
+
const { command, args } = resolveShellForScript(hookScriptPath);
|
|
140
|
+
const child = spawn(command, args, {
|
|
138
141
|
cwd: payload.cwd,
|
|
139
|
-
env: {
|
|
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
|
|
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: {
|
|
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 });
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
147
|
+
await renameWithRetryAsync(tempPath, filePath);
|
|
131
148
|
} catch (renameError) {
|
|
132
149
|
let matches = false;
|
|
133
150
|
try {
|
package/src/state/event-log.ts
CHANGED
|
@@ -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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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[] {
|
package/src/state/locks.ts
CHANGED
|
@@ -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 (
|
|
62
|
+
if (Date.now() > deadline) {
|
|
73
63
|
throw new Error(`Run '${path.basename(filePath)}' is locked by another operation.`);
|
|
74
64
|
}
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
+
}
|
package/src/utils/redaction.ts
CHANGED
|
@@ -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]
|
|
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;
|