From 15b184d676ea485065efeec5e37c2765a6d5a84c Mon Sep 17 00:00:00 2001 From: Jayce Freeman Date: Thu, 28 May 2026 21:40:23 -0600 Subject: [PATCH 1/2] feat(run-history): RunEntry v2 (model/cwd/tool_calls/error_excerpt) + lock-free rotation; extend BuiltinAgentOverrideConfig (output/defaultReads/defaultProgress) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related changes on the same agents.ts / run-history.ts surface. ## RunEntry v2 + lock-free rotation (run-history.ts + 4 call sites) RunEntry gains optional model / cwd / tool_calls / error_excerpt so the history is useful for telemetry, not just agent/status/duration. Sourced at each of the 4 recordRun call sites from the values actually used to run the child: - model <- SingleResult.model - cwd <- the child's executed cwd (taskCwd / stepCwd / resolveParallelTaskCwd / effectiveCwd), never the parent ctx.cwd - tool_calls <- progressSummary.toolCount (preserves 0; not dropped by truthiness) - error_excerpt<- SingleResult.error, truncated to 300 chars, only on error Rotation is now lock-free. The previous read-path trim raced under concurrent processes. New design: recordRun appends one JSON line via O_APPEND (kernel-atomic across processes on a local POSIX fs; entries are small, single write()), then best-effort maybeRotate trims to ROTATE_KEEP by writing a per-process-unique temp and atomic-renaming it into place. The file is a complete valid snapshot either way — no duplication, no corruption. The only loss this admits is an append landing inside a rotate's read->rename window; acceptable for telemetry. No new dependency, no lock file. A 16-process x 150-write storm test guards no-corruption, no-double-write, and bounded size. ## BuiltinAgentOverrideConfig: output / defaultReads / defaultProgress These three fields were honored on AgentConfig but silently dropped by the override schema, parser, applier, AND the write path — so a settings.json override that set them was a no-op (a lying contract). Threaded through all of: the schema (BuiltinAgentOverrideBase + Config), parseBuiltinOverrideEntry (with typed validation errors), applyBuiltinOverride (false sentinel clears the builtin default), and the save path (cloneOverrideBase / cloneOverrideValue / buildBuiltinOverrideConfig) so values round-trip through save+reload instead of being dropped on persist. Tests: +6 run-history unit tests (incl. multi-process storm) and +7 agent-overrides tests (apply, false-sentinel clear, malformed-value rejection, builder round-trip, save+reload persistence). Full suite: 477/477. --- src/agents/agents.ts | 33 +++++- src/runs/foreground/chain-execution.ts | 17 ++- src/runs/foreground/subagent-executor.ts | 14 ++- src/runs/shared/run-history.ts | 56 ++++++++-- test/unit/agent-overrides.test.ts | 110 +++++++++++++++++- test/unit/run-history.test.ts | 135 +++++++++++++++++++++++ 6 files changed, 348 insertions(+), 17 deletions(-) create mode 100644 test/unit/run-history.test.ts diff --git a/src/agents/agents.ts b/src/agents/agents.ts index fa8ee8c0..7802ed3c 100644 --- a/src/agents/agents.ts +++ b/src/agents/agents.ts @@ -45,6 +45,9 @@ export interface BuiltinAgentOverrideBase { systemPrompt: string; skills?: string[]; tools?: string[]; + output?: string; + defaultReads?: string[]; + defaultProgress?: boolean; mcpDirectTools?: string[]; completionGuard?: boolean; } @@ -61,6 +64,9 @@ interface BuiltinAgentOverrideConfig { systemPrompt?: string; skills?: string[] | false; tools?: string[] | false; + output?: string | false; + defaultReads?: string[] | false; + defaultProgress?: boolean; completionGuard?: boolean; } @@ -185,6 +191,9 @@ function cloneOverrideBase(agent: AgentConfig): BuiltinAgentOverrideBase { systemPrompt: agent.systemPrompt, skills: agent.skills ? [...agent.skills] : undefined, tools: agent.tools ? [...agent.tools] : undefined, + output: agent.output, + defaultReads: agent.defaultReads ? [...agent.defaultReads] : undefined, + defaultProgress: agent.defaultProgress, mcpDirectTools: agent.mcpDirectTools ? [...agent.mcpDirectTools] : undefined, completionGuard: agent.completionGuard, }; @@ -205,6 +214,9 @@ function cloneOverrideValue(override: BuiltinAgentOverrideConfig): BuiltinAgentO ...(override.systemPrompt !== undefined ? { systemPrompt: override.systemPrompt } : {}), ...(override.skills !== undefined ? { skills: override.skills === false ? false : [...override.skills] } : {}), ...(override.tools !== undefined ? { tools: override.tools === false ? false : [...override.tools] } : {}), + ...(override.output !== undefined ? { output: override.output } : {}), + ...(override.defaultReads !== undefined ? { defaultReads: override.defaultReads === false ? false : [...override.defaultReads] } : {}), + ...(override.defaultProgress !== undefined ? { defaultProgress: override.defaultProgress } : {}), ...(override.completionGuard !== undefined ? { completionGuard: override.completionGuard } : {}), }; } @@ -364,6 +376,19 @@ function parseBuiltinOverrideEntry( const tools = parseOverrideStringArrayOrFalse(input.tools, { filePath, name, field: "tools" }); if (tools !== undefined) override.tools = tools; + if ("output" in input) { + if (typeof input.output === "string" || input.output === false) override.output = input.output; + else throw new Error(`Builtin override '${name}' in '${filePath}' has invalid 'output'; expected a string or false.`); + } + + const defaultReads = parseOverrideStringArrayOrFalse(input.defaultReads, { filePath, name, field: "defaultReads" }); + if (defaultReads !== undefined) override.defaultReads = defaultReads; + + if ("defaultProgress" in input) { + if (typeof input.defaultProgress === "boolean") override.defaultProgress = input.defaultProgress; + else throw new Error(`Builtin override '${name}' in '${filePath}' has invalid 'defaultProgress'; expected a boolean.`); + } + return Object.keys(override).length > 0 ? override : undefined; } @@ -422,6 +447,9 @@ function applyBuiltinOverride( next.tools = tools; next.mcpDirectTools = mcpDirectTools; } + if (override.output !== undefined) next.output = override.output === false ? undefined : override.output; + if (override.defaultReads !== undefined) next.defaultReads = override.defaultReads === false ? undefined : [...override.defaultReads]; + if (override.defaultProgress !== undefined) next.defaultProgress = override.defaultProgress; if (override.completionGuard !== undefined) next.completionGuard = override.completionGuard; return next; @@ -462,7 +490,7 @@ function applyBuiltinOverrides( export function buildBuiltinOverrideConfig( base: BuiltinAgentOverrideBase, - draft: Pick, + draft: Pick, ): BuiltinAgentOverrideConfig | undefined { const override: BuiltinAgentOverrideConfig = {}; @@ -480,6 +508,9 @@ export function buildBuiltinOverrideConfig( const baseTools = joinToolList(base); const draftTools = joinToolList(draft); if (!arraysEqual(draftTools, baseTools)) override.tools = draftTools ? [...draftTools] : false; + if (draft.output !== base.output) override.output = draft.output ?? false; + if (!arraysEqual(draft.defaultReads, base.defaultReads)) override.defaultReads = draft.defaultReads ? [...draft.defaultReads] : false; + if ((draft.defaultProgress ?? false) !== (base.defaultProgress ?? false)) override.defaultProgress = draft.defaultProgress ?? false; if ((draft.completionGuard !== false) !== (base.completionGuard !== false)) { override.completionGuard = draft.completionGuard !== false; } diff --git a/src/runs/foreground/chain-execution.ts b/src/runs/foreground/chain-execution.ts index b5ebb872..d878d1b5 100644 --- a/src/runs/foreground/chain-execution.ts +++ b/src/runs/foreground/chain-execution.ts @@ -292,7 +292,12 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promiserename window is dropped (acceptable for telemetry); trimming also +// intentionally discards the oldest entries. +function maybeRotate(historyPath: string): void { + const lines = fs.readFileSync(historyPath, "utf-8").split("\n").filter((l) => l.trim().length > 0); + if (lines.length <= ROTATE_READ_THRESHOLD) return; + const tmp = `${historyPath}.${process.pid}.${Date.now()}.tmp`; + fs.writeFileSync(tmp, `${lines.slice(-ROTATE_KEEP).join("\n")}\n`); + fs.renameSync(tmp, historyPath); +} + +// Appends one JSON line via O_APPEND: on a local POSIX filesystem the kernel serializes +// concurrent appends from separate processes, so they never interleave or tear (telemetry +// entries are small — comfortably within a single atomic write()). Synchronous and +// best-effort: it never waits on a lock, and it swallows I/O errors rather than disrupting +// the agent flow (a swallowed error simply drops that one entry). +export function recordRun( + agent: string, + task: string, + exitCode: number, + durationMs: number, + extra?: { model?: string; cwd?: string; tool_calls?: number; error_excerpt?: string }, +): void { try { const entry: RunEntry = { agent, @@ -27,10 +56,15 @@ export function recordRun(agent: string, task: string, exitCode: number, duratio status: exitCode === 0 ? "ok" : "error", duration: durationMs, ...(exitCode !== 0 ? { exit: exitCode } : {}), + ...(extra?.model ? { model: extra.model } : {}), + ...(extra?.cwd ? { cwd: extra.cwd } : {}), + ...(extra?.tool_calls !== undefined ? { tool_calls: extra.tool_calls } : {}), + ...(extra?.error_excerpt ? { error_excerpt: extra.error_excerpt.slice(0, 300) } : {}), }; const historyPath = getHistoryPath(); fs.mkdirSync(path.dirname(historyPath), { recursive: true }); fs.appendFileSync(historyPath, `${JSON.stringify(entry)}\n`); + maybeRotate(historyPath); } catch { // Best-effort — never crash the execution flow for history recording } @@ -46,15 +80,17 @@ export function loadRunsForAgent(agent: string): RunEntry[] { return []; } - let lines = raw.split("\n").map((line) => line.trim()).filter((line) => line.length > 0); - - if (lines.length > ROTATE_READ_THRESHOLD) { - lines = lines.slice(-ROTATE_KEEP); - try { fs.writeFileSync(historyPath, `${lines.join("\n")}\n`, "utf-8"); } catch {} - } - - return lines - .map((line) => { try { return JSON.parse(line) as RunEntry; } catch { return undefined; } }) + return raw + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + try { + return JSON.parse(line) as RunEntry; + } catch { + return undefined; + } + }) .filter((entry): entry is RunEntry => Boolean(entry) && entry.agent === agent) .reverse(); } diff --git a/test/unit/agent-overrides.test.ts b/test/unit/agent-overrides.test.ts index 2617e4f0..58c88601 100644 --- a/test/unit/agent-overrides.test.ts +++ b/test/unit/agent-overrides.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, it } from "node:test"; -import { buildBuiltinOverrideConfig, discoverAgents, discoverAgentsAll, removeBuiltinAgentOverride } from "../../src/agents/agents.ts"; +import { buildBuiltinOverrideConfig, discoverAgents, discoverAgentsAll, removeBuiltinAgentOverride, saveBuiltinAgentOverride } from "../../src/agents/agents.ts"; let tempHome = ""; let tempProject = ""; @@ -268,4 +268,112 @@ describe("builtin agent overrides", () => { completionGuard: true, }); }); + + it("applies output / defaultReads / defaultProgress overrides to builtin agents", () => { + writeJson(path.join(tempHome, ".pi", "agent", "settings.json"), { + subagents: { + agentOverrides: { + scout: { output: "custom.md", defaultReads: ["a.md", "b.md"], defaultProgress: false }, + }, + }, + }); + + const scout = discoverAgents(tempProject, "both").agents.find((agent) => agent.name === "scout"); + assert.ok(scout); + assert.equal(scout.output, "custom.md"); + assert.deepEqual(scout.defaultReads, ["a.md", "b.md"]); + assert.equal(scout.defaultProgress, false); + }); + + it("clears a builtin's output / defaultReads via the false sentinel", () => { + writeJson(path.join(tempHome, ".pi", "agent", "settings.json"), { + subagents: { + agentOverrides: { + scout: { output: false }, + planner: { defaultReads: false }, + }, + }, + }); + + const agents = discoverAgents(tempProject, "both").agents; + const scout = agents.find((agent) => agent.name === "scout"); + const planner = agents.find((agent) => agent.name === "planner"); + assert.ok(scout); + assert.ok(planner); + // scout ships `output: context.md`; planner ships `defaultReads: context.md`. + assert.equal(scout.output, undefined); + assert.equal(planner.defaultReads, undefined); + }); + + it("surfaces malformed output override values", () => { + const settingsPath = path.join(tempHome, ".pi", "agent", "settings.json"); + writeJson(settingsPath, { subagents: { agentOverrides: { scout: { output: 123 } } } }); + assert.throws( + () => discoverAgents(tempProject, "both"), + (error: unknown) => error instanceof Error && error.message.includes("scout") && error.message.includes("output"), + ); + }); + + it("surfaces malformed defaultReads override values", () => { + const settingsPath = path.join(tempHome, ".pi", "agent", "settings.json"); + writeJson(settingsPath, { subagents: { agentOverrides: { scout: { defaultReads: "context.md" } } } }); + assert.throws( + () => discoverAgents(tempProject, "both"), + (error: unknown) => error instanceof Error && error.message.includes("scout") && error.message.includes("defaultReads"), + ); + }); + + it("surfaces malformed defaultProgress override values", () => { + const settingsPath = path.join(tempHome, ".pi", "agent", "settings.json"); + writeJson(settingsPath, { subagents: { agentOverrides: { scout: { defaultProgress: "true" } } } }); + assert.throws( + () => discoverAgents(tempProject, "both"), + (error: unknown) => error instanceof Error && error.message.includes("scout") && error.message.includes("defaultProgress"), + ); + }); + + it("round-trips output / defaultReads / defaultProgress through the override builder", () => { + const base = { + model: undefined, + fallbackModels: undefined, + thinking: undefined, + systemPromptMode: "append" as const, + inheritProjectContext: false, + inheritSkills: false, + defaultContext: undefined, + disabled: undefined, + systemPrompt: "P", + skills: undefined, + tools: undefined, + mcpDirectTools: undefined, + output: "context.md", + defaultReads: ["context.md"], + defaultProgress: true, + completionGuard: undefined, + }; + + // Clearing the builtin defaults emits the false sentinels. + const cleared = buildBuiltinOverrideConfig(base, { ...base, output: undefined, defaultReads: undefined, defaultProgress: false }); + assert.deepEqual(cleared, { output: false, defaultReads: false, defaultProgress: false }); + + // Changing to new values emits those; an unchanged field (defaultProgress) is omitted. + const changed = buildBuiltinOverrideConfig(base, { ...base, output: "custom.md", defaultReads: ["a.md"] }); + assert.deepEqual(changed, { output: "custom.md", defaultReads: ["a.md"] }); + }); + + it("persists output / defaultReads / defaultProgress through save and reload", () => { + // Crosses the save seam (cloneOverrideValue) that previously dropped these fields. + saveBuiltinAgentOverride(tempProject, "scout", "user", { output: "custom.md", defaultReads: ["a.md"], defaultProgress: false }); + saveBuiltinAgentOverride(tempProject, "planner", "user", { defaultReads: false }); + + const agents = discoverAgents(tempProject, "both").agents; + const scout = agents.find((agent) => agent.name === "scout"); + const planner = agents.find((agent) => agent.name === "planner"); + assert.ok(scout); + assert.ok(planner); + assert.equal(scout.output, "custom.md"); + assert.deepEqual(scout.defaultReads, ["a.md"]); + assert.equal(scout.defaultProgress, false); + assert.equal(planner.defaultReads, undefined); // false sentinel survives save+reload + }); }); diff --git a/test/unit/run-history.test.ts b/test/unit/run-history.test.ts new file mode 100644 index 00000000..ffcf199f --- /dev/null +++ b/test/unit/run-history.test.ts @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { loadRunsForAgent, recordRun, type RunEntry } from "../../src/runs/shared/run-history.ts"; + +const SRC = fileURLToPath(new URL("../../src/runs/shared/run-history.ts", import.meta.url)); + +function freshAgentDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "runhist-")); + process.env.PI_CODING_AGENT_DIR = dir; + return dir; +} + +function historyLines(dir: string): RunEntry[] { + const raw = fs.readFileSync(path.join(dir, "run-history.jsonl"), "utf-8"); + return raw + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0) + .map((l) => JSON.parse(l) as RunEntry); +} + +describe("recordRun v2 fields", () => { + it("writes the new fields when provided", () => { + const dir = freshAgentDir(); + recordRun("a", "task", 0, 12, { model: "openai-codex/gpt-5.3-codex", cwd: "/tmp/x", tool_calls: 7 }); + const [entry] = historyLines(dir); + assert.equal(entry.model, "openai-codex/gpt-5.3-codex"); + assert.equal(entry.cwd, "/tmp/x"); + assert.equal(entry.tool_calls, 7); + assert.equal(entry.status, "ok"); + assert.equal(entry.duration, 12); + }); + + it("omits new fields when not provided", () => { + const dir = freshAgentDir(); + recordRun("a", "task", 0, 12); + const [entry] = historyLines(dir); + assert.equal("model" in entry, false); + assert.equal("cwd" in entry, false); + assert.equal("tool_calls" in entry, false); + assert.equal("error_excerpt" in entry, false); + }); + + it("preserves tool_calls: 0 (not dropped by truthiness)", () => { + const dir = freshAgentDir(); + recordRun("a", "task", 0, 1, { tool_calls: 0 }); + const [entry] = historyLines(dir); + assert.equal("tool_calls" in entry, true); + assert.equal(entry.tool_calls, 0); + }); + + it("records error_excerpt on failure, truncated to 300 chars", () => { + const dir = freshAgentDir(); + const long = "x".repeat(500); + recordRun("a", "task", 1, 1, { error_excerpt: long }); + const [entry] = historyLines(dir); + assert.equal(entry.status, "error"); + assert.equal(entry.exit, 1); + assert.equal(entry.error_excerpt?.length, 300); + }); +}); + +describe("loadRunsForAgent forward-compat", () => { + it("parses v2-shaped entries (incl. unknown fields) without throwing, filtered by agent", () => { + const dir = freshAgentDir(); + const histPath = path.join(dir, "run-history.jsonl"); + const v2 = { agent: "z", task: "t", ts: 1, status: "ok", duration: 1, model: "m", cwd: "/c", tool_calls: 3, error_excerpt: "e", futureField: { nested: true } }; + const other = { agent: "other", task: "t2", ts: 2, status: "ok", duration: 1 }; + fs.writeFileSync(histPath, `${JSON.stringify(v2)}\n${JSON.stringify(other)}\n`); + const runs = loadRunsForAgent("z"); + assert.equal(runs.length, 1); + assert.equal(runs[0].agent, "z"); + assert.equal(runs[0].model, "m"); + assert.equal(runs[0].tool_calls, 3); + }); +}); + +describe("rotation on the write path", () => { + it("trims to ROTATE_KEEP and keeps the newest entry", () => { + const dir = freshAgentDir(); + const histPath = path.join(dir, "run-history.jsonl"); + const seed = Array.from({ length: 1200 }, (_, i) => JSON.stringify({ agent: "a", task: `seed${i}`, ts: i, status: "ok", duration: 1 })).join("\n"); + fs.writeFileSync(histPath, `${seed}\n`); + recordRun("a", "newest", 0, 1, { tool_calls: 1 }); + const lines = historyLines(dir); + assert.equal(lines.length, 1000); + assert.equal(lines[lines.length - 1].task, "newest"); + }); +}); + +describe("multi-process storm (lock-free append + atomic-rename rotation)", () => { + // The hard invariants the contract guarantees: never corrupt, never double-write, + // bounded size, rotation fires under concurrency. Dropping an append during the + // rare rotate window is permitted, so we do NOT assert that every write survives. + it("never corrupts or double-writes across many concurrent processes", async () => { + const dir = freshAgentDir(); + const histPath = path.join(dir, "run-history.jsonl"); + const childFile = path.join(dir, "writer.ts"); + const PROCS = 16; + const PER_PROC = 150; + fs.writeFileSync(childFile, ` +const [srcUrl, agentDir, tag, n] = process.argv.slice(2); +process.env.PI_CODING_AGENT_DIR = agentDir; +const { recordRun } = await import(srcUrl); +for (let i = 0; i < Number(n); i++) recordRun("storm", tag + ":" + i, 0, 1, { tool_calls: 0 }); +`); + + const procs = Array.from({ length: PROCS }, (_, k) => new Promise((resolve, reject) => { + const p = spawn(process.execPath, ["--experimental-strip-types", childFile, pathToFileURL(SRC).href, dir, `p${k}`, String(PER_PROC)], { stdio: "ignore" }); + p.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`child p${k} exited ${code}`)))); + p.on("error", reject); + })); + await Promise.all(procs); + + const lines = fs.readFileSync(histPath, "utf-8").split("\n").filter((l) => l.trim().length > 0); + // (1) no corruption: every line is valid JSON + const entries = lines.map((l) => JSON.parse(l) as RunEntry); + // (2) no double-write: markers in the file are unique (this is the codex + // double-enter failure mode — it cannot happen with lock-free appends) + const markers = entries.map((e) => e.task); + assert.equal(new Set(markers).size, markers.length, "duplicate entry detected"); + // (3) rotation fired and stayed bounded; never jammed to empty + const total = PROCS * PER_PROC; + assert.ok(entries.length < total, `rotation never fired: ${entries.length} >= ${total}`); + // Module-private rotation constants: ROTATE_KEEP=1000, ROTATE_READ_THRESHOLD=1200. + // The file is trimmed to 1000 and can transiently exceed 1200 by at most one append + // per concurrent process before a rotate completes. + assert.ok(entries.length >= 1000 && entries.length <= 1200 + PROCS, `unexpected count ${entries.length}`); + }); +}); From e20eeef60ae159dfe30992ab2c455b54ee48d6c5 Mon Sep 17 00:00:00 2001 From: JayceFreeman Date: Fri, 29 May 2026 01:29:16 -0600 Subject: [PATCH 2/2] test(run-history): derive child execArgv from a TS-loader whitelist The storm test spawned children with a hardcoded `--experimental-strip-types`. That drifts if Node renames the flag and diverges from the integration runner (`--experimental-transform-types --import ...`). Inheriting all of execArgv was rejected: it would leak `--test*`/`--inspect*`/profiler flags into 16 children (orphaned space-form values, debugger-port collisions, silent hangs). Instead, whitelist the TS-loader flags from the parent's execArgv (both `--flag=value` and space-form `--flag value`); unrecognized tokens are simply not copied, so test-runner/debugger/profiler flags and their orphaned values can never reach the child. Byte-identical to the old behavior on the current unit runner; robust if the TS mechanism changes. Full unit suite 477/477. --- test/unit/run-history.test.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/test/unit/run-history.test.ts b/test/unit/run-history.test.ts index ffcf199f..1afae1ce 100644 --- a/test/unit/run-history.test.ts +++ b/test/unit/run-history.test.ts @@ -110,8 +110,26 @@ const { recordRun } = await import(srcUrl); for (let i = 0; i < Number(n); i++) recordRun("storm", tag + ":" + i, 0, 1, { tool_calls: 0 }); `); + // Run children with the same TS-execution mechanism as the test runner. + // Whitelist the TS-loader flags from the parent's execArgv (handling both + // --flag=value and space-form --flag value) instead of hardcoding one flag + // that drifts on rename, or inheriting all of execArgv (which would leak + // --test*/--inspect*/profiler flags into 16 children). + const TS_BOOL_FLAGS = new Set(["--experimental-strip-types", "--experimental-transform-types"]); + const TS_VALUE_FLAGS = new Set(["--import", "--loader", "--experimental-loader", "--require", "-r"]); + const childExecArgv: string[] = []; + for (let i = 0; i < process.execArgv.length; i++) { + const arg = process.execArgv[i]!; + const name = arg.includes("=") ? arg.slice(0, arg.indexOf("=")) : arg; + if (TS_BOOL_FLAGS.has(name)) { + childExecArgv.push(arg); + } else if (TS_VALUE_FLAGS.has(name)) { + childExecArgv.push(arg); + if (!arg.includes("=") && i + 1 < process.execArgv.length) childExecArgv.push(process.execArgv[++i]!); + } + } const procs = Array.from({ length: PROCS }, (_, k) => new Promise((resolve, reject) => { - const p = spawn(process.execPath, ["--experimental-strip-types", childFile, pathToFileURL(SRC).href, dir, `p${k}`, String(PER_PROC)], { stdio: "ignore" }); + const p = spawn(process.execPath, [...childExecArgv, childFile, pathToFileURL(SRC).href, dir, `p${k}`, String(PER_PROC)], { stdio: "ignore" }); p.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`child p${k} exited ${code}`)))); p.on("error", reject); }));