pi-claude-supervisor 0.3.0 → 0.4.1

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.
@@ -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> {
@@ -250,10 +264,16 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
250
264
  const pane = await this.#paneStatus(record);
251
265
  record.paneDead = pane.dead;
252
266
  record.panePid = pane.pid;
253
- if (!pane.dead) await this.#rememberPaneIdentity(record, pane.pid);
267
+ if (pane.dead) record.cleanupError = undefined;
268
+ else await this.#rememberPaneIdentity(record, pane.pid);
254
269
  } catch (error) {
255
- if (isMissingSession(error) || isPaneIdentityError(error)) record.paneDead = true;
256
- else record.cleanupError = asError(error);
270
+ if (isMissingSession(error)) {
271
+ record.paneDead = true;
272
+ record.cleanupError = undefined;
273
+ } else if (isPaneIdentityError(error)) {
274
+ record.paneDead = false;
275
+ record.cleanupError = asError(error);
276
+ } else record.cleanupError = asError(error);
257
277
  }
258
278
  return this.#status(record, !record.paneDead);
259
279
  }
@@ -270,10 +290,11 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
270
290
  } catch (error) {
271
291
  if (isMissingSession(error)) {
272
292
  record.paneDead = true;
293
+ record.cleanupError = undefined;
273
294
  if (!record.cleanupComplete && record.owned) await this.#cleanup(record, false);
274
295
  } else {
275
296
  record.cleanupError = asError(error);
276
- if (isPaneIdentityError(error)) record.paneDead = true;
297
+ if (isPaneIdentityError(error)) record.paneDead = false;
277
298
  }
278
299
  }
279
300
  return this.#status(record, !record.paneDead && !record.released);
@@ -460,18 +481,18 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
460
481
  async #sendRaw(record: TmuxRecord, message: string): Promise<void> {
461
482
  const safeMessage = safeTmuxMessage(message);
462
483
  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]);
484
+ // Ask tmux to emit a real bracketed paste. Embedding the escape markers
485
+ // in the buffer makes Claude's TUI render them literally instead of
486
+ // entering paste mode.
487
+ await this.#run(record, ["load-buffer", "-b", bufferName, "-"], safeMessage);
488
+ await this.#run(record, ["paste-buffer", "-p", "-d", "-b", bufferName, "-t", record.target]);
468
489
  await this.#run(record, ["send-keys", "-t", record.target, "Enter"]);
469
490
  }
470
491
 
471
492
  #startMonitor(record: TmuxRecord): void {
472
493
  record.monitor = setInterval(() => {
473
494
  void this.#monitor(record).catch((error) => {
474
- if (isPaneIdentityError(error)) record.paneDead = true;
495
+ if (isPaneIdentityError(error)) record.paneDead = false;
475
496
  if (!record.cleanupComplete && !isMissingSession(error)) record.cleanupError = asError(error);
476
497
  });
477
498
  }, this.#pollIntervalMs);
@@ -562,6 +583,8 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
562
583
  async #assertExistingSession(record: TmuxRecord, cwd: string, approval?: { actor: "human"; reason: string }): Promise<void> {
563
584
  await this.#pinTarget(record);
564
585
  const pane = await this.#paneStatus(record);
586
+ const expected = record.expectedIdentity;
587
+ if (expected?.pid !== undefined && pane.pid !== expected.pid) throw new Error("tmux pane pid changed; refusing identity-unverified handoff");
565
588
  if (pane.dead) throw new Error("cannot adopt a dead tmux pane");
566
589
  record.handle.pid = pane.pid;
567
590
  const currentPath = await this.#run(record, ["display-message", "-p", "-t", record.target, "#{pane_current_path}"]);
@@ -584,12 +607,18 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
584
607
  }
585
608
  assertSafeWorkerCommand(command, [argsText], approval);
586
609
  await this.#rememberPaneIdentity(record, pane.pid);
610
+ if (expected?.startTime && record.paneStartTime !== expected.startTime) throw new Error("tmux pane process start time changed; refusing identity-unverified handoff");
611
+ if (expected?.paneStartTime && record.paneStartTime !== expected.paneStartTime) throw new Error("tmux pane identity changed; refusing identity-unverified handoff");
612
+ if (expected?.paneCommand && record.paneCommand !== expected.paneCommand) throw new Error("tmux pane command changed; refusing identity-unverified handoff");
587
613
  }
588
614
 
589
615
  async #pinTarget(record: TmuxRecord): Promise<void> {
616
+ if (record.expectedIdentity?.tmuxTarget && record.expectedIdentity.tmuxTarget !== record.sessionName) throw new Error("tmux session target changed; refusing identity-unverified handoff");
590
617
  const pane = await this.#run(record, ["display-message", "-p", "-t", record.target, "#{pane_id}"]);
591
618
  const paneId = pane.stdout.trim();
