pi-claude-supervisor 0.5.3 → 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,23 +158,30 @@ 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 };
175
186
  try {
176
187
  // This is the last asynchronous operation before spawn. Automatic mode
@@ -178,15 +189,17 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
178
189
  // setup work must invoke the hook only after that work is complete.
179
190
  await input.preSpawnCheck?.();
180
191
  } catch (error) {
181
- if (cgroupPath) await rm(cgroupPath, { recursive: true, force: true }).catch(() => {});
192
+ if (cgroupPath) await removeCgroupDirectory(cgroupPath).catch(() => {});
182
193
  throw error;
183
194
  }
184
195
  const finalAbortReason = input.startupToken ? this.#pendingStartupAborts.get(input.startupToken) : undefined;
185
196
  if (input.startupToken) this.#pendingStartupAborts.delete(input.startupToken);
186
197
  if (input.abortSignal?.aborted || finalAbortReason !== undefined) {
187
- if (cgroupPath) await rm(cgroupPath, { recursive: true, force: true }).catch(() => {});
198
+ if (cgroupPath) await removeCgroupDirectory(cgroupPath).catch(() => {});
188
199
  throw new Error(`worker startup aborted${finalAbortReason ? `: ${finalAbortReason}` : " before spawn"}`);
189
200
  }
201
+ const guardedLaunch = useGuardedBootstrap;
202
+ let bootstrapReady = !guardedLaunch;
190
203
  let child: ChildProcess;
191
204
  try {
192
205
  child = spawn(launch.command, launch.args, {
@@ -196,7 +209,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
196
209
  stdio: ["pipe", "pipe", "pipe"],
197
210
  });
198
211
  } catch (error) {
199
- if (cgroupPath) await rm(cgroupPath, { recursive: true, force: true }).catch(() => {});
212
+ if (cgroupPath) await removeCgroupDirectory(cgroupPath).catch(() => {});
200
213
  throw error;
201
214
  }
202
215
  handle.pid = child.pid;
@@ -233,6 +246,9 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
233
246
  inputTail: Promise.resolve(),
234
247
  listeners: new Set(input.eventListener ? [input.eventListener] : []),
235
248
  permissionResponses: new Set(),
249
+ enforceNestedClaude: Boolean(input.automatic),
250
+ expectedClaudeCommand: input.automatic ? input.command : undefined,
251
+ trustedClaudeProcesses: new Map(),
236
252
  starting: true,
237
253
  abortRequested: false,
238
254
  startupToken: input.startupToken,
@@ -249,7 +265,13 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
249
265
  this.#records.set(handle.id, record);
250
266
  if (input.abortSignal?.aborted) abortListener();
251
267
  const capture = (stream: "stdout" | "stderr") => (chunk: Buffer | string) => {
252
- 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;
253
275
  record.lastOutputAt = new Date().toISOString();
254
276
  const outputChunk = { stream, text, at: record.lastOutputAt } as WorkerOutputChunk;
255
277
  this.#appendOutput(record, outputChunk);
@@ -271,7 +293,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
271
293
  record.processGroupIdentityError = error instanceof Error ? error : new Error(String(error));
272
294
  }
273
295
  }
274
- resolveSpawn();
296
+ if (!guardedLaunch) resolveSpawn();
275
297
  });
276
298
  child.once("error", (error) => {
277
299
  if (!record.spawnedSuccessfully) {
@@ -287,6 +309,11 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
287
309
  }
288
310
  });
289
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
+ }
290
317
  if (!record.spawnError) {
291
318
  record.exitCode = code;
292
319
  record.signal = signal ?? undefined;
@@ -316,6 +343,23 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
316
343
  }
317
344
  throw startupError;
