pi-claude-supervisor 0.5.2 → 0.5.4

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.
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { spawn, type ChildProcess } from "node:child_process";
3
3
  import { constants as fsConstants, readFileSync } from "node:fs";
4
- import { access, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
4
+ import { access, mkdir, readFile, readdir, rm, rmdir, stat, writeFile } from "node:fs/promises";
5
5
  import { delimiter, isAbsolute, join } from "node:path";
6
6
  import type {
7
7
  WorkerAdapter,
@@ -15,6 +15,7 @@ import type {
15
15
  } from "../types.ts";
16
16
  import { assertSafeWorkerCommand } from "../policy.ts";
17
17
  import { workerEnvironment } from "./environment.ts";
18
+ import { isClaudeProcess, linuxCgroupProcesses, linuxProcessTree, sameProcessIdentity, unexpectedClaudeProcess, unexpectedReviewerProcess, type ProcessTreeEntry } from "./process-tree.ts";
18
19
 
19
20
  export interface ProcessWorkerAdapterOptions {
20
21
  /** Use Claude Code's documented stream-json stdin/stdout framing. */
@@ -79,6 +80,9 @@ interface ProcessRecord {
79
80
  inputTail: Promise<void>;
80
81
  listeners: Set<WorkerEventListener>;
81
82
  permissionResponses: Set<string>;
83
+ enforceNestedClaude: boolean;
84
+ expectedClaudeCommand?: string;
85
+ trustedClaudeProcesses: Map<number, ProcessTreeEntry>;
82
86
  stopping?: boolean;
83
87
  starting: boolean;
84
88
  abortRequested: boolean;
@@ -129,19 +133,19 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
129
133
  };
130
134
  }
131
135
 
132
- async preflight(input: Pick<WorkerStartInput, "cwd" | "command" | "args" | "env" | "approval">): Promise<void> {
136
+ async preflight(input: Pick<WorkerStartInput, "cwd" | "command" | "args" | "env" | "approval" | "automatic">): Promise<void> {
133
137
  const args = this.#mode === "claude-jsonl" ? claudeJsonlArgs(input.args) : (input.args ?? []);
134
138
  assertSafeWorkerCommand(input.command, args, input.approval);
135
139
  await assertWorkerCwd(input.cwd);
136
140
  await assertExecutable(input.command, input.env?.PATH ?? process.env.PATH);
137
- if (this.#cgroupMode === "required") await this.#preflightRequiredCgroup();
141
+ if (this.#cgroupMode === "required" || input.automatic) await this.#preflightRequiredCgroup();
138
142
  }
139
143
 
140
144
  async start(input: WorkerStartInput): Promise<WorkerHandle> {
141
145
  if (input.abortSignal?.aborted) throw new Error("worker startup aborted before spawn");
142
146
  const args = this.#mode === "claude-jsonl" ? claudeJsonlArgs(input.args) : (input.args ?? []);
143
147
  assertSafeWorkerCommand(input.command, args, input.approval);
144
- if (this.#cgroupMode === "required") await this.preflight(input);
148
+ if (this.#cgroupMode === "required" || input.automatic) await this.preflight(input);
145
149
  const handle: WorkerHandle = {
146
150
  id: randomUUID(),
147
151
  startedAt: new Date().toISOString(),
@@ -154,24 +158,48 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
154
158
  if (pendingAbortReason !== undefined) throw new Error(`worker startup aborted: ${pendingAbortReason}`);
155
159
  let cgroupPath: string | undefined;
156
160
  let cgroupError: Error | undefined;
157
- if (this.#cgroupMode !== "off" && process.platform === "linux") {
161
+ if (input.automatic && this.#cgroupMode === "off") throw new Error("automatic supervision requires cgroup containment");
162
+ if ((this.#cgroupMode !== "off" || input.automatic) && process.platform === "linux") {
158
163
  try {
159
164
  cgroupPath = await this.#createCgroup(handle.id);
160
165
  handle.cgroupPath = cgroupPath;
161
166
  } catch (error) {
162
167
  cgroupError = error instanceof Error ? error : new Error(String(error));
163
- if (this.#cgroupMode === "required") throw new Error(`unable to create a worker cgroup: ${cgroupError.message}`, { cause: cgroupError });
168
+ if (this.#cgroupMode === "required" || input.automatic) throw new Error(`unable to create a worker cgroup: ${cgroupError.message}`, { cause: cgroupError });
164
169
  }
165
170
  }
166
171
  const delayedAbortReason = input.startupToken ? this.#pendingStartupAborts.get(input.startupToken) : undefined;
167
172
  if (input.startupToken) this.#pendingStartupAborts.delete(input.startupToken);
168
173
  if (input.abortSignal?.aborted || delayedAbortReason !== undefined) {
169
- if (cgroupPath) await rm(cgroupPath, { recursive: true, force: true }).catch(() => {});
174
+ if (cgroupPath) await removeCgroupDirectory(cgroupPath).catch(() => {});
170
175
  throw new Error(`worker startup aborted${delayedAbortReason ? `: ${delayedAbortReason}` : " before spawn"}`);
171
176
  }
172
- const launch = cgroupPath
173
- ? cgroupBootstrapLaunch(input.command, args, input.cwd, workerEnv, cgroupPath)
177
+ const parentStartTime = process.platform === "linux" ? readProcessStartTimeSync(process.pid) : undefined;
178
+ if (input.automatic && process.platform === "linux" && !parentStartTime) {
179
+ if (cgroupPath) await removeCgroupDirectory(cgroupPath).catch(() => {});
180
+ throw new Error("automatic worker parent identity is unavailable");
181
+ }
182
+ const useGuardedBootstrap = process.platform === "linux" && Boolean(parentStartTime) && Boolean(cgroupPath);
183
+ const launch = useGuardedBootstrap
184
+ ? guardedBootstrapLaunch(input.command, args, input.cwd, workerEnv, cgroupPath, process.pid, parentStartTime!)
174
185
  : { command: input.command, args, env: workerEnv };
186
+ try {
187
+ // This is the last asynchronous operation before spawn. Automatic mode
188
+ // uses it for an exact repository HEAD assertion; adapters that add
189
+ // setup work must invoke the hook only after that work is complete.
190
+ await input.preSpawnCheck?.();
191
+ } catch (error) {
192
+ if (cgroupPath) await removeCgroupDirectory(cgroupPath).catch(() => {});
193
+ throw error;
194
+ }
195
+ const finalAbortReason = input.startupToken ? this.#pendingStartupAborts.get(input.startupToken) : undefined;
196
+ if (input.startupToken) this.#pendingStartupAborts.delete(input.startupToken);
197
+ if (input.abortSignal?.aborted || finalAbortReason !== undefined) {
198
+ if (cgroupPath) await removeCgroupDirectory(cgroupPath).catch(() => {});
199
+ throw new Error(`worker startup aborted${finalAbortReason ? `: ${finalAbortReason}` : " before spawn"}`);
200
+ }
201
+ const guardedLaunch = useGuardedBootstrap;
202
+ let bootstrapReady = !guardedLaunch;
175
203
  let child: ChildProcess;
176
204
  try {
177
205
  child = spawn(launch.command, launch.args, {
@@ -181,7 +209,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
181
209
  stdio: ["pipe", "pipe", "pipe"],
182
210
  });
183
211
  } catch (error) {
184
- if (cgroupPath) await rm(cgroupPath, { recursive: true, force: true }).catch(() => {});
212
+ if (cgroupPath) await removeCgroupDirectory(cgroupPath).catch(() => {});
185
213
  throw error;
186
214
  }
187
215
  handle.pid = child.pid;
@@ -218,6 +246,9 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
218
246
  inputTail: Promise.resolve(),
219
247
  listeners: new Set(input.eventListener ? [input.eventListener] : []),
220
248
  permissionResponses: new Set(),
249
+ enforceNestedClaude: Boolean(input.automatic),
250
+ expectedClaudeCommand: input.automatic ? input.command : undefined,
251
+ trustedClaudeProcesses: new Map(),
221
252
  starting: true,
222
253
  abortRequested: false,
223
254
  startupToken: input.startupToken,
@@ -234,7 +265,13 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
234
265
  this.#records.set(handle.id, record);
235
266
  if (input.abortSignal?.aborted) abortListener();
236
267
  const capture = (stream: "stdout" | "stderr") => (chunk: Buffer | string) => {
237
- const text = String(chunk);
268
+ let text = String(chunk);
269
+ if (guardedLaunch && stream === "stderr" && text.includes(BOOTSTRAP_READY_MARKER)) {
270
+ text = text.replaceAll(`${BOOTSTRAP_READY_MARKER}\n`, "").replaceAll(BOOTSTRAP_READY_MARKER, "");
271
+ bootstrapReady = true;
272
+ resolveSpawn();
273
+ }
274
+ if (!text) return;
238
275
  record.lastOutputAt = new Date().toISOString();
239
276
  const outputChunk = { stream, text, at: record.lastOutputAt } as WorkerOutputChunk;
240
277
  this.#appendOutput(record, outputChunk);
@@ -256,7 +293,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
256
293
  record.processGroupIdentityError = error instanceof Error ? error : new Error(String(error));
257
294
  }
258
295
  }
259
- resolveSpawn();
296
+ if (!guardedLaunch) resolveSpawn();
260
297
  });
261
298
  child.once("error", (error) => {
262
299
  if (!record.spawnedSuccessfully) {
@@ -272,6 +309,11 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
272
309
  }
273
310
  });
274
311
  child.once("exit", (code, signal) => {
312
+ if (guardedLaunch && !bootstrapReady && !record.spawnError) {
313
+ const error = new Error("worker bootstrap exited before reporting readiness");
314
+ record.spawnError = error;
315
+ rejectSpawn(error);
316
+ }
275
317
  if (!record.spawnError) {
276
318
  record.exitCode = code;
277
319
  record.signal = signal ?? undefined;
@@ -301,6 +343,23 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
301
343
  }
302
344
  throw startupError;
303
345
  }
346
+ if (record.enforceNestedClaude) {
347
+ try {
348
+ await this.#rememberClaudeProcesses(record);
349
+ } catch (error) {
350
+ let cleanupError: unknown;
351
+ try { await this.stop(handle, "worker containment startup failed"); }
352
+ catch (stopError) { cleanupError = stopError; }
353
+ if (cleanupError) {
354
+ const startupError = error instanceof Error ? error : new Error(String(error));
355
+ startupError.message = `${startupError.message}; startup cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`;
356
+ Object.defineProperty(startupError, "workerHandle", { value: handle, enumerable: false });
357
+ Object.defineProperty(startupError, "workerCleanupRequired", { value: true, enumerable: false });
358
+ throw startupError;
359
+ }
360
+ throw error;
361
+ }
362
+ }
304
363
  if (input.task) {
305
364
  this.#assertNotAborted(record);
306
365
  record.lastInputAt = new Date().toISOString();
@@ -351,6 +410,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
351
410
  async getStatus(handle: WorkerHandle): Promise<WorkerStatus> {
352
411
  const record = this.#record(handle);
353
412
  const running = record.exitCode === undefined;
413
+ if (running && record.enforceNestedClaude) await this.#checkNestedClaude(record);
354
414
  if (!running && record.groupCleanup) {
355
415
  try {
356
416
  await record.groupCleanup;
@@ -366,10 +426,10 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
366
426
  lastOutputAt: record.lastOutputAt,
367
427
  lastInputAt: record.lastInputAt,
368
428
  activeRequests: this.#mode === "claude-jsonl" ? record.activeRequests : undefined,
369
- exitReason: running ? undefined : record.stopping ? "stopped" : record.signal ? "crashed" : record.exitCode === 0 ? "completed" : "failed",
429
+ exitReason: running ? undefined : record.runtimeError ? "failed" : record.stopping ? "stopped" : record.signal ? "crashed" : record.exitCode === 0 ? "completed" : "failed",
370
430
  processGroupCleaned: record.groupCleanupComplete,
371
431
  cgroupCleaned: record.cgroupPath ? record.groupCleanupComplete : undefined,
372
- cgroupRequired: this.#cgroupMode === "required",
432
+ cgroupRequired: this.#cgroupMode === "required" || record.enforceNestedClaude,
373
433
  cgroupError: record.cgroupError?.message,
374
434
  cleanupError: record.cleanupError?.message,
375
435
  runtimeError: record.runtimeError?.message,
@@ -635,32 +695,82 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
635
695
  }
636
696
 
637
697
  async #preflightRequiredCgroup(): Promise<void> {
638
- if (process.platform !== "linux") throw new Error("required cgroup cleanup is unavailable on this platform");
639
- let probePath: string | undefined;
640
- try {
641
- const parent = this.#cgroupParentPath ?? await currentCgroupPath();
642
- await access(parent, fsConstants.W_OK);
643
- probePath = `${parent}/pi-claude-supervisor-preflight-${process.pid}-${randomUUID()}`;
644
- await mkdir(probePath);
645
- await stat(`${probePath}/cgroup.kill`);
646
- await stat(`${probePath}/cgroup.events`);
647
- await writeFile(`${probePath}/cgroup.kill`, "1\n");
648
- const events = await readFile(`${probePath}/cgroup.events`, "utf8");
649
- if (!/^populated 0$/mu.test(events)) throw new Error("preflight cgroup did not report populated 0");
650
- } catch (error) {
651
- throw new Error(`required cgroup preflight failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
652
- } finally {
653
- if (probePath) await rm(probePath, { recursive: true, force: true }).catch(() => {});
654
- }
698
+ await preflightCgroupContainment(this.#cgroupParentPath);
655
699
  }
656
700
 
657
701
  async #createCgroup(id: string): Promise<string> {
658
702
  const parent = this.#cgroupParentPath ?? await currentCgroupPath();
659
703
  const path = `${parent}/pi-claude-supervisor-${id}`;
660
704
  await mkdir(path);
705
+ try {
706
+ await access(`${path}/cgroup.procs`, fsConstants.R_OK | fsConstants.W_OK);
707
+ await access(`${path}/cgroup.events`, fsConstants.R_OK);
708
+ await access(`${path}/cgroup.kill`, fsConstants.W_OK);
709
+ } catch (error) {
710
+ await removeCgroupDirectory(path).catch(() => {});
711
+ throw new Error(`worker cgroup controls are unavailable: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
712
+ }
661
713
  return path;
662
714
  }
663
715
 
716
+ async #processScope(record: ProcessRecord): Promise<ProcessTreeEntry[]> {
717
+ const rootPid = record.handle.pid ?? 0;
718
+ const tree = await linuxProcessTree(rootPid);
719
+ if (!record.cgroupPath) return tree;
720
+ const group = await linuxCgroupProcesses(record.cgroupPath);
721
+ if (record.enforceNestedClaude && !group.some((entry) => entry.pid === rootPid)) {
722
+ throw new Error("automatic worker is not contained by its required cgroup");
723
+ }
724
+ return [...new Map([...tree, ...group].map((entry) => [entry.pid, entry])).values()];
725
+ }
726
+
727
+ async #rememberClaudeProcesses(record: ProcessRecord): Promise<void> {
728
+ const rootPid = record.handle.pid ?? 0;
729
+ let entries: ProcessTreeEntry[] = [];
730
+ for (let attempt = 0; attempt < 20; attempt += 1) {
731
+ try {
732
+ entries = await this.#processScope(record);
733
+ if (entries.some((entry) => entry.ppid === rootPid && isClaudeProcess(entry, record.expectedClaudeCommand))) break;
734
+ } catch (error) {
735
+ if (attempt === 19) throw error;
736
+ }
737
+ await delay(25);
738
+ }
739
+ record.trustedClaudeProcesses = new Map(entries.filter((entry) => entry.ppid === rootPid && isClaudeProcess(entry, record.expectedClaudeCommand)).map((entry) => [entry.pid, entry]));
740
+ }
741
+
742
+ async #checkNestedClaude(record: ProcessRecord): Promise<void> {
743
+ const rootPid = record.handle.pid ?? 0;
744
+ let entries: ProcessTreeEntry[];
745
+ try { entries = await this.#processScope(record); }
746
+ catch (error) {
747
+ if (process.platform === "linux" && !record.runtimeError) {
748
+ record.runtimeError = new Error(`nested Claude guard unavailable: ${error instanceof Error ? error.message : String(error)}`);
749
+ record.stopping = true;
750
+ try { record.child.kill("SIGTERM"); } catch {}
751
+ this.#appendOutput(record, { stream: "stderr", text: `${record.runtimeError.message}\n`, at: new Date().toISOString() });
752
+ }
753
+ return;
754
+ }
755
+ if (record.trustedClaudeProcesses.size === 0) {
756
+ const directClaude = entries.filter((entry) => entry.ppid === rootPid && isClaudeProcess(entry, record.expectedClaudeCommand));
757
+ if (directClaude.length > 0) {
758
+ record.trustedClaudeProcesses = new Map(directClaude.map((entry) => [entry.pid, entry]));
759
+ return;
760
+ }
761
+ }
762
+ const nestedClaude = unexpectedClaudeProcess(entries, record.trustedClaudeProcesses, record.expectedClaudeCommand);
763
+ const reviewerCandidate = unexpectedReviewerProcess(entries);
764
+ const trustedReviewerCandidate = reviewerCandidate && record.trustedClaudeProcesses.get(reviewerCandidate.pid);
765
+ const nestedReviewer = reviewerCandidate && (!trustedReviewerCandidate || !sameProcessIdentity(trustedReviewerCandidate, reviewerCandidate)) ? reviewerCandidate : undefined;
766
+ const nested = nestedClaude ?? nestedReviewer;
767
+ if (!nested || record.runtimeError) return;
768
+ record.runtimeError = new Error(`${nested === nestedReviewer ? "nested Reviewer" : "nested Claude"} process denied (pid=${nested.pid})`);
769
+ record.stopping = true;
770
+ try { record.child.kill("SIGTERM"); } catch {}
771
+ this.#appendOutput(record, { stream: "stderr", text: `${record.runtimeError.message}\n`, at: new Date().toISOString() });
772
+ }
773
+
664
774
  #record(handle: WorkerHandle): ProcessRecord {
665
775
  const record = this.#records.get(handle.id);
666
776
  if (!record) throw new Error(`unknown worker handle: ${handle.id}`);
@@ -709,12 +819,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
709
819
  }
710
820
  const deadline = Date.now() + this.#killGraceMs;
711
821
  while (Date.now() <= deadline) {
712
- try {
713
- process.kill(-pid, 0);
714
- } catch (error) {
715
- if (error instanceof Error && /ESRCH/u.test(error.message)) return;
716
- throw error;
717
- }
822
+ if (!(await processGroupHasLiveMember(pid))) return;
718
823
  await delay(Math.min(10, Math.max(1, deadline - Date.now())));
719
824
  }
720
825
  throw new Error(`worker process group ${pid} did not exit before cleanup deadline`);
@@ -727,91 +832,176 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
727
832
  }
728
833
  }
729
834
 
730
- const CGROUP_BOOTSTRAP_KEYS = {
835
+ const BOOTSTRAP_READY_MARKER = "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_READY";
836
+
837
+ const BOOTSTRAP_KEYS = {
731
838
  command: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_COMMAND",
732
839
  args: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_ARGS",
733
840
  cwd: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_CWD",
734
841
  cgroup: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_CGROUP",
842
+ parentPid: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_PARENT_PID",
843
+ parentStartTime: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_PARENT_START",
735
844
  } as const;
736
845
 
737
- const CGROUP_BOOTSTRAP_SCRIPT = `
846
+ /**
847
+ * Keep the worker in a small bootstrap process group which watches the
848
+ * Supervisor's PID/start-time identity. A SIGKILL cannot run cleanup in the
849
+ * Supervisor itself, so the detached worker must notice that its owner died
850
+ * and terminate its own descendants. The cgroup path remains an additional
851
+ * descendant boundary when v2 is available.
852
+ */
853
+ const GUARDED_BOOTSTRAP_SCRIPT = `
738
854
  const { spawn } = require("node:child_process");
739
- const { writeFileSync } = require("node:fs");
740
- const keys = ${JSON.stringify(Object.values(CGROUP_BOOTSTRAP_KEYS))};
855
+ const { readFileSync, rmSync, rmdirSync, writeFileSync } = require("node:fs");
856
+ const { dirname } = require("node:path");
857
+ const keys = ${JSON.stringify(Object.values(BOOTSTRAP_KEYS))};
741
858
  const decode = (key) => Buffer.from(process.env[key] || "", "base64").toString("utf8");
859
+ const readStart = (pid) => {
860
+ try {
861
+ const stat = readFileSync("/proc/" + pid + "/stat", "utf8");
862
+ const close = stat.lastIndexOf(")");
863
+ return close < 0 ? undefined : stat.slice(close + 2).trim().split(/\\s+/u)[19];
864
+ } catch { return undefined; }
865
+ };
866
+ const parentPid = Number(decode(${JSON.stringify(BOOTSTRAP_KEYS.parentPid)}));
867
+ const parentStart = decode(${JSON.stringify(BOOTSTRAP_KEYS.parentStartTime)});
868
+ const cgroup = decode(${JSON.stringify(BOOTSTRAP_KEYS.cgroup)});
869
+ const parentCgroup = cgroup ? dirname(cgroup) : undefined;
870
+ const parentAlive = () => Boolean(parentPid > 0 && process.ppid === parentPid && (() => {
871
+ try { process.kill(parentPid, 0); } catch { return false; }
872
+ return !parentStart || readStart(parentPid) === parentStart;
873
+ })());
874
+ const moveOutOfCgroup = () => {
875
+ if (!parentCgroup) return;
876
+ try { writeFileSync(parentCgroup + "/cgroup.procs", String(process.pid) + "\\n"); } catch {}
877
+ };
878
+ const removeCgroup = () => {
879
+ if (!cgroup) return;
880
+ try { rmdirSync(cgroup); return; } catch {}
881
+ try { rmSync(cgroup, { recursive: true, force: true }); } catch {}
882
+ try { rmdirSync(cgroup); } catch {}
883
+ };
884
+ const killGroup = (signal) => {
885
+ try { process.kill(-process.pid, signal); } catch {}
886
+ };
887
+ const finishAfterCgroup = (code, signal) => {
888
+ const finish = () => {
889
+ removeCgroup();
890
+ if (signal) {
891
+ for (const forwarded of ["SIGTERM", "SIGINT", "SIGQUIT"]) process.removeAllListeners(forwarded);
892
+ try { process.kill(process.pid, signal); } catch {}
893
+ setTimeout(() => process.exit(128), 50).unref();
894
+ return;
895
+ }
896
+ process.exit(code ?? 1);
897
+ };
898
+ if (!cgroup) { killGroup("SIGKILL"); return; }
899
+ try { writeFileSync(cgroup + "/cgroup.kill", "1\\n"); } catch {}
900
+ const deadline = Date.now() + 250;
901
+ const reap = () => {
902
+ let empty = false;
903
+ try { empty = /^populated 0$/mu.test(readFileSync(cgroup + "/cgroup.events", "utf8")); }
904
+ catch (error) {
905
+ if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { finish(); return; }
906
+ }
907
+ if (empty || Date.now() >= deadline) { finish(); return; }
908
+ setTimeout(reap, 25).unref();
909
+ };
910
+ reap();
911
+ };
742
912
  let child;
913
+ let finished = false;
914
+ let orphaning = false;
915
+ let parentWatch;
916
+ const stopParentlessWorker = () => {
917
+ if (orphaning || finished) return;
918
+ orphaning = true;
919
+ if (parentWatch) clearInterval(parentWatch);
920
+ try { child?.kill("SIGTERM"); } catch {}
921
+ // Move the bootstrap out first so cgroup.kill cannot kill the cleanup code.
922
+ moveOutOfCgroup();
923
+ setTimeout(() => finishAfterCgroup(143), 250).unref();
924
+ };
743
925
  try {
744
- const cgroup = decode(${JSON.stringify(CGROUP_BOOTSTRAP_KEYS.cgroup)});
745
- writeFileSync(cgroup + "/cgroup.procs", String(process.pid) + "\\n");
926
+ if (cgroup) writeFileSync(cgroup + "/cgroup.procs", String(process.pid) + "\\n");
746
927
  const env = { ...process.env };
747
928
  for (const key of keys) delete env[key];
748
929
  child = spawn(
749
- decode(${JSON.stringify(CGROUP_BOOTSTRAP_KEYS.command)}),
750
- JSON.parse(decode(${JSON.stringify(CGROUP_BOOTSTRAP_KEYS.args)})),
751
- { cwd: decode(${JSON.stringify(CGROUP_BOOTSTRAP_KEYS.cwd)}), env, stdio: "inherit" },
930
+ decode(${JSON.stringify(BOOTSTRAP_KEYS.command)}),
931
+ JSON.parse(decode(${JSON.stringify(BOOTSTRAP_KEYS.args)})),
932
+ { cwd: decode(${JSON.stringify(BOOTSTRAP_KEYS.cwd)}), env, stdio: "inherit" },
752
933
  );
753
934
  } catch (error) {
754
- console.error("worker cgroup bootstrap failed:", error instanceof Error ? error.message : String(error));
755
- process.exit(125);
935
+ console.error("worker bootstrap failed:", error instanceof Error ? error.message : String(error));
936
+ moveOutOfCgroup();
937
+ finishAfterCgroup(125);
756
938
  }
939
+ if (child) {
940
+ process.stderr.write("${BOOTSTRAP_READY_MARKER}\\n");
941
+ parentWatch = setInterval(() => { if (!parentAlive()) stopParentlessWorker(); }, 100);
942
+ parentWatch.unref();
757
943
  const forwardedSignals = ["SIGTERM", "SIGINT", "SIGQUIT"];
758
- for (const signal of forwardedSignals) {
759
- process.on(signal, () => {
760
- try { child.kill(signal); } catch {}
761
- });
762
- }
763
- let finished = false;
944
+ for (const signal of forwardedSignals) process.on(signal, () => { try { child.kill(signal); } catch {} });
764
945
  child.once("error", (error) => {
765
- if (finished) return;
946
+ if (finished || orphaning) return;
766
947
  finished = true;
948
+ clearInterval(parentWatch);
767
949
  console.error("worker bootstrap child failed:", error.message);
768
- process.exit(127);
950
+ moveOutOfCgroup();
951
+ finishAfterCgroup(127);
769
952
  });
770
953
  child.once("exit", (code, signal) => {
771
- if (finished) return;
954
+ if (finished || orphaning) return;
772
955
  finished = true;
773
- if (signal) {
774
- for (const forwarded of forwardedSignals) process.removeAllListeners(forwarded);
775
- try { process.kill(process.pid, signal); } catch {}
776
- setTimeout(() => process.exit(128), 50).unref();
777
- return;
778
- }
779
- process.exit(code ?? 1);
956
+ clearInterval(parentWatch);
957
+ moveOutOfCgroup();
958
+ finishAfterCgroup(code ?? 1, signal);
780
959
  });
960
+ }
781
961
  `;
782
962
 
783
- function cgroupBootstrapLaunch(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv, cgroupPath: string): { command: string; args: string[]; env: NodeJS.ProcessEnv } {
963
+ function guardedBootstrapLaunch(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv, cgroupPath: string | undefined, parentPid: number, parentStartTime: string): { command: string; args: string[]; env: NodeJS.ProcessEnv } {
784
964
  const encode = (value: string) => Buffer.from(value, "utf8").toString("base64");
785
965
  return {
786
966
  command: process.execPath,
787
- args: ["-e", CGROUP_BOOTSTRAP_SCRIPT],
967
+ args: ["-e", GUARDED_BOOTSTRAP_SCRIPT],
788
968
  env: {
789
969
  ...env,
790
- [CGROUP_BOOTSTRAP_KEYS.command]: encode(command),
791
- [CGROUP_BOOTSTRAP_KEYS.args]: encode(JSON.stringify(args)),
792
- [CGROUP_BOOTSTRAP_KEYS.cwd]: encode(cwd),
793
- [CGROUP_BOOTSTRAP_KEYS.cgroup]: encode(cgroupPath),
970
+ [BOOTSTRAP_KEYS.command]: encode(command),
971
+ [BOOTSTRAP_KEYS.args]: encode(JSON.stringify(args)),
972
+ [BOOTSTRAP_KEYS.cwd]: encode(cwd),
973
+ [BOOTSTRAP_KEYS.cgroup]: encode(cgroupPath ?? ""),
974
+ [BOOTSTRAP_KEYS.parentPid]: encode(String(parentPid)),
975
+ [BOOTSTRAP_KEYS.parentStartTime]: encode(parentStartTime),
794
976
  },
795
977
  };
796
978
  }
797
979
 
798
- function claudeJsonlArgs(args: readonly string[] = []): string[] {
980
+ export function claudeJsonlArgs(args: readonly string[] = []): string[] {
799
981
  const result = [...args];
800
982
  if (!result.includes("-p") && !result.includes("--print")) result.push("-p");
801
983
  ensureOption(result, "--input-format", "stream-json");
802
984
  ensureOption(result, "--output-format", "stream-json");
803
985
  ensureOption(result, "--permission-prompt-tool", "stdio");
986
+ ensureOption(result, "--permission-prompts", "host");
804
987
  if (!result.includes("--verbose")) result.push("--verbose");
805
988
  return result;
806
989
  }
807
990
 
808
991
  function ensureOption(args: string[], option: string, expected: string): void {
809
- const index = args.indexOf(option);
810
- if (index < 0) {
992
+ const equalPrefix = `${option}=`;
993
+ const indexes = args.flatMap((value, index) => value === option || value.startsWith(equalPrefix) ? [index] : []);
994
+ if (indexes.length > 1) throw new Error(`${option} may not be repeated in claude-jsonl mode`);
995
+ const index = indexes[0];
996
+ if (index === undefined) {
811
997
  args.push(option, expected);
812
998
  return;
813
999
  }
814
- if (args[index + 1] !== expected) throw new Error(`${option} must be ${expected} in claude-jsonl mode`);
1000
+ if (args[index] === equalPrefix + expected) {
1001
+ args.splice(index, 1, option, expected);
1002
+ return;
1003
+ }
1004
+ if (args[index] !== option || args[index + 1] !== expected) throw new Error(`${option} must be ${expected} in claude-jsonl mode`);
815
1005
  }
816
1006
 
817
1007
  async function assertWorkerCwd(cwd: string): Promise<void> {
@@ -835,7 +1025,7 @@ async function assertExecutable(command: string, pathValue: string | undefined):
835
1025
  throw new Error(`worker executable preflight failed (ENOENT): ${command}`);
836
1026
  }
837
1027
 
838
- async function currentCgroupPath(): Promise<string> {
1028
+ export async function currentCgroupPath(): Promise<string> {
839
1029
  const contents = await readFile("/proc/self/cgroup", "utf8");
840
1030
  const match = contents.match(/^0::([^\n]*)$/mu);
841
1031
  if (!match) throw new Error("cgroup v2 is not active");
@@ -844,6 +1034,71 @@ async function currentCgroupPath(): Promise<string> {
844
1034
  return `/sys/fs/cgroup${match[1]}`;
845
1035
  }
846
1036
 
1037
+ export async function preflightCgroupContainment(parentPath?: string): Promise<void> {
1038
+ if (process.platform !== "linux") throw new Error("required cgroup cleanup is unavailable on this platform");
1039
+ let probePath: string | undefined;
1040
+ let probeChild: ChildProcess | undefined;
1041
+ try {
1042
+ const parent = parentPath ?? await currentCgroupPath();
1043
+ await access(parent, fsConstants.W_OK);
1044
+ probePath = `${parent}/pi-claude-supervisor-preflight-${process.pid}-${randomUUID()}`;
1045
+ await mkdir(probePath);
1046
+ await stat(`${probePath}/cgroup.kill`);
1047
+ await stat(`${probePath}/cgroup.events`);
1048
+ const script = "const fs=require('node:fs'); fs.writeFileSync(process.env.PI_CLAUDE_SUPERVISOR_PREFLIGHT_CGROUP + '/cgroup.procs', String(process.pid) + String.fromCharCode(10)); setInterval(() => {}, 10000);";
1049
+ probeChild = spawn(process.execPath, ["-e", script], {
1050
+ env: { PATH: process.env.PATH ?? "", PI_CLAUDE_SUPERVISOR_PREFLIGHT_CGROUP: probePath },
1051
+ stdio: "ignore",
1052
+ });
1053
+ await new Promise<void>((resolve, reject) => {
1054
+ const timer = setTimeout(() => reject(new Error("preflight child did not join cgroup")), 1_000);
1055
+ const poll = async (): Promise<void> => {
1056
+ if (!probeChild || probeChild.exitCode !== null) {
1057
+ clearTimeout(timer);
1058
+ reject(new Error("preflight cgroup probe exited before joining"));
1059
+ return;
1060
+ }
1061
+ try {
1062
+ const members = await readFile(`${probePath}/cgroup.procs`, "utf8");
1063
+ if (members.split(/\s+/u).includes(String(probeChild.pid))) {
1064
+ clearTimeout(timer);
1065
+ resolve();
1066
+ return;
1067
+ }
1068
+ } catch {
1069
+ // Retry until the bounded probe deadline.
1070
+ }
1071
+ setTimeout(() => { void poll(); }, 10).unref();
1072
+ };
1073
+ void poll();
1074
+ });
1075
+ await writeFile(`${probePath}/cgroup.kill`, "1" + String.fromCharCode(10));
1076
+ const deadline = Date.now() + 1_000;
1077
+ let empty = false;
1078
+ while (Date.now() <= deadline) {
1079
+ const events = await readFile(`${probePath}/cgroup.events`, "utf8");
1080
+ if (/^populated 0$/mu.test(events)) { empty = true; break; }
1081
+ await delay(10);
1082
+ }
1083
+ if (!empty) throw new Error("preflight cgroup did not report populated 0");
1084
+ } catch (error) {
1085
+ throw new Error(`required cgroup preflight failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
1086
+ } finally {
1087
+ if (probePath) {
1088
+ try { await writeFile(`${probePath}/cgroup.kill`, "1" + String.fromCharCode(10)); } catch {}
1089
+ }
1090
+ const child = probeChild;
1091
+ if (child && child.exitCode === null) {
1092
+ try { child.kill("SIGKILL"); } catch {}
1093
+ await Promise.race([
1094
+ new Promise<void>((resolve) => child.once("close", () => resolve())),
1095
+ delay(250),
1096
+ ]);
1097
+ }
1098
+ if (probePath) await removeCgroupDirectory(probePath).catch(() => {});
1099
+ }
1100
+ }
1101
+
847
1102
  function parseProcessGroupIdentity(contents: string, pid: number): ProcessGroupIdentity {
848
1103
  const closeParen = contents.lastIndexOf(")");
849
1104
  if (closeParen < 0) throw new Error(`unable to read process identity for ${pid}`);
@@ -858,6 +1113,11 @@ function readProcessGroupIdentitySync(pid: number): ProcessGroupIdentity {
858
1113
  return parseProcessGroupIdentity(readFileSync(`/proc/${pid}/stat`, "utf8"), pid);
859
1114
  }
860
1115
 
1116
+ function readProcessStartTimeSync(pid: number): string | undefined {
1117
+ try { return parseProcessGroupIdentity(readFileSync(`/proc/${pid}/stat`, "utf8"), pid).startTime; }
1118
+ catch { return undefined; }
1119
+ }
1120
+
861
1121
  async function readProcessGroupIdentity(pid: number): Promise<ProcessGroupIdentity> {
862
1122
  return parseProcessGroupIdentity(await readFile(`/proc/${pid}/stat`, "utf8"), pid);
863
1123
  }
@@ -877,7 +1137,21 @@ async function assertProcessGroupIdentity(record: ProcessRecord, pid: number): P
877
1137
  }
878
1138
  }
879
1139
 
880
- async function cleanupCgroup(path: string, graceMs: number): Promise<void> {
1140
+ async function removeCgroupDirectory(path: string): Promise<void> {
1141
+ try {
1142
+ await rmdir(path);
1143
+ return;
1144
+ } catch (error) {
1145
+ if (error instanceof Error && /ENOENT/u.test(error.message)) return;
1146
+ }
1147
+ await rm(path, { recursive: true, force: true });
1148
+ try { await rmdir(path); }
1149
+ catch (error) {
1150
+ if (!(error instanceof Error && /ENOENT/u.test(error.message))) throw error;
1151
+ }
1152
+ }
1153
+
1154
+ export async function cleanupCgroup(path: string, graceMs: number): Promise<void> {
881
1155
  try {
882
1156
  await stat(path);
883
1157
  } catch (error) {
@@ -903,7 +1177,7 @@ async function cleanupCgroup(path: string, graceMs: number): Promise<void> {
903
1177
  try {
904
1178
  const events = await readFile(`${path}/cgroup.events`, "utf8");
905
1179
  if (/^populated 0$/mu.test(events)) {
906
- await rm(path, { recursive: true, force: true });
1180
+ await removeCgroupDirectory(path);
907
1181
  return;
908
1182
  }
909
1183
  } catch (error) {
@@ -954,3 +1228,31 @@ function boundedPositiveInteger(value: number, name: string): number {
954
1228
  if (!Number.isSafeInteger(value) || value < 1) throw new Error(`${name} must be a positive integer`);
955
1229
  return value;
956
1230
  }
1231
+
1232
+ async function processGroupHasLiveMember(pgid: number): Promise<boolean> {
1233
+ if (process.platform !== "linux") {
1234
+ try { process.kill(-pgid, 0); return true; }
1235
+ catch (error) {
1236
+ if (error instanceof Error && /ESRCH/u.test(error.message)) return false;
1237
+ throw error;
1238
+ }
1239
+ }
1240
+ let names: string[];
1241
+ try { names = await readdir("/proc"); }
1242
+ catch { return true; }
1243
+ for (const name of names) {
1244
+ if (!/^\\d+$/u.test(name)) continue;
1245
+ try {
1246
+ const statText = await readFile(`/proc/${name}/stat`, "utf8");
1247
+ const closeParen = statText.lastIndexOf(")");
1248
+ if (closeParen < 0) continue;
1249
+ const fields = statText.slice(closeParen + 2).trim().split(/\\s+/u);
1250
+ const state = fields[0];
1251
+ const processGroup = Number(fields[2]);
1252
+ if (processGroup === pgid && state !== "Z") return true;
1253
+ } catch {
1254
+ // A process can disappear between /proc enumeration and stat read.
1255
+ }
1256
+ }
1257
+ return false;
1258
+ }