592
619
  if (!/^%[0-9]+$/u.test(paneId)) throw new Error("tmux did not return a stable pane id");
620
+ if (record.expectedIdentity?.tmuxPaneId && record.expectedIdentity.tmuxPaneId !== paneId) throw new Error("tmux pane target changed; refusing identity-unverified handoff");
621
+ record.handle.tmuxPaneId = paneId;
593
622
  record.target = paneId;
594
623
  }
595
624
 
@@ -622,6 +651,8 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
622
651
  }
623
652
  record.paneStartTime = startTime;
624
653
  record.paneCommand = command;
654
+ record.handle.paneStartTime = startTime;
655
+ record.handle.paneCommand = command;
625
656
  } catch (error) {
626
657
  if (error instanceof Error && /tmux pane identity changed/u.test(error.message)) throw error;
627
658
  throw new Error(`tmux pane identity unavailable for pid ${pid}`);
@@ -671,14 +702,14 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
671
702
  record.cleanupError = new Error("owned tmux cleanup lacks a verifiable pane identity");
672
703
  return;
673
704
  }
674
- if (!isPidAlive(pid)) return;
705
+ if (!isPidAlive(pid) || await isZombie(pid)) return;
675
706
  if (!(await sameProcess(record, pid))) {
676
707
  await this.#markReplacement(record, pid);
677
708
  return;
678
709
  }
679
710
  signalProcessGroup(pid, "SIGTERM");
680
711
  for (let attempt = 0; attempt < 10 && isPidAlive(pid); attempt += 1) await delay(50);
681
- if (isPidAlive(pid)) {
712
+ if (isPidAlive(pid) && !(await isZombie(pid))) {
682
713
  if (!(await sameProcess(record, pid))) {
683
714
  await this.#markReplacement(record, pid);
684
715
  return;
@@ -686,21 +717,26 @@ export class TmuxWorkerAdapter implements WorkerAdapter {
686
717
  signalProcessGroup(pid, "SIGKILL");
687
718
  }
688
719
  for (let attempt = 0; attempt < 10 && isPidAlive(pid); attempt += 1) await delay(50);
689
- if (isPidAlive(pid)) {
720
+ if (isPidAlive(pid) && !(await isZombie(pid))) {
690
721
  if (await sameProcess(record, pid)) record.cleanupError = new Error(`tmux pane process did not exit: ${pid}`);
691
722
  else await this.#markReplacement(record, pid);
692
723
  }
693
724
  }
694
725
 
695
726
  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}`);
727
+ for (let attempt = 0; attempt < 5; attempt += 1) {
728
+ if (await isZombie(pid)) return;
729
+ const identity = await processIdentity(pid);
730
+ if (identity) {
731
+ record.replacementPaneStartTime = identity.startTime;
732
+ record.replacementPaneCommand = identity.command;
733
+ record.cleanupError = new Error(`owned tmux cleanup refused replacement pane process pid=${pid} start=${identity.startTime} command=${identity.command}`);
734
+ return;
735
+ }
736
+ if (!isPidAlive(pid)) return;
737
+ await delay(25);
703
738
  }
739
+ record.cleanupError = new Error(`owned tmux cleanup refused an unverified replacement pane process pid=${pid}`);
704
740
  }
705
741
 
706
742
  #assertNotAborted(record: TmuxRecord): void {
@@ -844,10 +880,6 @@ export function attachCommand(handle: Pick<WorkerHandle, "tmuxSocket" | "session
844
880
  return handle.tmuxSocket ? `tmux -S ${shellQuote(handle.tmuxSocket)} attach -t ${target}` : `tmux attach -t ${target}`;
845
881
  }
846
882
 
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
883
  function runCommand(command: string, args: string[], input: string | undefined, env: NodeJS.ProcessEnv, timeoutMs: number): Promise<{ stdout: string; stderr: string }> {
852
884
  return new Promise((resolve, reject) => {
853
885
  const child = spawn(command, args, { env, stdio: ["pipe", "pipe", "pipe"] });
@@ -964,6 +996,17 @@ interface ProcessIdentity {
964
996
  command: string;
965
997
  }
966
998
 
999
+ async function isZombie(pid: number): Promise<boolean> {
1000
+ try {
1001
+ const statText = await readFile(`/proc/${pid}/stat`, "utf8");
1002
+ const closeParen = statText.lastIndexOf(")");
1003
+ const fields = closeParen >= 0 ? statText.slice(closeParen + 2).trim().split(/\s+/u) : [];
1004
+ return fields[0] === "Z";
1005
+ } catch {
1006
+ return false;
1007
+ }
1008
+ }
1009
+
967
1010
  async function processIdentity(pid: number): Promise<ProcessIdentity | undefined> {
968
1011
  try {
969
1012
  const statText = await readFile(`/proc/${pid}/stat`, "utf8");