pi-claude-supervisor 0.5.0 → 0.5.2

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,6 +1,8 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { spawn, type ChildProcess } from "node:child_process";
3
- import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { constants as fsConstants, readFileSync } from "node:fs";
4
+ import { access, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
5
+ import { delimiter, isAbsolute, join } from "node:path";
4
6
  import type {
5
7
  WorkerAdapter,
6
8
  WorkerCapabilities,
@@ -35,6 +37,12 @@ export interface ProcessWorkerAdapterOptions {
35
37
  cgroupParentPath?: string;
36
38
  }
37
39
 
40
+ interface ProcessGroupIdentity {
41
+ pid: number;
42
+ pgid: number;
43
+ startTime: string;
44
+ }
45
+
38
46
  interface ProcessRecord {
39
47
  child: ChildProcess;
40
48
  handle: WorkerHandle;
@@ -60,6 +68,8 @@ interface ProcessRecord {
60
68
  cleanupError?: Error;
61
69
  cgroupPath?: string;
62
70
  cgroupError?: Error;
71
+ processGroupIdentity?: ProcessGroupIdentity;
72
+ processGroupIdentityError?: Error;
63
73
  runtimeError?: Error;
64
74
  spawned: Promise<void>;
65
75
  spawnedSuccessfully: boolean;
@@ -85,6 +95,7 @@ interface ProcessRecord {
85
95
  */
86
96
  export class ProcessWorkerAdapter implements WorkerAdapter {
87
97
  readonly #records = new Map<string, ProcessRecord>();
98
+ readonly #pendingStartupAborts = new Map<string, string>();
88
99
  readonly #mode: "process-pipe" | "claude-jsonl";
89
100
  readonly #terminationGraceMs: number;
90
101
  readonly #killGraceMs: number;
@@ -114,24 +125,65 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
114
125
  pause: true,
115
126
  resumeSession: false,
116
127
  processGroupControl: true,
128
+ repairableSession: this.#mode === "claude-jsonl",
117
129
  };
118
130
  }
119
131
 
132
+ async preflight(input: Pick<WorkerStartInput, "cwd" | "command" | "args" | "env" | "approval">): Promise<void> {
133
+ const args = this.#mode === "claude-jsonl" ? claudeJsonlArgs(input.args) : (input.args ?? []);
134
+ assertSafeWorkerCommand(input.command, args, input.approval);
135
+ await assertWorkerCwd(input.cwd);
136
+ await assertExecutable(input.command, input.env?.PATH ?? process.env.PATH);
137
+ if (this.#cgroupMode === "required") await this.#preflightRequiredCgroup();
138
+ }
139
+
120
140
  async start(input: WorkerStartInput): Promise<WorkerHandle> {
121
141
  if (input.abortSignal?.aborted) throw new Error("worker startup aborted before spawn");
122
142
  const args = this.#mode === "claude-jsonl" ? claudeJsonlArgs(input.args) : (input.args ?? []);
123
143
  assertSafeWorkerCommand(input.command, args, input.approval);
144
+ if (this.#cgroupMode === "required") await this.preflight(input);
124
145
  const handle: WorkerHandle = {
125
146
  id: randomUUID(),
126
147
  startedAt: new Date().toISOString(),
127
148
  cwd: input.cwd,
128
149
  };
129
- const child = spawn(input.command, args, {
130
- cwd: input.cwd,
131
- env: workerEnvironment(process.env, input.env),
132
- detached: true,
133
- stdio: ["pipe", "pipe", "pipe"],
134
- });
150
+ const workerEnv = workerEnvironment(process.env, input.env);
151
+ await assertExecutable(input.command, workerEnv.PATH);
152
+ const pendingAbortReason = input.startupToken ? this.#pendingStartupAborts.get(input.startupToken) : undefined;
153
+ if (input.startupToken) this.#pendingStartupAborts.delete(input.startupToken);
154
+ if (pendingAbortReason !== undefined) throw new Error(`worker startup aborted: ${pendingAbortReason}`);
155
+ let cgroupPath: string | undefined;
156
+ let cgroupError: Error | undefined;
157
+ if (this.#cgroupMode !== "off" && process.platform === "linux") {
158
+ try {
159
+ cgroupPath = await this.#createCgroup(handle.id);
160
+ handle.cgroupPath = cgroupPath;
161
+ } catch (error) {
162
+ 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 });
164
+ }
165
+ }
166
+ const delayedAbortReason = input.startupToken ? this.#pendingStartupAborts.get(input.startupToken) : undefined;
167
+ if (input.startupToken) this.#pendingStartupAborts.delete(input.startupToken);
168
+ if (input.abortSignal?.aborted || delayedAbortReason !== undefined) {
169
+ if (cgroupPath) await rm(cgroupPath, { recursive: true, force: true }).catch(() => {});
170
+ throw new Error(`worker startup aborted${delayedAbortReason ? `: ${delayedAbortReason}` : " before spawn"}`);
171
+ }
172
+ const launch = cgroupPath
173
+ ? cgroupBootstrapLaunch(input.command, args, input.cwd, workerEnv, cgroupPath)
174
+ : { command: input.command, args, env: workerEnv };
175
+ let child: ChildProcess;
176
+ try {
177
+ child = spawn(launch.command, launch.args, {
178
+ cwd: input.cwd,
179
+ env: launch.env,
180
+ detached: true,
181
+ stdio: ["pipe", "pipe", "pipe"],
182
+ });
183
+ } catch (error) {
184
+ if (cgroupPath) await rm(cgroupPath, { recursive: true, force: true }).catch(() => {});
185
+ throw error;
186
+ }
135
187
  handle.pid = child.pid;
136
188
  let resolveSpawn!: () => void;
137
189
  let rejectSpawn!: (error: Error) => void;
@@ -159,6 +211,8 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
159
211
  resolveExit,
160
212
  sentKeys: new Set(),
161
213
  groupCleanupComplete: child.pid === undefined,
214
+ cgroupPath,
215
+ cgroupError,
162
216
  spawned,
163
217
  spawnedSuccessfully: false,
164
218
  inputTail: Promise.resolve(),
@@ -195,6 +249,13 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
195
249
  });
196
250
  child.once("spawn", () => {
197
251
  record.spawnedSuccessfully = true;
252
+ if (child.pid !== undefined && process.platform === "linux") {
253
+ try {
254
+ record.processGroupIdentity = readProcessGroupIdentitySync(child.pid);
255
+ } catch (error) {
256
+ record.processGroupIdentityError = error instanceof Error ? error : new Error(String(error));
257
+ }
258
+ }
198
259
  resolveSpawn();
199
260
  });
200
261
  child.once("error", (error) => {
@@ -226,8 +287,6 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
226
287
  try {
227
288
  await record.spawned;
228
289
  this.#assertNotAborted(record);
229
- await this.#attachCgroup(record);
230
- this.#assertNotAborted(record);
231
290
  } catch (error) {
232
291
  try {
233
292
  await this.#ensureGroupCleanup(record);
@@ -271,6 +330,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
271
330
 
272
331
  async abortStart(reason = "startup aborted", startupToken?: string): Promise<void> {
273
332
  const records = [...this.#records.values()].filter((record) => record.starting && (startupToken === undefined || record.startupToken === startupToken));
333
+ if (startupToken !== undefined && records.length === 0) this.#pendingStartupAborts.set(startupToken, reason);
274
334
  for (const record of records) {
275
335
  record.abortRequested = true;
276
336
  record.stopping = true;
@@ -306,7 +366,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
306
366
  lastOutputAt: record.lastOutputAt,
307
367
  lastInputAt: record.lastInputAt,
308
368
  activeRequests: this.#mode === "claude-jsonl" ? record.activeRequests : undefined,
309
- exitReason: running ? undefined : record.signal ? "crashed" : record.exitCode === 0 ? "completed" : "failed",
369
+ exitReason: running ? undefined : record.stopping ? "stopped" : record.signal ? "crashed" : record.exitCode === 0 ? "completed" : "failed",
310
370
  processGroupCleaned: record.groupCleanupComplete,
311
371
  cgroupCleaned: record.cgroupPath ? record.groupCleanupComplete : undefined,
312
372
  cgroupRequired: this.#cgroupMode === "required",
@@ -574,37 +634,33 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
574
634
  }
575
635
  }
576
636
 
577
- async #attachCgroup(record: ProcessRecord): Promise<void> {
578
- if (this.#cgroupMode === "off") return;
579
- if (!record.handle.pid) {
580
- const error = new Error("worker pid was unavailable for cgroup attachment");
581
- record.cgroupError = error;
582
- if (this.#cgroupMode === "required") throw new Error(`unable to attach worker to a cgroup: ${error.message}`, { cause: error });
583
- throw error;
584
- }
585
- if (process.platform !== "linux") {
586
- const error = new Error("cgroups are unavailable on this platform");
587
- record.cgroupError = error;
588
- if (this.#cgroupMode === "required") throw new Error(`unable to attach worker to a cgroup: ${error.message}`, { cause: error });
589
- return;
590
- }
591
- let path: string | undefined;
637
+ 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;
592
640
  try {
593
641
  const parent = this.#cgroupParentPath ?? await currentCgroupPath();
594
- path = `${parent}/pi-claude-supervisor-${record.handle.id}`;
595
- await mkdir(path);
596
- await writeFile(`${path}/cgroup.procs`, `${record.handle.pid}\n`);
597
- record.cgroupPath = path;
598
- record.handle.cgroupPath = path;
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");
599
650
  } catch (error) {
600
- if (path) await rm(path, { recursive: true, force: true }).catch(() => {});
601
- record.cgroupError = error instanceof Error ? error : new Error(String(error));
602
- if (this.#cgroupMode === "required") {
603
- throw new Error(`unable to attach worker to a cgroup: ${record.cgroupError.message}`, { cause: record.cgroupError });
604
- }
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(() => {});
605
654
  }
606
655
  }
607
656
 
657
+ async #createCgroup(id: string): Promise<string> {
658
+ const parent = this.#cgroupParentPath ?? await currentCgroupPath();
659
+ const path = `${parent}/pi-claude-supervisor-${id}`;
660
+ await mkdir(path);
661
+ return path;
662
+ }
663
+
608
664
  #record(handle: WorkerHandle): ProcessRecord {
609
665
  const record = this.#records.get(handle.id);
610
666
  if (!record) throw new Error(`unknown worker handle: ${handle.id}`);
@@ -625,21 +681,30 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
625
681
  }
626
682
 
627
683
  async #cleanupProcessGroup(record: ProcessRecord): Promise<void> {
684
+ const errors: Error[] = [];
628
685
  if (record.cgroupPath) {
629
- await cleanupCgroup(record.cgroupPath, this.#killGraceMs);
630
- record.groupCleanupComplete = true;
631
- return;
686
+ try { await cleanupCgroup(record.cgroupPath, this.#killGraceMs); }
687
+ catch (error) { errors.push(error instanceof Error ? error : new Error(String(error))); }
632
688
  }
689
+ try { await this.#cleanupProcessGroupOnly(record); }
690
+ catch (error) { errors.push(error instanceof Error ? error : new Error(String(error))); }
691
+ if (errors.length === 1) throw errors[0];
692
+ if (errors.length > 1) throw new AggregateError(errors, "worker descendant cleanup failed");
693
+ record.groupCleanupComplete = true;
694
+ }
695
+
696
+ async #cleanupProcessGroupOnly(record: ProcessRecord): Promise<void> {
633
697
  const pid = record.handle.pid;
634
- if (!pid) {
635
- record.groupCleanupComplete = true;
636
- return;
698
+ if (!pid) return;
699
+ if (process.platform === "linux") {
700
+ await assertProcessGroupIdentity(record, pid);
637
701
  }
638
702
  try {
703
+ // detached:true binds this worker's process group to its handle PID;
704
+ // this second boundary catches descendants spawned before cgroup attach.
639
705
  process.kill(-pid, "SIGKILL");
640
706
  } catch (error) {
641
707
  if (!(error instanceof Error) || !/ESRCH/u.test(error.message)) throw error;
642
- record.groupCleanupComplete = true;
643
708
  return;
644
709
  }
645
710
  const deadline = Date.now() + this.#killGraceMs;
@@ -647,10 +712,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
647
712
  try {
648
713
  process.kill(-pid, 0);
649
714
  } catch (error) {
650
- if (error instanceof Error && /ESRCH/u.test(error.message)) {
651
- record.groupCleanupComplete = true;
652
- return;
653
- }
715
+ if (error instanceof Error && /ESRCH/u.test(error.message)) return;
654
716
  throw error;
655
717
  }
656
718
  await delay(Math.min(10, Math.max(1, deadline - Date.now())));
@@ -665,6 +727,74 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
665
727
  }
666
728
  }
667
729
 
730
+ const CGROUP_BOOTSTRAP_KEYS = {
731
+ command: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_COMMAND",
732
+ args: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_ARGS",
733
+ cwd: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_CWD",
734
+ cgroup: "PI_CLAUDE_SUPERVISOR_BOOTSTRAP_CGROUP",
735
+ } as const;
736
+
737
+ const CGROUP_BOOTSTRAP_SCRIPT = `
738
+ const { spawn } = require("node:child_process");
739
+ const { writeFileSync } = require("node:fs");
740
+ const keys = ${JSON.stringify(Object.values(CGROUP_BOOTSTRAP_KEYS))};
741
+ const decode = (key) => Buffer.from(process.env[key] || "", "base64").toString("utf8");
742
+ let child;
743
+ try {
744
+ const cgroup = decode(${JSON.stringify(CGROUP_BOOTSTRAP_KEYS.cgroup)});
745
+ writeFileSync(cgroup + "/cgroup.procs", String(process.pid) + "\\n");
746
+ const env = { ...process.env };
747
+ for (const key of keys) delete env[key];
748
+ 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" },
752
+ );
753
+ } catch (error) {
754
+ console.error("worker cgroup bootstrap failed:", error instanceof Error ? error.message : String(error));
755
+ process.exit(125);
756
+ }
757
+ 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;
764
+ child.once("error", (error) => {
765
+ if (finished) return;
766
+ finished = true;
767
+ console.error("worker bootstrap child failed:", error.message);
768
+ process.exit(127);
769
+ });
770
+ child.once("exit", (code, signal) => {
771
+ if (finished) return;
772
+ 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);
780
+ });
781
+ `;
782
+
783
+ function cgroupBootstrapLaunch(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv, cgroupPath: string): { command: string; args: string[]; env: NodeJS.ProcessEnv } {
784
+ const encode = (value: string) => Buffer.from(value, "utf8").toString("base64");
785
+ return {
786
+ command: process.execPath,
787
+ args: ["-e", CGROUP_BOOTSTRAP_SCRIPT],
788
+ env: {
789
+ ...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),
794
+ },
795
+ };
796
+ }
797
+
668
798
  function claudeJsonlArgs(args: readonly string[] = []): string[] {
669
799
  const result = [...args];
670
800
  if (!result.includes("-p") && !result.includes("--print")) result.push("-p");
@@ -684,6 +814,27 @@ function ensureOption(args: string[], option: string, expected: string): void {
684
814
  if (args[index + 1] !== expected) throw new Error(`${option} must be ${expected} in claude-jsonl mode`);
685
815
  }
686
816
 
817
+ async function assertWorkerCwd(cwd: string): Promise<void> {
818
+ const info = await stat(cwd);
819
+ if (!info.isDirectory()) throw new Error(`Worker cwd is not a directory: ${cwd}`);
820
+ await access(cwd, fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK);
821
+ }
822
+
823
+ async function assertExecutable(command: string, pathValue: string | undefined): Promise<void> {
824
+ const candidates = isAbsolute(command) || command.includes("/") || command.includes("\\")
825
+ ? [command]
826
+ : (pathValue ?? "").split(delimiter).filter(Boolean).map((directory) => join(directory, command));
827
+ for (const candidate of candidates) {
828
+ try {
829
+ await access(candidate, fsConstants.X_OK);
830
+ return;
831
+ } catch {
832
+ // Try the next PATH entry.
833
+ }
834
+ }
835
+ throw new Error(`worker executable preflight failed (ENOENT): ${command}`);
836
+ }
837
+
687
838
  async function currentCgroupPath(): Promise<string> {
688
839
  const contents = await readFile("/proc/self/cgroup", "utf8");
689
840
  const match = contents.match(/^0::([^\n]*)$/mu);
@@ -693,13 +844,60 @@ async function currentCgroupPath(): Promise<string> {
693
844
  return `/sys/fs/cgroup${match[1]}`;
694
845
  }
695
846
 
847
+ function parseProcessGroupIdentity(contents: string, pid: number): ProcessGroupIdentity {
848
+ const closeParen = contents.lastIndexOf(")");
849
+ if (closeParen < 0) throw new Error(`unable to read process identity for ${pid}`);
850
+ const fields = contents.slice(closeParen + 2).trim().split(/\s+/u);
851
+ const pgid = Number(fields[2]);
852
+ const startTime = fields[19];
853
+ if (!Number.isSafeInteger(pgid) || !startTime) throw new Error(`invalid process identity for ${pid}`);
854
+ return { pid, pgid, startTime };
855
+ }
856
+
857
+ function readProcessGroupIdentitySync(pid: number): ProcessGroupIdentity {
858
+ return parseProcessGroupIdentity(readFileSync(`/proc/${pid}/stat`, "utf8"), pid);
859
+ }
860
+
861
+ async function readProcessGroupIdentity(pid: number): Promise<ProcessGroupIdentity> {
862
+ return parseProcessGroupIdentity(await readFile(`/proc/${pid}/stat`, "utf8"), pid);
863
+ }
864
+
865
+ async function assertProcessGroupIdentity(record: ProcessRecord, pid: number): Promise<void> {
866
+ const identity = record.processGroupIdentity;
867
+ if (record.processGroupIdentityError) throw new Error(`worker process-group identity unavailable: ${record.processGroupIdentityError.message}`);
868
+ if (!identity || identity.pid !== pid || identity.pgid !== pid) throw new Error(`worker process-group identity was not established for ${pid}`);
869
+ try {
870
+ const current = await readProcessGroupIdentity(pid);
871
+ if (current.startTime !== identity.startTime || current.pgid !== identity.pgid) {
872
+ throw new Error(`worker process-group identity changed for ${pid}`);
873
+ }
874
+ } catch (error) {
875
+ if (error instanceof Error && /ENOENT/u.test(error.message)) return;
876
+ throw error;
877
+ }
878
+ }
879
+
696
880
  async function cleanupCgroup(path: string, graceMs: number): Promise<void> {
697
881
  try {
698
- await writeFile(`${path}/cgroup.kill`, "1\n");
882
+ await stat(path);
699
883
  } catch (error) {
700
884
  if (error instanceof Error && /ENOENT/u.test(error.message)) return;
701
885
  throw error;
702
886
  }
887
+ try {
888
+ await writeFile(`${path}/cgroup.kill`, "1\n");
889
+ } catch (error) {
890
+ if (error instanceof Error && /ENOENT/u.test(error.message)) {
891
+ // ENOENT is success only when the cgroup directory itself disappeared.
892
+ try { await stat(path); }
893
+ catch (directoryError) {
894
+ if (directoryError instanceof Error && /ENOENT/u.test(directoryError.message)) return;
895
+ throw directoryError;
896
+ }
897
+ throw new Error(`cgroup.kill is missing from live cgroup ${path}`);
898
+ }
899
+ throw error;
900
+ }
703
901
  const deadline = Date.now() + graceMs;
704
902
  while (Date.now() <= deadline) {
705
903
  try {
@@ -1,8 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
- import { mkdir, open, readFile, rm, stat, truncate, writeFile } from "node:fs/promises";
3
+ import { constants as fsConstants } from "node:fs";
4
+ import { access, chmod, lstat, mkdir, open, readFile, rm, stat, truncate, writeFile } from "node:fs/promises";
4
5
  import { tmpdir } from "node:os";
5
- import { join } from "node:path";
6
+ import { delimiter, isAbsolute, join } from "node:path";
6
7
  import type {
7
8
  WorkerAdapter,
8
9
  WorkerCapabilities,
@@ -108,6 +109,17 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
108
109
  this.#terminationGraceMs = boundedDelay(options.terminationGraceMs ?? 2_000);
109
110
  }
110
111
 
112
+ async preflight(input: Pick<WorkerStartInput, "cwd" | "command" | "args" | "env" | "approval">): Promise<void> {
113
+ assertSafeWorkerCommand(input.command, input.args ?? [], input.approval);
114
+ await assertDirectory(input.cwd);
115
+ await assertExecutableAvailable(input.command, input.env?.PATH ?? process.env.PATH);
116
+ await assertExecutableAvailable(this.#tmuxBinary, process.env.PATH);
117
+ await mkdir(this.#stateDir, { recursive: true, mode: 0o700 });
118
+ const stateInfo = await lstat(this.#stateDir);
119
+ if (!stateInfo.isDirectory() || stateInfo.isSymbolicLink()) throw new Error(`tmux state directory is not a real directory: ${this.#stateDir}`);
120
+ await chmod(this.#stateDir, 0o700);
121
+ }
122
+
111
123
  capabilities(): WorkerCapabilities {
112
124
  return {
113
125
  transport: "tmux",
@@ -116,6 +128,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
116
128
  resumeSession: false,
117
129
  processGroupControl: false,
118
130
  persistentSession: true,
131
+ repairableSession: true,
119
132
  };
120
133
  }
121
134
 
@@ -272,8 +285,12 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
272
285
  record.cleanupError = undefined;
273
286
  } else if (isPaneIdentityError(error)) {
274
287
  record.paneDead = false;
288
+ record.cleanupComplete = false;
289
+ record.cleanupError = asError(error);
290
+ } else {
291
+ record.cleanupComplete = false;
275
292
  record.cleanupError = asError(error);
276
- } else record.cleanupError = asError(error);
293
+ }
277
294
  }
278
295
  return this.#status(record, !record.paneDead);
279
296
  }
@@ -409,6 +426,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
409
426
  try { await rm(record.runtimeDir, { recursive: true, force: true }); }
410
427
  catch (error) { record.cleanupError ??= asError(error); }
411
428
  }
429
+ record.cleanupComplete = !record.cleanupError;
412
430
  if (record.cleanupError) throw new Error(`tmux supervision release failed: ${record.cleanupError.message}`);
413
431
  // A released tmux worker is intentionally left running. It can be adopted
414
432
  // again explicitly after Pi restarts, and the user's attached window stays open.
@@ -1034,6 +1052,27 @@ function isPidAlive(pid: number): boolean {
1034
1052
  }
1035
1053
  }
1036
1054
 
1055
+ async function assertDirectory(path: string): Promise<void> {
1056
+ const info = await stat(path);
1057
+ if (!info.isDirectory()) throw new Error(`Worker cwd is not a directory: ${path}`);
1058
+ await access(path, fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK);
1059
+ }
1060
+
1061
+ async function assertExecutableAvailable(command: string, pathValue: string | undefined): Promise<void> {
1062
+ const candidates = isAbsolute(command) || command.includes("/") || command.includes("\\")
1063
+ ? [command]
1064
+ : (pathValue ?? "").split(delimiter).filter(Boolean).map((directory) => join(directory, command));
1065
+ for (const candidate of candidates) {
1066
+ try {
1067
+ await access(candidate, fsConstants.X_OK);
1068
+ return;
1069
+ } catch {
1070
+ // Try the next PATH entry.
1071
+ }
1072
+ }
1073
+ throw new Error(`executable preflight failed (ENOENT): ${command}`);
1074
+ }
1075
+
1037
1076
  function boundedDelay(value: number): number {
1038
1077
  if (!Number.isFinite(value) || value < 1) throw new Error("tmux adapter delays must be positive finite numbers");
1039
1078
  return value;