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.
- package/CHANGELOG.md +21 -2
- package/README.cn.md +6 -4
- package/README.md +11 -5
- package/docs/architecture.md +42 -7
- package/docs/releasing.md +2 -1
- package/docs/testing.md +35 -14
- package/docs/transport-spike-2026-09-12.md +8 -8
- package/package.json +3 -1
- package/src/config.ts +2 -0
- package/src/cwd-lease.ts +345 -0
- package/src/index.ts +206 -39
- package/src/supervisor.ts +105 -22
- package/src/types.ts +22 -1
- package/src/worker/process-adapter.ts +98 -26
- package/src/worker/tmux-adapter.ts +73 -30
|
@@ -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, "--",
|
|
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 (
|
|
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 (
|
|
267
|
+
if (pane.dead) record.cleanupError = undefined;
|
|
268
|
+
else await this.#rememberPaneIdentity(record, pane.pid);
|
|
254
269
|
} catch (error) {
|
|
255
|
-
if (isMissingSession(error)
|
|
256
|
-
|
|
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 =
|
|
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
|
-
//
|
|
464
|
-
//
|
|
465
|
-
|
|
466
|
-
await this.#run(record, ["load-buffer", "-b", bufferName, "-"],
|
|
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 =
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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");
|