318
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
+ }
319
363
  if (input.task) {
320
364
  this.#assertNotAborted(record);
321
365
  record.lastInputAt = new Date().toISOString();
@@ -366,6 +410,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
366
410
  async getStatus(handle: WorkerHandle): Promise<WorkerStatus> {
367
411
  const record = this.#record(handle);
368
412
  const running = record.exitCode === undefined;
413
+ if (running && record.enforceNestedClaude) await this.#checkNestedClaude(record);
369
414
  if (!running && record.groupCleanup) {
370
415
  try {
371
416
  await record.groupCleanup;
@@ -381,10 +426,10 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
381
426
  lastOutputAt: record.lastOutputAt,
382
427
  lastInputAt: record.lastInputAt,
383
428
  activeRequests: this.#mode === "claude-jsonl" ? record.activeRequests : undefined,
384
- 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",
385
430
  processGroupCleaned: record.groupCleanupComplete,
386
431
  cgroupCleaned: record.cgroupPath ? record.groupCleanupComplete : undefined,
387
- cgroupRequired: this.#cgroupMode === "required",
432
+ cgroupRequired: this.#cgroupMode === "required" || record.enforceNestedClaude,
388
433
  cgroupError: record.cgroupError?.message,
389
434
  cleanupError: record.cleanupError?.message,
390
435
  runtimeError: record.runtimeError?.message,
@@ -650,32 +695,82 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
650
695
  }
651
696
 
652
697
  async #preflightRequiredCgroup(): Promise<void> {
653
- if (process.platform !== "linux") throw new Error("required cgroup cleanup is unavailable on this platform");
654
- let probePath: string | undefined;
655
- try {
656
- const parent = this.#cgroupParentPath ?? await currentCgroupPath();
657
- await access(parent, fsConstants.W_OK);
658
- probePath = `${parent}/pi-claude-supervisor-preflight-${process.pid}-${randomUUID()}`;
659
- await mkdir(probePath);
660
- await stat(`${probePath}/cgroup.kill`);
661
- await stat(`${probePath}/cgroup.events`);
662
- await writeFile(`${probePath}/cgroup.kill`, "1\n");
663
- const events = await readFile(`${probePath}/cgroup.events`, "utf8");
664
- if (!/^populated 0$/mu.test(events)) throw new Error("preflight cgroup did not report populated 0");
665
- } catch (error) {
666
- throw new Error(`required cgroup preflight failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
667
- } finally {
668
- if (probePath) await rm(probePath, { recursive: true, force: true }).catch(() => {});
669
- }
698
+ await preflightCgroupContainment(this.#cgroupParentPath);
670
699
  }
671
700
 
672
701
  async #createCgroup(id: string): Promise<string> {
673
702
  const parent = this.#cgroupParentPath ?? await currentCgroupPath();
674
703
  const path = `${parent}/pi-claude-supervisor-${id}`;
675
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
+ }
676
713
  return path;
677
714
  }
678
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
+
679
774
  #record(handle: WorkerHandle): ProcessRecord {
680
775
  const record = this.#records.get(handle.id);
681
776
  if (!record) throw new Error(`unknown worker handle: ${handle.id}`);
@@ -724,12 +819,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
724
819
  }
725
820
  const deadline = Date.now() + this.#killGraceMs;
726
821
  while (Date.now() <= deadline) {
727
- try {
728
- process.kill(-pid, 0);
729
- } catch (error) {
730
- if (error instanceof Error && /ESRCH/u.test(error.message)) return;
731
- throw error;
732
- }
822
+ if (!(await processGroupHasLiveMember(pid))) return;
733
823
  await delay(Math.min(10, Math.max(1, deadline - Date.now())));
734
824
  }
735
825
  throw new Error(`worker process group ${pid} did not exit before cleanup deadline`);
@@ -742,91 +832,176 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
742
832
  }
743
833
  }
744
834
 
745
- const CGROUP_BOOTSTRAP_KEYS = {
835
+ const BOOTSTRAP_READY_MARKER = "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_READY";
836
+
837
+ const BOOTSTRAP_KEYS = {
746
838
  command: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_COMMAND",
747
839
  args: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_ARGS",
748
840
  cwd: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_CWD",
749
841
  cgroup: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_CGROUP",
842
+ parentPid: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_PARENT_PID",
843
+ parentStartTime: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_PARENT_START",
750
844
  } as const;
751
845
 
752
- 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 = `
753
854
  const { spawn } = require("node:child_process");
754
- const { writeFileSync } = require("node:fs");
755
- 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))};
756
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
+ };
757
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
+ };
758
925
  try {
759
- const cgroup = decode(${JSON.stringify(CGROUP_BOOTSTRAP_KEYS.cgroup)});
760
- writeFileSync(cgroup + "/cgroup.procs", String(process.pid) + "\\n");
926
+ if (cgroup) writeFileSync(cgroup + "/cgroup.procs", String(process.pid) + "\\n");
761
927
  const env = { ...process.env };
762
928
  for (const key of keys) delete env[key];
763
929
  child = spawn(
764
- decode(${JSON.stringify(CGROUP_BOOTSTRAP_KEYS.command)}),
765
- JSON.parse(decode(${JSON.stringify(CGROUP_BOOTSTRAP_KEYS.args)})),
766
- { 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" },
767
933
  );
768
934
  } catch (error) {
769
- console.error("worker cgroup bootstrap failed:", error instanceof Error ? error.message : String(error));
770
- process.exit(125);
935
+ console.error("worker bootstrap failed:", error instanceof Error ? error.message : String(error));
936
+ moveOutOfCgroup();
937
+ finishAfterCgroup(125);
771
938
  }
939
+ if (child) {
940
+ process.stderr.write("${BOOTSTRAP_READY_MARKER}\\n");
941
+ parentWatch = setInterval(() => { if (!parentAlive()) stopParentlessWorker(); }, 100);
942
+ parentWatch.unref();
772
943
  const forwardedSignals = ["SIGTERM", "SIGINT", "SIGQUIT"];
773
- for (const signal of forwardedSignals) {
774
- process.on(signal, () => {
775
- try { child.kill(signal); } catch {}
776
- });
777
- }
778
- let finished = false;
944
+ for (const signal of forwardedSignals) process.on(signal, () => { try { child.kill(signal); } catch {} });
779
945
  child.once("error", (error) => {
780
- if (finished) return;
946
+ if (finished || orphaning) return;
781
947
  finished = true;
948
+ clearInterval(parentWatch);
782
949
  console.error("worker bootstrap child failed:", error.message);
783
- process.exit(127);
950
+ moveOutOfCgroup();
951
+ finishAfterCgroup(127);
784
952
  });
785
953
  child.once("exit", (code, signal) => {
786
- if (finished) return;
954
+ if (finished || orphaning) return;
787
955
  finished = true;
788
- if (signal) {
789
- for (const forwarded of forwardedSignals) process.removeAllListeners(forwarded);
790
- try { process.kill(process.pid, signal); } catch {}
791
- setTimeout(() => process.exit(128), 50).unref();
792
- return;
793
- }
794
- process.exit(code ?? 1);
956
+ clearInterval(parentWatch);
957
+ moveOutOfCgroup();
958
+ finishAfterCgroup(code ?? 1, signal);
795
959
  });
960
+ }
796
961
  `;
797
962
 
798
- 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 } {
799
964
  const encode = (value: string) => Buffer.from(value, "utf8").toString("base64");
800
965
  return {
801
966
  command: process.execPath,
802
- args: ["-e", CGROUP_BOOTSTRAP_SCRIPT],
967
+ args: ["-e", GUARDED_BOOTSTRAP_SCRIPT],
803
968
  env: {
804
969
  ...env,
805
- [CGROUP_BOOTSTRAP_KEYS.command]: encode(command),
806
- [CGROUP_BOOTSTRAP_KEYS.args]: encode(JSON.stringify(args)),
807
- [CGROUP_BOOTSTRAP_KEYS.cwd]: encode(cwd),
808
- [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),
809
976
  },
810
977
  };
811
978
  }
812
979
 
813
- function claudeJsonlArgs(args: readonly string[] = []): string[] {
980
+ export function claudeJsonlArgs(args: readonly string[] = []): string[] {
814
981
  const result = [...args];
815
982
  if (!result.includes("-p") && !result.includes("--print")) result.push("-p");
816
983
  ensureOption(result, "--input-format", "stream-json");
817
984
  ensureOption(result, "--output-format", "stream-json");
818
985
  ensureOption(result, "--permission-prompt-tool", "stdio");
986
+ ensureOption(result, "--permission-prompts", "host");
819
987
  if (!result.includes("--verbose")) result.push("--verbose");
820
988
  return result;
821
989
  }
822
990
 
823
991
  function ensureOption(args: string[], option: string, expected: string): void {
824
- const index = args.indexOf(option);
825
- 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) {
826
997
  args.push(option, expected);
827
998
  return;
828
999
  }
829
- 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`);
830
1005
  }
