pi-claude-supervisor 0.3.0 → 0.4.0

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.
package/src/types.ts CHANGED
@@ -40,8 +40,21 @@ export interface WorkerStartInput {
40
40
  tmuxSession?: string;
41
41
  /** Optional tmux socket path; omitted means the user's default server. */
42
42
  tmuxSocket?: string;
43
+ /** Persisted identity required when handing off an existing tmux lease. */
44
+ tmuxExpectedIdentity?: {
45
+ pid?: number;
46
+ startTime?: string;
47
+ tmuxTarget?: string;
48
+ tmuxPaneId?: string;
49
+ paneStartTime?: string;
50
+ paneCommand?: string;
51
+ };
43
52
  /** Adopted sessions must not replay the task as a new user message. */
44
53
  sendInitialInput?: boolean;
54
+ /** Cancel startup before a worker is fully returned to the supervisor. */
55
+ abortSignal?: AbortSignal;
56
+ /** Internal token that scopes out-of-band startup cancellation. */
57
+ startupToken?: string;
45
58
  }
46
59
 
47
60
  export interface WorkerHandle {
@@ -53,6 +66,12 @@ export interface WorkerHandle {
53
66
  sessionName?: string;
54
67
  tmuxSocket?: string;
55
68
  ownership?: "owned" | "adopted";
69
+ /** Verified process-boundary metadata persisted in the cwd lease registry. */
70
+ cgroupPath?: string;
71
+ tmuxTarget?: string;
72
+ tmuxPaneId?: string;
73
+ paneStartTime?: string;
74
+ paneCommand?: string;
56
75
  }
57
76
 
58
77
  export interface WorkerStatus {
@@ -68,6 +87,8 @@ export interface WorkerStatus {
68
87
  processGroupCleaned?: boolean;
69
88
  /** Whether a Linux cgroup provided descendant cleanup for this worker. */
70
89
  cgroupCleaned?: boolean;
90
+ /** Whether cgroup attachment was required for this worker. */
91
+ cgroupRequired?: boolean;
71
92
  /** cgroup attachment was unavailable and a fallback may have been used. */
72
93
  cgroupError?: string;
73
94
  /** Cleanup failure is diagnostic and must be treated as a safety failure. */
@@ -85,7 +106,7 @@ export interface WorkerAdapter {
85
106
  capabilities(): WorkerCapabilities;
86
107
  start(input: WorkerStartInput): Promise<WorkerHandle>;
87
108
  /** Cancel adapter-owned startup work before a WorkerHandle is returned. */
88
- abortStart?(reason: string): Promise<void>;
109
+ abortStart?(reason: string, startupToken?: string): Promise<void>;
89
110
  getStatus(handle: WorkerHandle): Promise<WorkerStatus>;
90
111
  readOutput(handle: WorkerHandle): Promise<WorkerOutputChunk[]>;
91
112
  /** Restore chunks when diagnostic event persistence fails before acknowledgement. */
@@ -29,6 +29,8 @@ export interface ProcessWorkerAdapterOptions {
29
29
  inputWriteTimeoutMs?: number;
30
30
  /** Linux descendant cleanup mode; auto uses cgroup v2 when available. */
31
31
  cgroupMode?: "off" | "auto" | "required";
32
+ /** Override the detected cgroup parent for controlled integration tests. */
33
+ cgroupParentPath?: string;
32
34
  }
33
35
 
34
36
  interface ProcessRecord {
@@ -61,6 +63,10 @@ interface ProcessRecord {
61
63
  listeners: Set<WorkerEventListener>;
62
64
  permissionResponses: Set<string>;
63
65
  stopping?: boolean;
66
+ starting: boolean;
67
+ abortRequested: boolean;
68
+ startupToken?: string;
69
+ abortListener?: () => void;
64
70
  }
65
71
 
66
72
  /**
@@ -79,6 +85,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
79
85
  readonly #maxOutputBytes: number;
80
86
  readonly #inputWriteTimeoutMs: number;
81
87
  readonly #cgroupMode: "off" | "auto" | "required";
88
+ readonly #cgroupParentPath?: string;
82
89
 
83
90
  constructor(options: ProcessWorkerAdapterOptions = {}) {
84
91
  this.#mode = options.mode ?? "process-pipe";
@@ -88,6 +95,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
88
95
  this.#maxOutputBytes = boundedPositiveInteger(options.maxOutputBytes ?? 8 * 1024 * 1024, "maxOutputBytes");
89
96
  this.#inputWriteTimeoutMs = boundedDelay(options.inputWriteTimeoutMs ?? 10_000);
90
97
  this.#cgroupMode = options.cgroupMode ?? "auto";
98
+ this.#cgroupParentPath = options.cgroupParentPath;
91
99
  }
92
100
 
93
101
  capabilities(): WorkerCapabilities {
@@ -101,6 +109,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
101
109
  }
102
110
 
103
111
  async start(input: WorkerStartInput): Promise<WorkerHandle> {
112
+ if (input.abortSignal?.aborted) throw new Error("worker startup aborted before spawn");
104
113
  const args = this.#mode === "claude-jsonl" ? claudeJsonlArgs(input.args) : (input.args ?? []);
105
114
  assertSafeWorkerCommand(input.command, args, input.approval);
106
115
  const handle: WorkerHandle = {
@@ -141,8 +150,21 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
141
150
  inputTail: Promise.resolve(),
142
151
  listeners: new Set(input.eventListener ? [input.eventListener] : []),
143
152
  permissionResponses: new Set(),
153
+ starting: true,
154
+ abortRequested: false,
155
+ startupToken: input.startupToken,
144
156
  };
157
+ const abortListener = () => {
158
+ record.abortRequested = true;
159
+ record.stopping = true;
160
+ if (record.exitCode === undefined) {
161
+ try { record.child.kill("SIGTERM"); } catch { /* cleanup below remains authoritative */ }
162
+ }
163
+ };
164
+ record.abortListener = abortListener;
165
+ input.abortSignal?.addEventListener("abort", abortListener, { once: true });
145
166
  this.#records.set(handle.id, record);
167
+ if (input.abortSignal?.aborted) abortListener();
146
168
  const capture = (stream: "stdout" | "stderr") => (chunk: Buffer | string) => {
147
169
  const text = String(chunk);
148
170
  record.lastOutputAt = new Date().toISOString();
@@ -187,37 +209,69 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
187
209
  });
188
210
  });
