pi-claude-supervisor 0.2.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 +36 -0
- package/LICENSE +21 -0
- package/README.cn.md +98 -0
- package/README.md +122 -0
- package/docs/architecture.md +122 -0
- package/docs/engineering-plan.md +918 -0
- package/docs/implementation-review.md +62 -0
- package/docs/independent-review.md +332 -0
- package/docs/releasing.md +112 -0
- package/docs/testing.md +109 -0
- package/docs/transport-spike-2026-09-12.md +94 -0
- package/package.json +72 -0
- package/src/config.ts +35 -0
- package/src/decision-session-store.ts +170 -0
- package/src/decision-worker.ts +245 -0
- package/src/events.ts +179 -0
- package/src/index.ts +500 -0
- package/src/notifications.ts +85 -0
- package/src/policy.ts +61 -0
- package/src/state.ts +44 -0
- package/src/supervisor.ts +626 -0
- package/src/types.ts +122 -0
- package/src/verifier.ts +40 -0
- package/src/worker/environment.ts +31 -0
- package/src/worker/process-adapter.ts +604 -0
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
3
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import type {
|
|
5
|
+
WorkerAdapter,
|
|
6
|
+
WorkerCapabilities,
|
|
7
|
+
WorkerHandle,
|
|
8
|
+
PermissionDecision,
|
|
9
|
+
WorkerEventListener,
|
|
10
|
+
WorkerOutputChunk,
|
|
11
|
+
WorkerStartInput,
|
|
12
|
+
WorkerStatus,
|
|
13
|
+
} from "../types.ts";
|
|
14
|
+
import { assertSafeWorkerCommand } from "../policy.ts";
|
|
15
|
+
import { workerEnvironment } from "./environment.ts";
|
|
16
|
+
|
|
17
|
+
export interface ProcessWorkerAdapterOptions {
|
|
18
|
+
/** Use Claude Code's documented stream-json stdin/stdout framing. */
|
|
19
|
+
mode?: "process-pipe" | "claude-jsonl";
|
|
20
|
+
/** Grace period before the detached process group is force-killed. */
|
|
21
|
+
terminationGraceMs?: number;
|
|
22
|
+
/** Additional wait after force-killing the process group. */
|
|
23
|
+
killGraceMs?: number;
|
|
24
|
+
/** Maximum number of captured output chunks retained per worker. */
|
|
25
|
+
maxOutputChunks?: number;
|
|
26
|
+
/** Maximum UTF-8 bytes of captured output retained per worker. */
|
|
27
|
+
maxOutputBytes?: number;
|
|
28
|
+
/** Maximum time a blocked stdin write may hold lifecycle operations. */
|
|
29
|
+
inputWriteTimeoutMs?: number;
|
|
30
|
+
/** Linux descendant cleanup mode; auto uses cgroup v2 when available. */
|
|
31
|
+
cgroupMode?: "off" | "auto" | "required";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ProcessRecord {
|
|
35
|
+
child: ChildProcess;
|
|
36
|
+
handle: WorkerHandle;
|
|
37
|
+
output: WorkerOutputChunk[];
|
|
38
|
+
outputBytes: number;
|
|
39
|
+
outputTruncated: boolean;
|
|
40
|
+
lastOutputAt?: string;
|
|
41
|
+
lastInputAt?: string;
|
|
42
|
+
activeRequests: number;
|
|
43
|
+
turnSequence: number;
|
|
44
|
+
protocolBuffer: string;
|
|
45
|
+
exitCode?: number | null;
|
|
46
|
+
signal?: NodeJS.Signals;
|
|
47
|
+
spawnError?: Error;
|
|
48
|
+
stdinError?: Error;
|
|
49
|
+
groupCleanup?: Promise<void>;
|
|
50
|
+
groupCleanupComplete?: boolean;
|
|
51
|
+
cleanupError?: Error;
|
|
52
|
+
cgroupPath?: string;
|
|
53
|
+
cgroupError?: Error;
|
|
54
|
+
runtimeError?: Error;
|
|
55
|
+
spawned: Promise<void>;
|
|
56
|
+
spawnedSuccessfully: boolean;
|
|
57
|
+
exited: Promise<void>;
|
|
58
|
+
resolveExit: () => void;
|
|
59
|
+
sentKeys: Set<string>;
|
|
60
|
+
inputTail: Promise<void>;
|
|
61
|
+
listeners: Set<WorkerEventListener>;
|
|
62
|
+
permissionResponses: Set<string>;
|
|
63
|
+
stopping?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Minimal dependency-free worker transport.
|
|
68
|
+
*
|
|
69
|
+
* This is deliberately process-pipe, not a PTY. It is suitable for the MVP
|
|
70
|
+
* control boundary and transport spike; PTY support must be added only after
|
|
71
|
+
* its lifecycle and takeover semantics are independently verified.
|
|
72
|
+
*/
|
|
73
|
+
export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
74
|
+
readonly #records = new Map<string, ProcessRecord>();
|
|
75
|
+
readonly #mode: "process-pipe" | "claude-jsonl";
|
|
76
|
+
readonly #terminationGraceMs: number;
|
|
77
|
+
readonly #killGraceMs: number;
|
|
78
|
+
readonly #maxOutputChunks: number;
|
|
79
|
+
readonly #maxOutputBytes: number;
|
|
80
|
+
readonly #inputWriteTimeoutMs: number;
|
|
81
|
+
readonly #cgroupMode: "off" | "auto" | "required";
|
|
82
|
+
|
|
83
|
+
constructor(options: ProcessWorkerAdapterOptions = {}) {
|
|
84
|
+
this.#mode = options.mode ?? "process-pipe";
|
|
85
|
+
this.#terminationGraceMs = boundedDelay(options.terminationGraceMs ?? 2_000);
|
|
86
|
+
this.#killGraceMs = boundedDelay(options.killGraceMs ?? 500);
|
|
87
|
+
this.#maxOutputChunks = boundedPositiveInteger(options.maxOutputChunks ?? 10_000, "maxOutputChunks");
|
|
88
|
+
this.#maxOutputBytes = boundedPositiveInteger(options.maxOutputBytes ?? 8 * 1024 * 1024, "maxOutputBytes");
|
|
89
|
+
this.#inputWriteTimeoutMs = boundedDelay(options.inputWriteTimeoutMs ?? 10_000);
|
|
90
|
+
this.#cgroupMode = options.cgroupMode ?? "auto";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
capabilities(): WorkerCapabilities {
|
|
94
|
+
return {
|
|
95
|
+
transport: this.#mode === "claude-jsonl" ? "jsonl" : "process-pipe",
|
|
96
|
+
interactiveInput: true,
|
|
97
|
+
pause: true,
|
|
98
|
+
resumeSession: false,
|
|
99
|
+
processGroupControl: true,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async start(input: WorkerStartInput): Promise<WorkerHandle> {
|
|
104
|
+
const args = this.#mode === "claude-jsonl" ? claudeJsonlArgs(input.args) : (input.args ?? []);
|
|
105
|
+
assertSafeWorkerCommand(input.command, args, input.approval);
|
|
106
|
+
const handle: WorkerHandle = {
|
|
107
|
+
id: randomUUID(),
|
|
108
|
+
startedAt: new Date().toISOString(),
|
|
109
|
+
cwd: input.cwd,
|
|
110
|
+
};
|
|
111
|
+
const child = spawn(input.command, args, {
|
|
112
|
+
cwd: input.cwd,
|
|
113
|
+
env: workerEnvironment(process.env, input.env),
|
|
114
|
+
detached: true,
|
|
115
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
116
|
+
});
|
|
117
|
+
handle.pid = child.pid;
|
|
118
|
+
let resolveSpawn!: () => void;
|
|
119
|
+
let rejectSpawn!: (error: Error) => void;
|
|
120
|
+
const spawned = new Promise<void>((resolve, reject) => {
|
|
121
|
+
resolveSpawn = resolve;
|
|
122
|
+
rejectSpawn = reject;
|
|
123
|
+
});
|
|
124
|
+
let resolveExit!: () => void;
|
|
125
|
+
const exited = new Promise<void>((resolve) => { resolveExit = resolve; });
|
|
126
|
+
const record: ProcessRecord = {
|
|
127
|
+
child,
|
|
128
|
+
handle,
|
|
129
|
+
output: [],
|
|
130
|
+
outputBytes: 0,
|
|
131
|
+
outputTruncated: false,
|
|
132
|
+
activeRequests: input.task ? 1 : 0,
|
|
133
|
+
turnSequence: 0,
|
|
134
|
+
protocolBuffer: "",
|
|
135
|
+
exited,
|
|
136
|
+
resolveExit,
|
|
137
|
+
sentKeys: new Set(),
|
|
138
|
+
groupCleanupComplete: child.pid === undefined,
|
|
139
|
+
spawned,
|
|
140
|
+
spawnedSuccessfully: false,
|
|
141
|
+
inputTail: Promise.resolve(),
|
|
142
|
+
listeners: new Set(input.eventListener ? [input.eventListener] : []),
|
|
143
|
+
permissionResponses: new Set(),
|
|
144
|
+
};
|
|
145
|
+
this.#records.set(handle.id, record);
|
|
146
|
+
const capture = (stream: "stdout" | "stderr") => (chunk: Buffer | string) => {
|
|
147
|
+
const text = String(chunk);
|
|
148
|
+
record.lastOutputAt = new Date().toISOString();
|
|
149
|
+
const outputChunk = { stream, text, at: record.lastOutputAt } as WorkerOutputChunk;
|
|
150
|
+
this.#appendOutput(record, outputChunk);
|
|
151
|
+
this.#emit(record, { type: "output", handle: record.handle, chunk: outputChunk });
|
|
152
|
+
if (stream === "stdout" && this.#mode === "claude-jsonl") this.#observeJsonl(record, text);
|
|
153
|
+
};
|
|
154
|
+
child.stdout?.on("data", capture("stdout"));
|
|
155
|
+
child.stderr?.on("data", capture("stderr"));
|
|
156
|
+
child.stdin?.on("error", (error) => {
|
|
157
|
+
record.stdinError = error;
|
|
158
|
+
this.#appendOutput(record, { stream: "stderr", text: `worker stdin error: ${error.message}\n`, at: new Date().toISOString() });
|
|
159
|
+
});
|
|
160
|
+
child.once("spawn", () => {
|
|
161
|
+
record.spawnedSuccessfully = true;
|
|
162
|
+
resolveSpawn();
|
|
163
|
+
});
|
|
164
|
+
child.once("error", (error) => {
|
|
165
|
+
if (!record.spawnedSuccessfully) {
|
|
166
|
+
record.spawnError = error;
|
|
167
|
+
record.exitCode = -1;
|
|
168
|
+
rejectSpawn(error);
|
|
169
|
+
this.#appendOutput(record, { stream: "stderr", text: `worker spawn error: ${error.message}\n`, at: new Date().toISOString() });
|
|
170
|
+
record.activeRequests = 0;
|
|
171
|
+
record.resolveExit();
|
|
172
|
+
} else {
|
|
173
|
+
record.runtimeError = error;
|
|
174
|
+
this.#appendOutput(record, { stream: "stderr", text: `worker runtime error: ${error.message}\n`, at: new Date().toISOString() });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
child.once("exit", (code, signal) => {
|
|
178
|
+
if (!record.spawnError) {
|
|
179
|
+
record.exitCode = code;
|
|
180
|
+
record.signal = signal ?? undefined;
|
|
181
|
+
}
|
|
182
|
+
record.activeRequests = 0;
|
|
183
|
+
this.#emit(record, { type: "exited", handle: record.handle, exitCode: record.exitCode, signal: record.signal });
|
|
184
|
+
record.resolveExit();
|
|
185
|
+
void this.#ensureGroupCleanup(record).catch((error) => {
|
|
186
|
+
record.cleanupError = error instanceof Error ? error : new Error(String(error));
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
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
|
+
Object.defineProperty(startupError, "workerHandle", { value: handle, enumerable: false });
|
|
196
|
+
throw startupError;
|
|
197
|
+
}
|
|
198
|
+
if (input.task) {
|
|
199
|
+
record.lastInputAt = new Date().toISOString();
|
|
200
|
+
try {
|
|
201
|
+
await this.#writeInput(record, this.#encodeMessage(input.task));
|
|
202
|
+
} catch (error) {
|
|
203
|
+
await this.stop(handle, "initial worker input failed");
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return handle;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async getStatus(handle: WorkerHandle): Promise<WorkerStatus> {
|
|
211
|
+
const record = this.#record(handle);
|
|
212
|
+
const running = record.exitCode === undefined;
|
|
213
|
+
if (!running && record.groupCleanup) {
|
|
214
|
+
try {
|
|
215
|
+
await record.groupCleanup;
|
|
216
|
+
} catch (error) {
|
|
217
|
+
record.cleanupError = error instanceof Error ? error : new Error(String(error));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
handle: record.handle,
|
|
222
|
+
running,
|
|
223
|
+
exitCode: record.exitCode,
|
|
224
|
+
signal: record.signal,
|
|
225
|
+
lastOutputAt: record.lastOutputAt,
|
|
226
|
+
lastInputAt: record.lastInputAt,
|
|
227
|
+
activeRequests: this.#mode === "claude-jsonl" ? record.activeRequests : undefined,
|
|
228
|
+
exitReason: running ? undefined : record.signal ? "crashed" : record.exitCode === 0 ? "completed" : "failed",
|
|
229
|
+
processGroupCleaned: record.groupCleanupComplete,
|
|
230
|
+
cgroupCleaned: record.cgroupPath ? record.groupCleanupComplete : undefined,
|
|
231
|
+
cgroupError: record.cgroupError?.message,
|
|
232
|
+
cleanupError: record.cleanupError?.message,
|
|
233
|
+
runtimeError: record.runtimeError?.message,
|
|
234
|
+
outputTruncated: record.outputTruncated,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
subscribe(handle: WorkerHandle, listener: WorkerEventListener): () => void {
|
|
239
|
+
const record = this.#record(handle);
|
|
240
|
+
record.listeners.add(listener);
|
|
241
|
+
return () => record.listeners.delete(listener);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async respondPermission(handle: WorkerHandle, requestId: string, toolUseId: string, decision: PermissionDecision, updatedInput?: unknown): Promise<void> {
|
|
245
|
+
const record = this.#record(handle);
|
|
246
|
+
if (this.#mode !== "claude-jsonl") throw new Error("permission responses require claude-jsonl transport");
|
|
247
|
+
if (record.permissionResponses.has(requestId)) return;
|
|
248
|
+
if (record.exitCode !== undefined) throw new Error("worker is not running");
|
|
249
|
+
let release!: () => void;
|
|
250
|
+
const gate = new Promise<void>((resolve) => { release = resolve; });
|
|
251
|
+
const previous = record.inputTail;
|
|
252
|
+
record.inputTail = previous.then(() => gate);
|
|
253
|
+
await previous;
|
|
254
|
+
try {
|
|
255
|
+
if (record.stopping) throw new Error("worker is stopping");
|
|
256
|
+
if (record.exitCode !== undefined) throw new Error("worker is not running");
|
|
257
|
+
const response = {
|
|
258
|
+
type: "control_response",
|
|
259
|
+
response: {
|
|
260
|
+
subtype: "success",
|
|
261
|
+
request_id: requestId,
|
|
262
|
+
response: decision.behavior === "allow"
|
|
263
|
+
? { behavior: "allow", updatedInput }
|
|
264
|
+
: { behavior: "deny", message: decision.message ?? "permission denied by supervisor" },
|
|
265
|
+
toolUseID: toolUseId,
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
await this.#writeInput(record, `${JSON.stringify(response)}\n`);
|
|
269
|
+
record.permissionResponses.add(requestId);
|
|
270
|
+
record.lastInputAt = new Date().toISOString();
|
|
271
|
+
} finally {
|
|
272
|
+
release();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async readOutput(handle: WorkerHandle): Promise<WorkerOutputChunk[]> {
|
|
277
|
+
const record = this.#record(handle);
|
|
278
|
+
const output = record.output.splice(0);
|
|
279
|
+
return output;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async restoreOutput(handle: WorkerHandle, chunks: WorkerOutputChunk[]): Promise<void> {
|
|
283
|
+
const record = this.#record(handle);
|
|
284
|
+
const pending = [...chunks, ...record.output];
|
|
285
|
+
record.output = [];
|
|
286
|
+
record.outputBytes = 0;
|
|
287
|
+
for (const chunk of pending) this.#appendOutput(record, chunk);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async send(handle: WorkerHandle, message: string, idempotencyKey: string): Promise<void> {
|
|
291
|
+
const record = this.#record(handle);
|
|
292
|
+
let release!: () => void;
|
|
293
|
+
const gate = new Promise<void>((resolve) => { release = resolve; });
|
|
294
|
+
const previous = record.inputTail;
|
|
295
|
+
record.inputTail = previous.then(() => gate);
|
|
296
|
+
await previous;
|
|
297
|
+
try {
|
|
298
|
+
if (record.stopping) throw new Error("worker is stopping");
|
|
299
|
+
if (record.sentKeys.has(idempotencyKey)) return;
|
|
300
|
+
if (record.exitCode !== undefined) throw new Error("worker is not running");
|
|
301
|
+
if (this.#mode === "claude-jsonl") record.activeRequests += 1;
|
|
302
|
+
try {
|
|
303
|
+
await this.#writeInput(record, this.#encodeMessage(message));
|
|
304
|
+
} catch (error) {
|
|
305
|
+
if (this.#mode === "claude-jsonl") record.activeRequests = Math.max(0, record.activeRequests - 1);
|
|
306
|
+
throw error;
|
|
307
|
+
}
|
|
308
|
+
record.sentKeys.add(idempotencyKey);
|
|
309
|
+
record.lastInputAt = new Date().toISOString();
|
|
310
|
+
} finally {
|
|
311
|
+
release();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async pause(handle: WorkerHandle): Promise<void> {
|
|
316
|
+
this.#signal(handle, "SIGSTOP");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async resume(handle: WorkerHandle): Promise<void> {
|
|
320
|
+
this.#signal(handle, "SIGCONT");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async stop(handle: WorkerHandle, _reason: string): Promise<void> {
|
|
324
|
+
const record = this.#record(handle);
|
|
325
|
+
record.stopping = true;
|
|
326
|
+
await Promise.race([record.inputTail, delay(this.#terminationGraceMs)]);
|
|
327
|
+
if (record.exitCode === undefined) {
|
|
328
|
+
record.child.kill("SIGTERM");
|
|
329
|
+
await Promise.race([record.exited, delay(this.#terminationGraceMs)]);
|
|
330
|
+
}
|
|
331
|
+
// The leader may have exited while descendants remain in its detached group.
|
|
332
|
+
// Always clean the group so stop is also an orphan cleanup operation.
|
|
333
|
+
await this.killProcessGroup(handle, "worker stop cleanup");
|
|
334
|
+
await Promise.race([record.exited, delay(this.#killGraceMs)]);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async killProcessGroup(handle: WorkerHandle, _reason: string): Promise<void> {
|
|
338
|
+
const record = this.#record(handle);
|
|
339
|
+
await this.#ensureGroupCleanup(record);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async resumeSession(_sessionId: string): Promise<WorkerHandle> {
|
|
343
|
+
throw new Error("process-pipe transport does not support session resume");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async #writeInput(record: ProcessRecord, message: string): Promise<void> {
|
|
347
|
+
const stdin = record.child.stdin;
|
|
348
|
+
if (record.stdinError) throw new Error(`worker stdin is unavailable: ${record.stdinError.message}`);
|
|
349
|
+
if (!stdin || stdin.destroyed || stdin.writableEnded) throw new Error("worker stdin is unavailable");
|
|
350
|
+
await new Promise<void>((resolve, reject) => {
|
|
351
|
+
let timeout: ReturnType<typeof setTimeout>;
|
|
352
|
+
const onError = (error: Error) => {
|
|
353
|
+
clearTimeout(timeout);
|
|
354
|
+
stdin.off("error", onError);
|
|
355
|
+
reject(error);
|
|
356
|
+
};
|
|
357
|
+
stdin.once("error", onError);
|
|
358
|
+
timeout = setTimeout(() => {
|
|
359
|
+
stdin.off("error", onError);
|
|
360
|
+
reject(new Error("worker stdin write timed out"));
|
|
361
|
+
}, this.#inputWriteTimeoutMs);
|
|
362
|
+
try {
|
|
363
|
+
stdin.write(message, (error?: Error | null) => {
|
|
364
|
+
clearTimeout(timeout);
|
|
365
|
+
stdin.off("error", onError);
|
|
366
|
+
if (error) reject(error);
|
|
367
|
+
else resolve();
|
|
368
|
+
});
|
|
369
|
+
} catch (error) {
|
|
370
|
+
clearTimeout(timeout);
|
|
371
|
+
stdin.off("error", onError);
|
|
372
|
+
reject(error);
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
#encodeMessage(message: string): string {
|
|
378
|
+
if (this.#mode === "claude-jsonl") {
|
|
379
|
+
return `${JSON.stringify({ type: "user", message: { role: "user", content: message } })}\n`;
|
|
380
|
+
}
|
|
381
|
+
return `${message}\n`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
#observeJsonl(record: ProcessRecord, chunk: string): void {
|
|
385
|
+
record.protocolBuffer += chunk;
|
|
386
|
+
let newline = record.protocolBuffer.indexOf("\n");
|
|
387
|
+
while (newline >= 0) {
|
|
388
|
+
const line = record.protocolBuffer.slice(0, newline).trim();
|
|
389
|
+
record.protocolBuffer = record.protocolBuffer.slice(newline + 1);
|
|
390
|
+
if (line) {
|
|
391
|
+
try {
|
|
392
|
+
const event = JSON.parse(line) as Record<string, unknown>;
|
|
393
|
+
this.#emit(record, { type: "jsonl", handle: record.handle, record: event });
|
|
394
|
+
if (event.type === "control_request") {
|
|
395
|
+
const request = event.request;
|
|
396
|
+
if (isPermissionRequest(event, request)) {
|
|
397
|
+
this.#emit(record, {
|
|
398
|
+
type: "permission_request",
|
|
399
|
+
handle: record.handle,
|
|
400
|
+
request: {
|
|
401
|
+
requestId: String(event.request_id),
|
|
402
|
+
toolUseId: request.tool_use_id,
|
|
403
|
+
toolName: request.tool_name,
|
|
404
|
+
input: request.input,
|
|
405
|
+
raw: event,
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
if (event.type === "result") {
|
|
411
|
+
record.activeRequests = Math.max(0, record.activeRequests - 1);
|
|
412
|
+
record.turnSequence += 1;
|
|
413
|
+
this.#emit(record, { type: "turn_completed", handle: record.handle, result: event, sequence: record.turnSequence });
|
|
414
|
+
}
|
|
415
|
+
} catch {
|
|
416
|
+
// Keep raw output for diagnostics; malformed output is not a completion signal.
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
newline = record.protocolBuffer.indexOf("\n");
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
#emit(record: ProcessRecord, event: Parameters<WorkerEventListener>[0]): void {
|
|
424
|
+
for (const listener of record.listeners) {
|
|
425
|
+
try {
|
|
426
|
+
const result = listener(event);
|
|
427
|
+
if (result && typeof (result as Promise<void>).catch === "function") {
|
|
428
|
+
void (result as Promise<void>).catch(() => { /* event consumers must not affect the worker */ });
|
|
429
|
+
}
|
|
430
|
+
} catch {
|
|
431
|
+
// Event consumers are observers; transport lifecycle must continue.
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
#appendOutput(record: ProcessRecord, chunk: WorkerOutputChunk): void {
|
|
437
|
+
let text = chunk.text;
|
|
438
|
+
if (Buffer.byteLength(text, "utf8") > this.#maxOutputBytes) {
|
|
439
|
+
text = Buffer.from(text, "utf8").subarray(-this.#maxOutputBytes).toString("utf8");
|
|
440
|
+
record.outputTruncated = true;
|
|
441
|
+
}
|
|
442
|
+
const retained = { ...chunk, text };
|
|
443
|
+
record.output.push(retained);
|
|
444
|
+
record.outputBytes += Buffer.byteLength(text, "utf8");
|
|
445
|
+
while (record.output.length > this.#maxOutputChunks || record.outputBytes > this.#maxOutputBytes) {
|
|
446
|
+
const removed = record.output.shift();
|
|
447
|
+
if (!removed) break;
|
|
448
|
+
record.outputBytes -= Buffer.byteLength(removed.text, "utf8");
|
|
449
|
+
record.outputTruncated = true;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async #attachCgroup(record: ProcessRecord): Promise<void> {
|
|
454
|
+
if (this.#cgroupMode === "off" || process.platform !== "linux" || !record.handle.pid) return;
|
|
455
|
+
let path: string | undefined;
|
|
456
|
+
try {
|
|
457
|
+
const parent = await currentCgroupPath();
|
|
458
|
+
path = `${parent}/pi-claude-supervisor-${record.handle.id}`;
|
|
459
|
+
await mkdir(path);
|
|
460
|
+
await writeFile(`${path}/cgroup.procs`, `${record.handle.pid}\n`);
|
|
461
|
+
record.cgroupPath = path;
|
|
462
|
+
} catch (error) {
|
|
463
|
+
if (path) await rm(path, { recursive: true, force: true }).catch(() => {});
|
|
464
|
+
record.cgroupError = error instanceof Error ? error : new Error(String(error));
|
|
465
|
+
if (this.#cgroupMode === "required") {
|
|
466
|
+
throw new Error(`unable to attach worker to a cgroup: ${record.cgroupError.message}`, { cause: record.cgroupError });
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
#record(handle: WorkerHandle): ProcessRecord {
|
|
472
|
+
const record = this.#records.get(handle.id);
|
|
473
|
+
if (!record) throw new Error(`unknown worker handle: ${handle.id}`);
|
|
474
|
+
return record;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
#ensureGroupCleanup(record: ProcessRecord): Promise<void> {
|
|
478
|
+
if (record.groupCleanup) return record.groupCleanup;
|
|
479
|
+
record.cleanupError = undefined;
|
|
480
|
+
const cleanup = this.#cleanupProcessGroup(record);
|
|
481
|
+
record.groupCleanup = cleanup.catch((error) => {
|
|
482
|
+
// A timed-out cleanup must remain retryable; callers such as shutdown
|
|
483
|
+
// may have a later opportunity to reap the group.
|
|
484
|
+
record.groupCleanup = undefined;
|
|
485
|
+
throw error;
|
|
486
|
+
});
|
|
487
|
+
return record.groupCleanup;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async #cleanupProcessGroup(record: ProcessRecord): Promise<void> {
|
|
491
|
+
if (record.cgroupPath) {
|
|
492
|
+
await cleanupCgroup(record.cgroupPath, this.#killGraceMs);
|
|
493
|
+
record.groupCleanupComplete = true;
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
const pid = record.handle.pid;
|
|
497
|
+
if (!pid) {
|
|
498
|
+
record.groupCleanupComplete = true;
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
try {
|
|
502
|
+
process.kill(-pid, "SIGKILL");
|
|
503
|
+
} catch (error) {
|
|
504
|
+
if (!(error instanceof Error) || !/ESRCH/u.test(error.message)) throw error;
|
|
505
|
+
record.groupCleanupComplete = true;
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const deadline = Date.now() + this.#killGraceMs;
|
|
509
|
+
while (Date.now() <= deadline) {
|
|
510
|
+
try {
|
|
511
|
+
process.kill(-pid, 0);
|
|
512
|
+
} catch (error) {
|
|
513
|
+
if (error instanceof Error && /ESRCH/u.test(error.message)) {
|
|
514
|
+
record.groupCleanupComplete = true;
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
throw error;
|
|
518
|
+
}
|
|
519
|
+
await delay(Math.min(10, Math.max(1, deadline - Date.now())));
|
|
520
|
+
}
|
|
521
|
+
throw new Error(`worker process group ${pid} did not exit before cleanup deadline`);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
#signal(handle: WorkerHandle, signal: NodeJS.Signals): void {
|
|
525
|
+
const record = this.#record(handle);
|
|
526
|
+
if (record.exitCode !== undefined || !record.handle.pid) return;
|
|
527
|
+
process.kill(-record.handle.pid, signal);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function claudeJsonlArgs(args: readonly string[] = []): string[] {
|
|
532
|
+
const result = [...args];
|
|
533
|
+
if (!result.includes("-p") && !result.includes("--print")) result.push("-p");
|
|
534
|
+
ensureOption(result, "--input-format", "stream-json");
|
|
535
|
+
ensureOption(result, "--output-format", "stream-json");
|
|
536
|
+
ensureOption(result, "--permission-prompt-tool", "stdio");
|
|
537
|
+
if (!result.includes("--verbose")) result.push("--verbose");
|
|
538
|
+
return result;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function ensureOption(args: string[], option: string, expected: string): void {
|
|
542
|
+
const index = args.indexOf(option);
|
|
543
|
+
if (index < 0) {
|
|
544
|
+
args.push(option, expected);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (args[index + 1] !== expected) throw new Error(`${option} must be ${expected} in claude-jsonl mode`);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function currentCgroupPath(): Promise<string> {
|
|
551
|
+
const contents = await readFile("/proc/self/cgroup", "utf8");
|
|
552
|
+
const match = contents.match(/^0::([^\n]*)$/mu);
|
|
553
|
+
if (!match) throw new Error("cgroup v2 is not active");
|
|
554
|
+
// /proc/self/cgroup uses the same escaped component spelling as the cgroup
|
|
555
|
+
// filesystem (for example, a literal `\\x2d` in a systemd scope name).
|
|
556
|
+
return `/sys/fs/cgroup${match[1]}`;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function cleanupCgroup(path: string, graceMs: number): Promise<void> {
|
|
560
|
+
try {
|
|
561
|
+
await writeFile(`${path}/cgroup.kill`, "1\n");
|
|
562
|
+
} catch (error) {
|
|
563
|
+
if (error instanceof Error && /ENOENT/u.test(error.message)) return;
|
|
564
|
+
throw error;
|
|
565
|
+
}
|
|
566
|
+
const deadline = Date.now() + graceMs;
|
|
567
|
+
while (Date.now() <= deadline) {
|
|
568
|
+
try {
|
|
569
|
+
const events = await readFile(`${path}/cgroup.events`, "utf8");
|
|
570
|
+
if (/^populated 0$/mu.test(events)) {
|
|
571
|
+
await rm(path, { recursive: true, force: true });
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
} catch (error) {
|
|
575
|
+
if (error instanceof Error && /ENOENT/u.test(error.message)) return;
|
|
576
|
+
throw error;
|
|
577
|
+
}
|
|
578
|
+
await delay(Math.min(10, Math.max(1, deadline - Date.now())));
|
|
579
|
+
}
|
|
580
|
+
throw new Error(`worker cgroup ${path} did not empty before cleanup deadline`);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function isPermissionRequest(event: Record<string, unknown>, request: unknown): request is { subtype: "can_use_tool"; tool_use_id: string; tool_name: string; input: unknown } {
|
|
584
|
+
if (event.type !== "control_request" || typeof event.request_id !== "string" || !request || typeof request !== "object") return false;
|
|
585
|
+
const value = request as { subtype?: unknown; tool_use_id?: unknown; tool_name?: unknown; input?: unknown };
|
|
586
|
+
return value.subtype === "can_use_tool"
|
|
587
|
+
&& typeof value.tool_use_id === "string"
|
|
588
|
+
&& typeof value.tool_name === "string"
|
|
589
|
+
&& "input" in value;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function delay(ms: number): Promise<void> {
|
|
593
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function boundedDelay(value: number): number {
|
|
597
|
+
if (!Number.isFinite(value) || value < 0) throw new Error("process termination delays must be finite and non-negative");
|
|
598
|
+
return value;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function boundedPositiveInteger(value: number, name: string): number {
|
|
602
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new Error(`${name} must be a positive integer`);
|
|
603
|
+
return value;
|
|
604
|
+
}
|