831
1006
 
832
1007
  async function assertWorkerCwd(cwd: string): Promise<void> {
@@ -850,7 +1025,7 @@ async function assertExecutable(command: string, pathValue: string | undefined):
850
1025
  throw new Error(`worker executable preflight failed (ENOENT): ${command}`);
851
1026
  }
852
1027
 
853
- async function currentCgroupPath(): Promise<string> {
1028
+ export async function currentCgroupPath(): Promise<string> {
854
1029
  const contents = await readFile("/proc/self/cgroup", "utf8");
855
1030
  const match = contents.match(/^0::([^\n]*)$/mu);
856
1031
  if (!match) throw new Error("cgroup v2 is not active");
@@ -859,6 +1034,71 @@ async function currentCgroupPath(): Promise<string> {
859
1034
  return `/sys/fs/cgroup${match[1]}`;
860
1035
  }
861
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
+
862
1102
  function parseProcessGroupIdentity(contents: string, pid: number): ProcessGroupIdentity {
863
1103
  const closeParen = contents.lastIndexOf(")");
864
1104
  if (closeParen < 0) throw new Error(`unable to read process identity for ${pid}`);
@@ -873,6 +1113,11 @@ function readProcessGroupIdentitySync(pid: number): ProcessGroupIdentity {
873
1113
  return parseProcessGroupIdentity(readFileSync(`/proc/${pid}/stat`, "utf8"), pid);
874
1114
  }
875
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
+
876
1121
  async function readProcessGroupIdentity(pid: number): Promise<ProcessGroupIdentity> {
877
1122
  return parseProcessGroupIdentity(await readFile(`/proc/${pid}/stat`, "utf8"), pid);
878
1123
  }
@@ -892,7 +1137,21 @@ async function assertProcessGroupIdentity(record: ProcessRecord, pid: number): P
892
1137
  }
893
1138
  }
894
1139
 
895
- 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> {
896
1155
  try {
897
1156
  await stat(path);
898
1157
  } catch (error) {
@@ -918,7 +1177,7 @@ async function cleanupCgroup(path: string, graceMs: number): Promise<void> {
918
1177
  try {
919
1178
  const events = await readFile(`${path}/cgroup.events`, "utf8");
920
1179
  if (/^populated 0$/mu.test(events)) {
921
- await rm(path, { recursive: true, force: true });
1180
+ await removeCgroupDirectory(path);
922
1181
  return;
923
1182
  }
924
1183
  } catch (error) {
@@ -969,3 +1228,31 @@ function boundedPositiveInteger(value: number, name: string): number {
969
1228
  if (!Number.isSafeInteger(value) || value < 1) throw new Error(`${name} must be a positive integer`);
970
1229
  return value;
971
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
+ }