189
211
  try {
190
- await record.spawned;
191
- await this.#attachCgroup(record);
192
- } catch (error) {
193
- try { await this.#ensureGroupCleanup(record); } catch (cleanupError) { record.cleanupError = cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError)); }
194
- const startupError = error instanceof Error ? error : new Error(String(error));
195
- if (record.cleanupError) {
196
- startupError.message = `${startupError.message}; startup cleanup failed: ${record.cleanupError.message}`;
197
- Object.defineProperty(startupError, "workerHandle", { value: handle, enumerable: false });
198
- Object.defineProperty(startupError, "workerCleanupRequired", { value: true, enumerable: false });
199
- }
200
- throw startupError;
201
- }
202
- if (input.task) {
203
- record.lastInputAt = new Date().toISOString();
204
212
  try {
205
- await this.#writeInput(record, this.#encodeMessage(input.task));
213
+ await record.spawned;
214
+ this.#assertNotAborted(record);
215
+ await this.#attachCgroup(record);
216
+ this.#assertNotAborted(record);
206
217
  } catch (error) {
207
- let cleanupError: unknown;
208
- try { await this.stop(handle, "initial worker input failed"); }
209
- catch (stopError) { cleanupError = stopError; }
210
- if (cleanupError) {
211
- const startupError = error instanceof Error ? error : new Error(String(error));
212
- startupError.message = `${startupError.message}; startup cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`;
218
+ try {
219
+ await this.#ensureGroupCleanup(record);
220
+ } catch (cleanupError) {
221
+ record.cleanupError = cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError));
222
+ }
223
+ const startupError = error instanceof Error ? error : new Error(String(error));
224
+ if (record.cleanupError) {
225
+ startupError.message = `${startupError.message}; startup cleanup failed: ${record.cleanupError.message}`;
213
226
  Object.defineProperty(startupError, "workerHandle", { value: handle, enumerable: false });
214
227
  Object.defineProperty(startupError, "workerCleanupRequired", { value: true, enumerable: false });
215
- throw startupError;
216
228
  }
217
- throw error;
229
+ throw startupError;
230
+ }
231
+ if (input.task) {
232
+ this.#assertNotAborted(record);
233
+ record.lastInputAt = new Date().toISOString();
234
+ try {
235
+ await this.#writeInput(record, this.#encodeMessage(input.task));
236
+ } catch (error) {
237
+ let cleanupError: unknown;
238
+ try { await this.stop(handle, "initial worker input failed"); }
239
+ catch (stopError) { cleanupError = stopError; }
240
+ if (cleanupError) {
241
+ const startupError = error instanceof Error ? error : new Error(String(error));
242
+ startupError.message = `${startupError.message}; startup cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`;
243
+ Object.defineProperty(startupError, "workerHandle", { value: handle, enumerable: false });
244
+ Object.defineProperty(startupError, "workerCleanupRequired", { value: true, enumerable: false });
245
+ throw startupError;
246
+ }
247
+ throw error;
248
+ }
249
+ }
250
+ return handle;
251
+ } finally {
252
+ record.starting = false;
253
+ if (record.abortListener) input.abortSignal?.removeEventListener("abort", record.abortListener);
254
+ record.abortListener = undefined;
255
+ }
256
+ }
257
+
258
+ async abortStart(reason = "startup aborted", startupToken?: string): Promise<void> {
259
+ const records = [...this.#records.values()].filter((record) => record.starting && (startupToken === undefined || record.startupToken === startupToken));
260
+ for (const record of records) {
261
+ record.abortRequested = true;
262
+ record.stopping = true;
263
+ if (record.exitCode === undefined) {
264
+ try { record.child.kill("SIGTERM"); } catch { /* cleanup below remains authoritative */ }
265
+ }
266
+ }
267
+ const deadline = Date.now() + Math.max(this.#terminationGraceMs, 1_000);
268
+ while (records.some((record) => record.starting) && Date.now() < deadline) await delay(10);
269
+ for (const record of records) {
270
+ if (record.exitCode === undefined) {
271
+ try { await this.#ensureGroupCleanup(record); }
272
+ catch (error) { record.cleanupError = error instanceof Error ? error : new Error(`${reason}: ${String(error)}`); }
218
273
  }
219
274
  }
220
- return handle;
221
275
  }
222
276
 
223
277
  async getStatus(handle: WorkerHandle): Promise<WorkerStatus> {
@@ -241,6 +295,7 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
241
295
  exitReason: running ? undefined : record.signal ? "crashed" : record.exitCode === 0 ? "completed" : "failed",
242
296
  processGroupCleaned: record.groupCleanupComplete,
243
297
  cgroupCleaned: record.cgroupPath ? record.groupCleanupComplete : undefined,
298
+ cgroupRequired: this.#cgroupMode === "required",
244
299
  cgroupError: record.cgroupError?.message,
245
300
  cleanupError: record.cleanupError?.message,
246
301
  runtimeError: record.runtimeError?.message,
@@ -356,6 +411,10 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
356
411
  throw new Error("process-pipe transport does not support session resume");
357
412
  }
358
413
 
414
+ #assertNotAborted(record: ProcessRecord): void {
415
+ if (record.abortRequested) throw new Error("worker startup aborted");
416
+ }
417
+
359
418
  async #writeInput(record: ProcessRecord, message: string): Promise<void> {
360
419
  const stdin = record.child.stdin;
361
420
  if (record.stdinError) throw new Error(`worker stdin is unavailable: ${record.stdinError.message}`);
@@ -464,14 +523,27 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
464
523
  }
465
524
 
466
525
  async #attachCgroup(record: ProcessRecord): Promise<void> {
467
- if (this.#cgroupMode === "off" || process.platform !== "linux" || !record.handle.pid) return;
526
+ if (this.#cgroupMode === "off") return;
527
+ if (!record.handle.pid) {
528
+ const error = new Error("worker pid was unavailable for cgroup attachment");
529
+ record.cgroupError = error;
530
+ if (this.#cgroupMode === "required") throw new Error(`unable to attach worker to a cgroup: ${error.message}`, { cause: error });
531
+ throw error;
532
+ }
533
+ if (process.platform !== "linux") {
534
+ const error = new Error("cgroups are unavailable on this platform");
535
+ record.cgroupError = error;
536
+ if (this.#cgroupMode === "required") throw new Error(`unable to attach worker to a cgroup: ${error.message}`, { cause: error });
537
+ return;
538
+ }
468
539
  let path: string | undefined;
469
540
  try {
470
- const parent = await currentCgroupPath();
541
+ const parent = this.#cgroupParentPath ?? await currentCgroupPath();
471
542
  path = `${parent}/pi-claude-supervisor-${record.handle.id}`;
472
543
  await mkdir(path);
473
544
  await writeFile(`${path}/cgroup.procs`, `${record.handle.pid}\n`);
474
545
  record.cgroupPath = path;
546
+ record.handle.cgroupPath = path;
475
547
  } catch (error) {
476
548
  if (path) await rm(path, { recursive: true, force: true }).catch(() => {});
477
549
  record.cgroupError = error instanceof Error ? error : new Error(String(error));
@@ -34,6 +34,7 @@ interface TmuxRecord {
34
34
  sessionName: string;
35
35
  socketPath?: string;
36
36
  target: string;
37
+ expectedIdentity?: WorkerStartInput["tmuxExpectedIdentity"];
37
38
  logPath: string;
38
39
  runtimeDir: string;
39
40
  owned: boolean;
@@ -60,6 +61,8 @@ interface TmuxRecord {
60
61
  stopping: boolean;
61
62
  starting: boolean;
62
63
  abortRequested: boolean;
64
+ startupToken?: string;
65
+ abortListener?: () => void;
63
66
  released: boolean;
64
67
  cleanupComplete: boolean;
65
68
  sessionCreated?: boolean;
@@ -133,12 +136,14 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
133
136
  sessionName,
134
137
  tmuxSocket: socketPath,
135
138
  ownership: owned ? "owned" : "adopted",
139
+ tmuxTarget: target,
136
140
  };
137
141
  const record: TmuxRecord = {
138
142
  handle,
139
143
  sessionName,
140
144
  socketPath,
141
145
  target,
146
+ expectedIdentity: input.tmuxExpectedIdentity,
142
147
  logPath,
143
148
  runtimeDir,
144
149
  owned,
@@ -159,10 +164,18 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
159
164
  stopping: false,
160
165
  starting: true,
161
166
  abortRequested: false,
167
+ startupToken: input.startupToken,
162
168
  released: false,
163
169
  cleanupComplete: false,
164
170
  };
171
+ const abortListener = () => {
172
+ record.abortRequested = true;
173
+ record.stopping = true;
174
+ };
175
+ record.abortListener = abortListener;
176
+ input.abortSignal?.addEventListener("abort", abortListener, { once: true });
165
177
  this.#records.set(id, record);
178
+ if (input.abortSignal?.aborted) abortListener();
166
179
 
167
180
  try {
168
181
  await mkdir(runtimeDir, { recursive: true, mode: 0o700 });
@@ -170,15 +183,13 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
170
183
  if (owned) {
171
184
  assertSafeWorkerCommand(input.command, input.args ?? [], input.approval);
172
185
  assertNoCredentialArguments(input.command, input.args ?? []);
173
- const launcherPath = join(runtimeDir!, "launcher.mjs");
174
- await writeFile(launcherPath, launcherSource({ command: input.command, args: input.args ?? [], cwd: input.cwd }), { mode: 0o600 });
175
186
  const env = workerEnvironment(process.env, input.env);
176
187
  // Create the window with its shell first so remain-on-exit is set
177
188
  // before the launcher can finish instantly.
178
189
  await this.#run(record, ["new-session", "-d", "-s", sessionName, "-x", "140", "-y", "40", "-c", input.cwd], undefined, env);
179
190
  record.sessionCreated = true;
180
191
  await this.#run(record, ["set-window-option", "-t", sessionName, "remain-on-exit", "on"]);
181
- await this.#run(record, ["respawn-pane", "-k", "-t", target, "--", process.execPath, launcherPath]);
192
+ await this.#run(record, ["respawn-pane", "-k", "-t", target, "--", input.command, ...(input.args ?? [])], undefined, env);
182
193
  await this.#pinTarget(record);
183
194
  const ownedPane = await this.#paneStatus(record);
184
195
  record.paneDead = ownedPane.dead;
@@ -228,11 +239,14 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
228
239
  if (cleanupMessage) startupError.message = `${startupError.message}; startup cleanup failed: ${cleanupMessage}`;
229
240
  Object.defineProperty(startupError, "workerHandle", { value: handle, enumerable: false });
230
241
  throw startupError;
242
+ } finally {
243
+ if (record.abortListener) input.abortSignal?.removeEventListener("abort", record.abortListener);
244
+ record.abortListener = undefined;
231
245
  }
232
246
  }
233
247
 
234
- async abortStart(_reason: string): Promise<void> {
235
- const starts = [...this.#records.values()].filter((record) => record.starting);
248
+ async abortStart(_reason: string, startupToken?: string): Promise<void> {
249
+ const starts = [...this.#records.values()].filter((record) => record.starting && (startupToken === undefined || record.startupToken === startupToken));
236
250
  for (const record of starts) {
237
251
  record.abortRequested = true;
238
252
  record.stopping = true;
@@ -240,7 +254,7 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
240
254
  else await this.#detachPipe(record);
241
255
  }
242
256
  const deadline = Date.now() + 25_000;
243
- while ([...this.#records.values()].some((record) => record.starting) && Date.now() < deadline) await delay(25);
257
+ while (starts.some((record) => record.starting) && Date.now() < deadline) await delay(25);
244
258
  }
245
259
 
246
260
  async getStatus(handle: WorkerHandle): Promise<WorkerStatus> {
@@ -460,11 +474,11 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
460
474
  async #sendRaw(record: TmuxRecord, message: string): Promise<void> {
461
475
  const safeMessage = safeTmuxMessage(message);
462
476
  const bufferName = `pi-cs-${record.handle.id}`;
463
- // Bracketed paste keeps newlines and ordinary text from being interpreted
464
- // as individual terminal key presses by Claude's TUI.
465
- const pasted = `\u001b[200~${safeMessage}\u001b[201~`;
466
- await this.#run(record, ["load-buffer", "-b", bufferName, "-"], pasted);
467
- await this.#run(record, ["paste-buffer", "-d", "-b", bufferName, "-t", record.target]);
477
+ // Ask tmux to emit a real bracketed paste. Embedding the escape markers
478
+ // in the buffer makes Claude's TUI render them literally instead of
479
+ // entering paste mode.
480
+ await this.#run(record, ["load-buffer", "-b", bufferName, "-"], safeMessage);
481
+ await this.#run(record, ["paste-buffer", "-p", "-d", "-b", bufferName, "-t", record.target]);
468
482
  await this.#run(record, ["send-keys", "-t", record.target, "Enter"]);
469
483
  }
470
484
 
@@ -562,6 +576,8 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
562
576
  async #assertExistingSession(record: TmuxRecord, cwd: string, approval?: { actor: "human"; reason: string }): Promise<void> {
563
577
  await this.#pinTarget(record);
564
578
  const pane = await this.#paneStatus(record);
579
+ const expected = record.expectedIdentity;
580
+ if (expected?.pid !== undefined && pane.pid !== expected.pid) throw new Error("tmux pane pid changed; refusing identity-unverified handoff");
565
581
  if (pane.dead) throw new Error("cannot adopt a dead tmux pane");
566
582
  record.handle.pid = pane.pid;
567
583
  const currentPath = await this.#run(record, ["display-message", "-p", "-t", record.target, "#{pane_current_path}"]);
@@ -584,12 +600,18 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
584
600
  }
585
601
  assertSafeWorkerCommand(command, [argsText], approval);
586
602
  await this.#rememberPaneIdentity(record, pane.pid);
603
+ if (expected?.startTime && record.paneStartTime !== expected.startTime) throw new Error("tmux pane process start time changed; refusing identity-unverified handoff");
604
+ if (expected?.paneStartTime && record.paneStartTime !== expected.paneStartTime) throw new Error("tmux pane identity changed; refusing identity-unverified handoff");
605
+ if (expected?.paneCommand && record.paneCommand !== expected.paneCommand) throw new Error("tmux pane command changed; refusing identity-unverified handoff");
587
606
  }
588
607
 
589
608
  async #pinTarget(record: TmuxRecord): Promise<void> {
609
+ if (record.expectedIdentity?.tmuxTarget && record.expectedIdentity.tmuxTarget !== record.sessionName) throw new Error("tmux session target changed; refusing identity-unverified handoff");
590
610
  const pane = await this.#run(record, ["display-message", "-p", "-t", record.target, "#{pane_id}"]);
591
611
  const paneId = pane.stdout.trim();
592
612
  if (!/^%[0-9]+$/u.test(paneId)) throw new Error("tmux did not return a stable pane id");
613
+ if (record.expectedIdentity?.tmuxPaneId && record.expectedIdentity.tmuxPaneId !== paneId) throw new Error("tmux pane target changed; refusing identity-unverified handoff");
614
+ record.handle.tmuxPaneId = paneId;
593
615
  record.target = paneId;
594
616
  }
595
617
 
@@ -622,6 +644,8 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
622
644
  }
623
645
  record.paneStartTime = startTime;
624
646
  record.paneCommand = command;
647
+ record.handle.paneStartTime = startTime;
648
+ record.handle.paneCommand = command;
625
649
  } catch (error) {
626
650
  if (error instanceof Error && /tmux pane identity changed/u.test(error.message)) throw error;
627
651
  throw new Error(`tmux pane identity unavailable for pid ${pid}`);
@@ -671,14 +695,14 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
671
695
  record.cleanupError = new Error("owned tmux cleanup lacks a verifiable pane identity");
672
696
  return;
673
697
  }
674
- if (!isPidAlive(pid)) return;
698
+ if (!isPidAlive(pid) || await isZombie(pid)) return;
675
699
  if (!(await sameProcess(record, pid))) {
676
700
  await this.#markReplacement(record, pid);
677
701
  return;
678
702
  }
679
703
  signalProcessGroup(pid, "SIGTERM");
680
704
  for (let attempt = 0; attempt < 10 && isPidAlive(pid); attempt += 1) await delay(50);
681
- if (isPidAlive(pid)) {
705
+ if (isPidAlive(pid) && !(await isZombie(pid))) {
682
706
  if (!(await sameProcess(record, pid))) {
683
707
  await this.#markReplacement(record, pid);
684
708
  return;
@@ -686,21 +710,26 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
686
710
  signalProcessGroup(pid, "SIGKILL");
687
711
  }
688
712
  for (let attempt = 0; attempt < 10 && isPidAlive(pid); attempt += 1) await delay(50);
689
- if (isPidAlive(pid)) {
713
+ if (isPidAlive(pid) && !(await isZombie(pid))) {
690
714
  if (await sameProcess(record, pid)) record.cleanupError = new Error(`tmux pane process did not exit: ${pid}`);
691
715
  else await this.#markReplacement(record, pid);
692
716
  }
693
717
  }
694
718
 
695
719
  async #markReplacement(record: TmuxRecord, pid: number): Promise<void> {
696
- const identity = await processIdentity(pid);
697
- if (identity) {
698
- record.replacementPaneStartTime = identity.startTime;
699
- record.replacementPaneCommand = identity.command;
700
- record.cleanupError = new Error(`owned tmux cleanup refused replacement pane process pid=${pid} start=${identity.startTime} command=${identity.command}`);
701
- } else {
702
- record.cleanupError = new Error(`owned tmux cleanup refused an unverified replacement pane process pid=${pid}`);
720
+ for (let attempt = 0; attempt < 5; attempt += 1) {
721
+ if (await isZombie(pid)) return;
722
+ const identity = await processIdentity(pid);
723
+ if (identity) {
724
+ record.replacementPaneStartTime = identity.startTime;
725
+ record.replacementPaneCommand = identity.command;
726
+ record.cleanupError = new Error(`owned tmux cleanup refused replacement pane process pid=${pid} start=${identity.startTime} command=${identity.command}`);
727
+ return;
728
+ }
729
+ if (!isPidAlive(pid)) return;
730
+ await delay(25);
703
731
  }
732
+ record.cleanupError = new Error(`owned tmux cleanup refused an unverified replacement pane process pid=${pid}`);
704
733
  }
705
734
 
706
735
  #assertNotAborted(record: TmuxRecord): void {
@@ -844,10 +873,6 @@ export function attachCommand(handle: Pick<WorkerHandle, "tmuxSocket" | "session
844
873
  return handle.tmuxSocket ? `tmux -S ${shellQuote(handle.tmuxSocket)} attach -t ${target}` : `tmux attach -t ${target}`;
845
874
  }
846
875
 
847
- function launcherSource(spec: { command: string; args: string[]; cwd: string }): string {
848
- return `import { spawn } from "node:child_process";\nconst spec = ${JSON.stringify(spec)};\nconst child = spawn(spec.command, spec.args, { cwd: spec.cwd, env: process.env, stdio: "inherit" });\nchild.once("error", (error) => { console.error(error.message); process.exitCode = 127; });\nchild.once("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exitCode = code ?? 1; });\n`;
849
- }
850
-
851
876
  function runCommand(command: string, args: string[], input: string | undefined, env: NodeJS.ProcessEnv, timeoutMs: number): Promise<{ stdout: string; stderr: string }> {
852
877
  return new Promise((resolve, reject) => {
853
878
  const child = spawn(command, args, { env, stdio: ["pipe", "pipe", "pipe"] });
@@ -964,6 +989,17 @@ interface ProcessIdentity {
964
989
  command: string;
965
990
  }
966
991
 
992
+ async function isZombie(pid: number): Promise<boolean> {
993
+ try {
994
+ const statText = await readFile(`/proc/${pid}/stat`, "utf8");
995
+ const closeParen = statText.lastIndexOf(")");
996
+ const fields = closeParen >= 0 ? statText.slice(closeParen + 2).trim().split(/\s+/u) : [];
997
+ return fields[0] === "Z";
998
+ } catch {
999
+ return false;
1000
+ }
1001
+ }
1002
+
967
1003
  async function processIdentity(pid: number): Promise<ProcessIdentity | undefined> {
968
1004
  try {
969
1005
  const statText = await readFile(`/proc/${pid}/stat`, "utf8");