/** * Worker spawn: pi resolution (R-EXEC-4), argv (§5.2), env (R-EXEC-6), detach * (R-EXEC-5), descriptor (R-CTRL-20/21), temp prompt file. */ import { type ChildProcess, spawn } from "node:child_process"; import * as fs from "node:fs"; import type { Socket } from "node:net"; import * as os from "node:os"; import * as path from "node:path"; import { randomBytes } from "node:crypto"; import type { Readable, Writable } from "node:stream"; import { getProcessStartIdentity, readTrustedPs } from "../proof-of-death.ts"; import { type RunPaths, type RunProcessGroupAnchor, writeJsonAtomic } from "./status.ts"; export interface PiResolution { command: string; /** Leading args before pi's own flags (e.g. the cli.js path for a node host). */ prefixArgs: string[]; /** Which R-EXEC-4 step produced this, for diagnostics and /agi-doctor. */ via: "PI_AGI_PI_BINARY" | "config.piBinary" | "execPath-binary" | "execPath-cli" | "PATH"; candidatesTried: string[]; } function isExecutable(file: string): boolean { try { fs.accessSync(file, fs.constants.X_OK); return fs.statSync(file).isFile(); } catch { return false; } } /** * P39 (from nicobailon `pi-spawn.ts` `resolvePiCliScript`). Walk up from a * candidate `cli.js` to the nearest `package.json` and require * `name === "@earendil-works/pi-coding-agent"`. * * This is the whole point of the port: without the name check, any host package * that happens to ship a `dist/cli.js` beside `process.execPath` hijacks worker * spawning and every worker runs someone else's program. Keeping the shape and * dropping the check would be a latent bug (R-IMPL-4). */ export function verifyPiPackage(cliPath: string, readJson: (file: string) => string | undefined = readIfExists): boolean { let dir = path.dirname(path.resolve(cliPath)); for (let depth = 0; depth < 8; depth++) { const manifest = readJson(path.join(dir, "package.json")); if (manifest !== undefined) { try { const parsed = JSON.parse(manifest) as { name?: unknown }; return parsed.name === "@earendil-works/pi-coding-agent"; } catch { return false; } } const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } return false; } function readIfExists(file: string): string | undefined { try { return fs.readFileSync(file, "utf8"); } catch { return undefined; } } /** * True when `process.execPath` is a standalone bun-compiled `pi`, rather than a * `node`/`bun`/`tsx` host running pi's JavaScript. Checked by basename because a * compiled binary is named `pi` and a host interpreter is not. */ function isStandalonePi(execPath: string): boolean { const base = path.basename(execPath).replace(/\.exe$/i, ""); return base === "pi"; } /** * R-EXEC-4, in priority order: * 1. `PI_AGI_PI_BINARY` if set and executable (plus `config.piBinary`, which is * the same override through a file rather than the environment) * 2. `process.execPath` if it is a standalone `pi` binary * 3. `process.execPath` + a resolved `cli.js` verified by package name * 4. `pi` on PATH * * E21: on total failure the caller gets every candidate tried and the * `PI_AGI_PI_BINARY` remedy, because "cannot find pi" with no list is unactionable. */ export function resolvePi(options: { env?: NodeJS.ProcessEnv; execPath?: string; configPiBinary?: string | null; /** Injection points, so the resolver is testable without a filesystem. */ executable?: (file: string) => boolean; verify?: (cliPath: string) => boolean; moduleDir?: string; } = {}): PiResolution { const env = options.env ?? process.env; const execPath = options.execPath ?? process.execPath; const executable = options.executable ?? isExecutable; const verify = options.verify ?? verifyPiPackage; const tried: string[] = []; const explicit = env.PI_AGI_PI_BINARY; if (explicit !== undefined && explicit.length > 0) { tried.push(`PI_AGI_PI_BINARY=${explicit}`); if (executable(explicit)) { return { command: explicit, prefixArgs: [], via: "PI_AGI_PI_BINARY", candidatesTried: tried }; } } const configured = options.configPiBinary; if (configured !== undefined && configured !== null && configured.length > 0) { tried.push(`config.piBinary=${configured}`); if (executable(configured)) { return { command: configured, prefixArgs: [], via: "config.piBinary", candidatesTried: tried }; } } tried.push(`process.execPath=${execPath}`); if (isStandalonePi(execPath) && executable(execPath)) { return { command: execPath, prefixArgs: [], via: "execPath-binary", candidatesTried: tried }; } // Step 3: this module lives inside the extension, which is installed beside (or // depends on) the coding-agent package. Search the plausible cli.js locations // and accept only one whose package.json carries pi's own name. const moduleDir = options.moduleDir ?? path.dirname(new URL(import.meta.url).pathname); const candidates: string[] = []; let dir = moduleDir; for (let depth = 0; depth < 8; depth++) { candidates.push(path.join(dir, "node_modules", "@earendil-works", "pi-coding-agent", "dist", "cli.js")); const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } for (const candidate of candidates) { tried.push(candidate); if (readIfExists(candidate) !== undefined && verify(candidate)) { return { command: execPath, prefixArgs: [candidate], via: "execPath-cli", candidatesTried: tried }; } } tried.push("pi (on PATH)"); return { command: "pi", prefixArgs: [], via: "PATH", candidatesTried: tried }; } export function describeResolutionFailure(resolution: PiResolution): string { return ( `Could not resolve a pi executable to spawn a worker with. Tried, in order:\n` + `${resolution.candidatesTried.map((candidate) => ` - ${candidate}`).join("\n")}\n` + `Set PI_AGI_PI_BINARY to the absolute path of your pi executable, or set "piBinary" in ` + `~/.pi/agent/agi/config.json.` ); } /** * R-EXEC-6. The child gets the parent environment plus the AGI variables in the * table, **minus** any other `PI_AGI_*` variable. * * The strip is the security-relevant half: without it, a `PI_AGI_ROLE` or * `PI_AGI_DISABLE` already present in the orchestrator's environment (set by a * user, an outer process, or a forged parent) reaches the child and either * disables the worker's own guards or makes it behave as something it is not. * Only the values this function sets are allowed through. */ export function buildWorkerEnv( parentEnv: NodeJS.ProcessEnv, fields: { runId: string; name: string; runDir: string; depth: number; maxDepth: number }, ): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {}; for (const [key, value] of Object.entries(parentEnv)) { if (key.startsWith("PI_AGI_")) continue; env[key] = value; } env.PI_AGI_ROLE = "worker"; env.PI_AGI_RUN_ID = fields.runId; env.PI_AGI_AGENT_NAME = fields.name; env.PI_AGI_RUN_DIR = fields.runDir; env.PI_AGI_DEPTH = String(fields.depth); env.PI_AGI_MAX_DEPTH = String(fields.maxDepth); // Optional tracing extensions can correlate a worker without knowing pi-agi's // private environment contract. These values are identifiers only; credentials // remain inherited from the parent environment and are never synthesized here. env.LMNR_TRACE_RUN_ID = fields.runId; env.LMNR_TRACE_AGENT_NAME = fields.name; env.LMNR_TRACE_PHASE = "worker"; env.LMNR_TRACE_ROLE = "worker"; return env; } export interface SpawnArgsOptions { sessionId: string | null; /** Set only when the parent had one (§5.2). */ sessionDir?: string; model?: string; thinking?: string; promptFile: string; /** R-WORK-2: --no-approve is always preserved; --approve only on cwd equality. */ approve?: boolean; extensionPaths: string[]; } export function buildSpawnArgs(options: SpawnArgsOptions): string[] { const args = ["--mode", "rpc"]; if (options.sessionId === null) args.push("--no-session"); else args.push("--session-id", options.sessionId); if (options.sessionDir !== undefined) args.push("--session-dir", options.sessionDir); if (options.model !== undefined) args.push("--model", options.model); if (options.thinking !== undefined) args.push("--thinking", options.thinking); // pi reads --append-system-prompt as a file when the value exists on disk and // as literal text otherwise (resource-loader.ts resolvePromptInput). A temp file // avoids a multi-kilobyte argv and any shell quoting question. args.push("--append-system-prompt", options.promptFile); if (options.approve === true) args.push("--approve"); else if (options.approve === false) args.push("--no-approve"); for (const extension of options.extensionPaths) args.push("-e", extension); return args; } /** R-CTRL-20. Stable relaunch state; capabilities come from the current Pi host. */ export interface RunDescriptor { schemaVersion: number; runId: string; name: string; agent: string; model: string | null; thinking: string | null; /** Inline, not a path: temp files vanish (R-CTRL-20). */ appendSystemPrompt: string; prompt: string; cwd: string; sessionFile: string | null; sessionId: string | null; /** * The session directory `--session-id` must be resolved in (R-CTRL-24). Recorded * because the orchestrator's own `--session-dir` can differ by the time a resume * happens — a restart with a different flag, or a config change — and resolving * the same id in a different directory silently continues a *different* session. * `null` means "pi's default", which is still only correct if it has not moved, * so a resume derives it from the verified `sessionFile` instead. */ sessionDir: string | null; sourceRunId: string; createdAt: string; } const DESCRIPTOR_SCHEMA_VERSION = 5; const DESCRIPTOR_FIELDS = new Set([ "schemaVersion", "runId", "name", "agent", "model", "thinking", "appendSystemPrompt", "prompt", "cwd", "sessionFile", "sessionId", "sessionDir", "sourceRunId", "createdAt", ]); /** * What the run's own independently-written records say about the worker that was * started. A descriptor is only a relaunch instruction; these are the facts. */ export interface DescriptorExpectation { runId: string; name: string; agent?: string; cwd?: string; model?: string | null; sessionId?: string | null; } export function writeDescriptor(paths: RunPaths, descriptor: RunDescriptor): void { writeJsonAtomic(paths.descriptor, descriptor); } /** * P26 / R-CTRL-21. Read back through an allowlist that **rejects unknown fields**, * with the `sourceRunId`/`name` cross-checks. * * A descriptor is a relaunch instruction. A corrupted or forged one can spawn a * worker with a different model, working directory, or system prompt. Rejecting * unknown fields is what makes the contract closed rather than merely validated. */ export function readDescriptor( raw: string, expect: DescriptorExpectation, ): { ok: true; descriptor: RunDescriptor } | { ok: false; reason: string } { let parsed: unknown; try { parsed = JSON.parse(raw); } catch (error) { return { ok: false, reason: `descriptor.json is not valid JSON: ${(error as Error).message}` }; } if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { return { ok: false, reason: "descriptor.json is not a JSON object" }; } const record = parsed as Record; const unknown = Object.keys(record).filter((key) => !DESCRIPTOR_FIELDS.has(key)); if (unknown.length > 0) { return { ok: false, reason: `descriptor.json has unknown field(s): ${unknown.join(", ")}` }; } if (record.schemaVersion !== DESCRIPTOR_SCHEMA_VERSION) { return { ok: false, reason: `descriptor.json has unsupported schemaVersion ${String(record.schemaVersion)}` }; } if (record.sourceRunId !== expect.runId) { return { ok: false, reason: `descriptor.json sourceRunId '${String(record.sourceRunId)}' does not match run '${expect.runId}'` }; } if (record.name !== expect.name) { return { ok: false, reason: `descriptor.json name '${String(record.name)}' does not match run task '${expect.name}'` }; } if (record.runId !== expect.runId) { return { ok: false, reason: `descriptor.json runId '${String(record.runId)}' does not match run '${expect.runId}'` }; } for (const field of ["agent", "cwd", "prompt", "appendSystemPrompt", "createdAt"]) { if (typeof record[field] !== "string" || record[field].length === 0) return { ok: false, reason: `descriptor.json field '${field}' must be a non-empty string` }; } if (typeof record.model !== "string" && record.model !== null) return { ok: false, reason: "descriptor.json field 'model' must be a string or null" }; if (typeof record.thinking !== "string" && record.thinking !== null) return { ok: false, reason: "descriptor.json field 'thinking' must be a string or null" }; if (typeof record.sessionFile !== "string" && record.sessionFile !== null) return { ok: false, reason: "descriptor.json field 'sessionFile' must be a string or null" }; if (typeof record.sessionId !== "string" || record.sessionId.length === 0) return { ok: false, reason: "descriptor.json field 'sessionId' must be a non-empty string" }; if (typeof record.sessionDir !== "string" && record.sessionDir !== null && record.sessionDir !== undefined) { return { ok: false, reason: "descriptor.json field 'sessionDir' must be a string or null" }; } if (record.sessionDir === undefined) record.sessionDir = null; if (!path.isAbsolute(record.cwd as string)) return { ok: false, reason: "descriptor.json cwd must be an absolute path" }; if (expect.agent !== undefined && record.agent !== expect.agent) { return { ok: false, reason: `descriptor.json agent '${String(record.agent)}' does not match the recorded agent '${expect.agent}'` }; } if (expect.cwd !== undefined && !sameRealpath(record.cwd as string, expect.cwd)) { return { ok: false, reason: `descriptor.json cwd '${String(record.cwd)}' does not resolve to the recorded cwd '${expect.cwd}'` }; } if (expect.model !== undefined && record.model !== expect.model) { return { ok: false, reason: `descriptor.json model '${String(record.model)}' does not match the recorded model '${String(expect.model)}'` }; } if (expect.sessionId !== undefined && record.sessionId !== expect.sessionId) { return { ok: false, reason: "descriptor.json session identity does not match status.json" }; } return { ok: true, descriptor: { schemaVersion: DESCRIPTOR_SCHEMA_VERSION, runId: record.runId as string, name: record.name as string, agent: record.agent as string, model: record.model as string | null, thinking: record.thinking as string | null, appendSystemPrompt: record.appendSystemPrompt as string, prompt: record.prompt as string, cwd: record.cwd as string, sessionFile: record.sessionFile as string | null, sessionId: record.sessionId as string, sessionDir: record.sessionDir as string | null, sourceRunId: record.sourceRunId as string, createdAt: record.createdAt as string, }, }; } /** Temp prompt file for `--append-system-prompt`, mode 0600. */ export function writePromptFile(content: string, dir = os.tmpdir()): string { const file = path.join(dir, `pi-agi-prompt-${randomBytes(10).toString("hex")}.md`); fs.writeFileSync(file, content, { encoding: "utf8", mode: 0o600 }); return file; } export interface SpawnResult { child: ChildProcess; pid: number; processStartIdentity: string | undefined; anchorReady: Promise<{ ok: true; anchor: RunProcessGroupAnchor } | { ok: false; reason: string }> | null; command: string; args: string[]; } export interface ProcessGroupIdentity { pgid: number; sid: number; } /** Parse the two numeric columns emitted by BSD `ps -o pgid= -o sess=`. */ export function parseProcessGroupIdentity(output: string): ProcessGroupIdentity | undefined { const fields = output.trim().split(/\s+/); if (fields.length !== 2) return undefined; const pgid = Number(fields[0]); const sid = Number(fields[1]); if (!Number.isInteger(pgid) || pgid <= 1 || !Number.isInteger(sid) || sid <= 1) return undefined; return { pgid, sid }; } /** OS process-group/session facts used by the detached-group anchor. */ export function readProcessGroupIdentity(pid: number): ProcessGroupIdentity | undefined { if (!Number.isInteger(pid) || pid <= 1) return undefined; if (process.platform === "linux") { try { const stat = fs.readFileSync(`/proc/${pid}/stat`, "utf8"); const close = stat.lastIndexOf(")"); if (close < 0) return undefined; const fields = stat.slice(close + 2).trim().split(/\s+/); return parseProcessGroupIdentity(`${fields[2] ?? ""} ${fields[3] ?? ""}`); } catch { return undefined; } } if (process.platform === "darwin" || process.platform === "freebsd" || process.platform === "openbsd") { const out = readTrustedPs(["-o", "pgid=", "-o", "sess=", "-p", String(pid)]); return out === undefined ? undefined : parseProcessGroupIdentity(out); } return undefined; } const POSIX_WORKER_LAUNCHER = String.raw` leader=$$ anchor_delay=$1 anchor_ready_file=$2 shift 2 umask 077 rm -f "$anchor_ready_file" ( exec 3>&- PATH=/usr/bin:/bin export PATH if [ "$anchor_delay" != "0" ]; then sleep "$anchor_delay" || exit 125; fi trap '' TERM HUP INT printf '%s\n' ready > "$anchor_ready_file" || exit 125 while kill -0 "$leader" 2>/dev/null; do sleep 1 || :; done sleep 60 ) /dev/null 2>&1 & anchor=$! anchor_waits=0 while [ ! -s "$anchor_ready_file" ]; do if ! kill -0 "$anchor" 2>/dev/null; then rm -f "$anchor_ready_file" exec 3>&- exit 125 fi anchor_waits=$((anchor_waits + 1)) if [ "$anchor_waits" -ge 500 ]; then kill -KILL "$anchor" 2>/dev/null || : rm -f "$anchor_ready_file" exec 3>&- exit 125 fi sleep 0.01 || : done rm -f "$anchor_ready_file" printf '%s\n' "$anchor" >&3 exec 3>&- exec "$@" `; function readAnchor( stream: Readable | null | undefined, leaderPid: number, readyFile: string, ): Promise<{ ok: true; anchor: RunProcessGroupAnchor } | { ok: false; reason: string }> { if (stream === null || stream === undefined) { try { fs.unlinkSync(readyFile); } catch {} return Promise.resolve({ ok: false, reason: "launcher did not expose its anchor pipe" }); } return new Promise((resolve) => { let settled = false; let buffer = ""; const finish = (result: { ok: true; anchor: RunProcessGroupAnchor } | { ok: false; reason: string }): void => { if (settled) return; settled = true; try { fs.unlinkSync(readyFile); } catch {} resolve(result); }; stream.on("data", (chunk: Buffer | string) => { if (settled) return; buffer += chunk.toString(); if (buffer.length > 128) { finish({ ok: false, reason: "launcher anchor record exceeded 128 bytes" }); return; } const newline = buffer.indexOf("\n"); if (newline < 0) return; const raw = buffer.slice(0, newline).trim(); const pid = Number(raw); if (!Number.isInteger(pid) || pid <= 1 || pid === leaderPid) { finish({ ok: false, reason: `launcher reported invalid anchor pid '${raw}'` }); return; } const processStartIdentity = getProcessStartIdentity(pid); const anchor = readProcessGroupIdentity(pid); if (processStartIdentity === undefined || anchor === undefined) { finish({ ok: false, reason: "launcher anchor identity was unreadable" }); return; } // The detached child is created as both PGID and SID leader. The inert anchor // is the durable witness, so do not require the leader to remain alive until // Node drains fd 3; very short workers may already have exited here. if (anchor.pgid !== leaderPid || anchor.sid !== leaderPid) { finish({ ok: false, reason: `launcher anchor group/session mismatch (expected ${leaderPid}/${leaderPid}, anchor ${anchor.pgid}/${anchor.sid})` }); return; } finish({ ok: true, anchor: { pid, processStartIdentity, pgid: anchor.pgid, sid: anchor.sid } }); }); stream.once("error", (error) => finish({ ok: false, reason: `launcher anchor pipe failed: ${error.message}` })); stream.once("end", () => finish({ ok: false, reason: "launcher closed before reporting its anchor" })); unrefStream(stream); }); } /** * R-EXEC-5. `detached: true` on POSIX so the child leads its own process group * and a negative-PID signal reaches its whole tree (a worker's own `npm` children * included). The child handle **and all three stdio pipes** are unref'd so the * orchestrator's event loop is not held open by a worker that outlives the turn — * unreffing the child alone is not enough, because the pipes are independent libuv * handles that the pump keeps flowing. Windows does not get `detached`; termination * there goes through `taskkill /T /F`. * * KNOWN LIMITATION, verified by experiment — a worker does **not** actually survive * the orchestrator's death on pi 0.83, and `detached` cannot make it: * * `modes/rpc/rpc-mode.ts:800-803` registers `process.stdin.on("end", () => * shutdown())`. When the orchestrator dies, its end of the stdin pipe closes, the * worker reads EOF, and pi shuts the worker down *deliberately*. Measured: a * detached pi rpc child was dead within 3s of its parent being SIGKILLed, while a * detached `bash -c "sleep 300"` spawned identically survived. Passing * `stdio[0]="ignore"` instead makes it worse, not better — stdin is then * `/dev/null`, EOF is immediate, and the worker exits at startup. * * So §5.2's "stdin is a live control channel" and R-CTRL-31's "adopt the live child" * are mutually exclusive on this pi version. nicobailon's `stdio[0]="ignore"` (which * §5.2 explicitly rejects) is *why* its workers were survivable; it paid for that * with a filesystem-only control plane. * * We keep stdin, because R-EXEC-2 is not optional: without a writable stdin there is * no way to answer `extension_ui_request`, and an unanswered dialog hangs the worker * forever with no deadline of its own (verified: alive and silent 25s after emitting * `confirm`). A worker that dies with its orchestrator and is then correctly reported * as `orphaned` is a better failure than a worker that hangs invisibly. * * Consequence for reconciliation: the adoption branch of §10.7 is implemented and * correct, but in practice a crashed orchestrator's workers are already dead by the * time the next session starts, so they take the orphan branch. Adoption only fires * when the worker genuinely outlived the orchestrator process, which needs a pi that * can be told not to exit on stdin EOF (a `--rpc-keep-alive` flag, or treating EOF as * "no more commands" rather than "quit"). */ export function spawnWorker(options: { resolution: PiResolution; args: string[]; cwd: string; env: NodeJS.ProcessEnv; /** Test seam for a real stop-before-readiness race. Production leaves this at zero. */ anchorSetupDelayMs?: number; }): SpawnResult { const args = [...options.resolution.prefixArgs, ...options.args]; const anchored = process.platform !== "win32"; const anchorDelaySeconds = Math.max(0, options.anchorSetupDelayMs ?? 0) / 1_000; const anchorReadyFile = path.join(os.tmpdir(), `pi-agi-anchor-ready-${process.pid}-${randomBytes(10).toString("hex")}`); const child = spawn( anchored ? "/bin/sh" : options.resolution.command, anchored ? ["-c", POSIX_WORKER_LAUNCHER, "pi-agi-worker", anchorDelaySeconds === 0 ? "0" : anchorDelaySeconds.toFixed(3), anchorReadyFile, options.resolution.command, ...args] : args, { cwd: options.cwd, env: options.env, detached: anchored, stdio: anchored ? ["pipe", "pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe"], windowsHide: true, }, ); child.unref(); // `child.unref()` unrefs only the process handle. The three stdio pipes are // separate libuv handles and stay referenced, and the pump attaches `data` // listeners to stdout/stderr, so without these the orchestrator's event loop is // held open for the worker's whole lifetime (measured: 8.0s for an 8s child). // // The pipes are `net.Socket`s at runtime, which have `unref()`, but the declared // stdio types are the wider `Readable`/`Writable`, so the call is guarded rather // than cast — a non-socket stream simply does not need unreffing. unrefStream(child.stdout); unrefStream(child.stderr); unrefStream(child.stdin); const pid = child.pid ?? -1; const anchorReady = anchored && pid > 0 ? readAnchor(child.stdio[3] as Readable | null | undefined, pid, anchorReadyFile) : null; return { child, pid, // Captured immediately: this is the proof-of-death token (P12), and it must // be read while the process is known to be the one we just started. processStartIdentity: pid > 0 ? getProcessStartIdentity(pid) : undefined, anchorReady, command: options.resolution.command, args, }; } /** See `spawnWorker`: the stdio pipes are Sockets, whose `unref` the base types hide. */ function unrefStream(stream: Readable | Writable | null | undefined): void { (stream as Socket | null | undefined)?.unref?.(); } /** * R-EXEC-3 / R-CTRL-17 escalation against the **process group** (negative pid), so * a worker's own children die with it. Windows uses taskkill /T /F because * process.kill cannot reach a tree there (P20's platform note). */ export function signalProcessTree(pid: number, signal: NodeJS.Signals): boolean { if (pid <= 0) return false; try { if (process.platform === "win32") { spawn("taskkill", ["/T", "/F", "/PID", String(pid)], { stdio: "ignore", windowsHide: true }).unref(); return true; } process.kill(-pid, signal); return true; } catch { // ESRCH on the *group* does not mean the process is gone: if `detached` never // took effect the child is not a group leader, so no process group with that // id exists while the child itself is alive and needs killing. Returning early // on ESRCH made this fallback unreachable in exactly the case it was written for. try { process.kill(pid, signal); return true; } catch { return false; } } } /** Group-only signal used only after a live anchor proves the original leader's PGID. */ export function signalProcessGroup(pgid: number, signal: NodeJS.Signals): boolean { if (process.platform === "win32" || !Number.isInteger(pgid) || pgid <= 1) return false; try { process.kill(-pgid, signal); return true; } catch { return false; } } /** R-WORK-2: project trust transfers only when the child runs in the same directory. */ export function sameRealpath(a: string, b: string): boolean { try { return fs.realpathSync(a) === fs.realpathSync(b); } catch { return false; } }