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/CHANGELOG.md +14 -2
- package/README.cn.md +3 -3
- package/README.md +1 -0
- package/docs/architecture.md +39 -7
- package/docs/releasing.md +2 -1
- package/docs/testing.md +15 -6
- package/docs/transport-spike-2026-09-12.md +8 -8
- package/package.json +2 -1
- package/src/config.ts +2 -0
- package/src/cwd-lease.ts +345 -0
- package/src/index.ts +188 -38
- package/src/supervisor.ts +100 -22
- package/src/types.ts +22 -1
- package/src/worker/process-adapter.ts +98 -26
- package/src/worker/tmux-adapter.ts +61 -25
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
|
|
213
|
+
await record.spawned;
|
|
214
|
+
this.#assertNotAborted(record);
|
|
215
|
+
await this.#attachCgroup(record);
|
|
216
|
+
this.#assertNotAborted(record);
|
|
206
217
|
} catch (error) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
catch (
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
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"
|
|
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, "--",
|
|
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> {
|
|
@@ -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
|
-
//
|
|
464
|
-
//
|
|
465
|
-
|
|
466
|
-
await this.#run(record, ["load-buffer", "-b", bufferName, "-"],
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
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");
|