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/src/events.ts ADDED
@@ -0,0 +1,179 @@
1
+ import { appendFile, chmod, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+
4
+ export interface SupervisorEvent {
5
+ seq: number;
6
+ at: string;
7
+ type: string;
8
+ taskId?: string;
9
+ workerId?: string;
10
+ idempotencyKey?: string;
11
+ data?: Record<string, unknown>;
12
+ }
13
+
14
+ const LOCK_TIMEOUT_MS = 10_000;
15
+ const STALE_LOCK_MS = 5_000;
16
+
17
+ export class EventLog {
18
+ #seq = 0;
19
+ #initialized = false;
20
+ readonly #path?: string;
21
+ #writeTail: Promise<void> = Promise.resolve();
22
+
23
+ constructor(path?: string) {
24
+ this.#path = path;
25
+ }
26
+
27
+ async append(event: Omit<SupervisorEvent, "seq" | "at">): Promise<SupervisorEvent> {
28
+ const operation = this.#writeTail.then(async () => {
29
+ return this.#path
30
+ ? this.#withFileLock(async () => {
31
+ await this.#initialize();
32
+ await this.#refreshSequence();
33
+ return this.#appendEntry(event);
34
+ })
35
+ : this.#appendEntry(event);
36
+ });
37
+ this.#writeTail = operation.then(() => undefined, () => undefined);
38
+ return operation;
39
+ }
40
+
41
+ async #appendEntry(event: Omit<SupervisorEvent, "seq" | "at">): Promise<SupervisorEvent> {
42
+ const entry: SupervisorEvent = {
43
+ ...redactEvent(event),
44
+ seq: ++this.#seq,
45
+ at: new Date().toISOString(),
46
+ };
47
+ if (this.#path) {
48
+ await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
49
+ await appendFile(this.#path, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
50
+ await chmod(this.#path, 0o600);
51
+ }
52
+ return entry;
53
+ }
54
+
55
+ async #withFileLock<T>(operation: () => Promise<T>): Promise<T> {
56
+ if (!this.#path) return operation();
57
+ const lockPath = `${this.#path}.lock`;
58
+ await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
59
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
60
+ while (true) {
61
+ try {
62
+ await mkdir(lockPath);
63
+ await writeFile(`${lockPath}/owner.json`, JSON.stringify({ pid: process.pid, at: new Date().toISOString() }));
64
+ break;
65
+ } catch (error) {
66
+ if (!(error instanceof Error) || !/EEXIST/u.test(error.message)) throw error;
67
+ if (await this.#removeStaleLock(lockPath)) continue;
68
+ if (Date.now() >= deadline) throw new Error(`event log lock timeout: ${lockPath}`);
69
+ await delay(25);
70
+ }
71
+ }
72
+ try {
73
+ return await operation();
74
+ } finally {
75
+ await rm(lockPath, { recursive: true, force: true });
76
+ }
77
+ }
78
+
79
+ async #removeStaleLock(lockPath: string): Promise<boolean> {
80
+ try {
81
+ const info = await stat(`${lockPath}/owner.json`);
82
+ if (Date.now() - info.mtimeMs < STALE_LOCK_MS) return false;
83
+ let owner: { pid?: unknown };
84
+ try {
85
+ owner = JSON.parse(await readFile(`${lockPath}/owner.json`, "utf8")) as { pid?: unknown };
86
+ } catch {
87
+ await rm(lockPath, { recursive: true, force: true });
88
+ return true;
89
+ }
90
+ if (typeof owner.pid === "number") {
91
+ try {
92
+ process.kill(owner.pid, 0);
93
+ return false;
94
+ } catch (error) {
95
+ if (error instanceof Error && /EPERM/u.test(error.message)) return false;
96
+ }
97
+ }
98
+ await rm(lockPath, { recursive: true, force: true });
99
+ return true;
100
+ } catch (error) {
101
+ if (error instanceof Error && /ENOENT/u.test(error.message)) {
102
+ try {
103
+ const lockInfo = await stat(lockPath);
104
+ if (Date.now() - lockInfo.mtimeMs >= STALE_LOCK_MS) {
105
+ await rm(lockPath, { recursive: true, force: true });
106
+ return true;
107
+ }
108
+ } catch {
109
+ return true;
110
+ }
111
+ }
112
+ return false;
113
+ }
114
+ }
115
+
116
+ async #initialize(): Promise<void> {
117
+ if (this.#initialized) return;
118
+ this.#initialized = true;
119
+ await this.#refreshSequence();
120
+ }
121
+
122
+ async #refreshSequence(): Promise<void> {
123
+ if (!this.#path) return;
124
+ try {
125
+ const contents = await readFile(this.#path, "utf8");
126
+ const lines = contents.split("\n");
127
+ let firstCorruptLine = -1;
128
+ for (let index = 0; index < lines.length; index += 1) {
129
+ if (!lines[index].trim()) continue;
130
+ try {
131
+ JSON.parse(lines[index]);
132
+ } catch {
133
+ firstCorruptLine = index;
134
+ break;
135
+ }
136
+ }
137
+ if (firstCorruptLine >= 0) {
138
+ // A partial write is only safe to recover by removing it and anything
139
+ // after it; otherwise future appends would remain unreplayable JSONL.
140
+ const repaired = `${lines.slice(0, firstCorruptLine).join("\n").replace(/\n+$/u, "")}\n`;
141
+ await writeFile(this.#path, repaired, { mode: 0o600 });
142
+ await chmod(this.#path, 0o600);
143
+ lines.length = firstCorruptLine;
144
+ }
145
+ for (const line of lines) {
146
+ if (!line.trim()) continue;
147
+ const seq = (JSON.parse(line) as { seq?: unknown }).seq;
148
+ if (typeof seq === "number" && Number.isSafeInteger(seq) && seq >= 0) {
149
+ this.#seq = Math.max(this.#seq, seq);
150
+ }
151
+ }
152
+ } catch (error) {
153
+ if (!(error instanceof Error) || !/ENOENT/u.test(error.message)) throw error;
154
+ }
155
+ }
156
+ }
157
+
158
+ function delay(ms: number): Promise<void> {
159
+ return new Promise((resolve) => setTimeout(resolve, ms));
160
+ }
161
+
162
+ function redactEvent<T extends Omit<SupervisorEvent, "seq" | "at">>(event: T): T {
163
+ return redactValue(event, undefined) as T;
164
+ }
165
+
166
+ function redactValue(value: unknown, key: string | undefined): unknown {
167
+ if (typeof value === "string") {
168
+ if (key && /(password|secret|token|api[-_]?key|authorization|credential)/iu.test(key)) return "[REDACTED]";
169
+ return value
170
+ .replace(/\b(sk-ant-[A-Za-z0-9_-]+)\b/gu, "[REDACTED]")
171
+ .replace(/\b(Bearer\s+)[^\s]+/giu, "$1[REDACTED]")
172
+ .replace(/\b((?:ANTHROPIC|OPENAI|AWS)_[A-Z0-9_]*(?:KEY|TOKEN|SECRET))=([^\s]+)/gu, "$1=[REDACTED]");
173
+ }
174
+ if (Array.isArray(value)) return value.map((item) => redactValue(item, key));
175
+ if (value && typeof value === "object") {
176
+ return Object.fromEntries(Object.entries(value).map(([childKey, childValue]) => [childKey, redactValue(childValue, childKey)]));
177
+ }
178
+ return value;
179
+ }
package/src/index.ts ADDED
@@ -0,0 +1,500 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { homedir } from "node:os";
3
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
4
+ import { realpath } from "node:fs/promises";
5
+ import { EventLog } from "./events.ts";
6
+ import { ProcessWorkerAdapter } from "./worker/process-adapter.ts";
7
+ import { Supervisor } from "./supervisor.ts";
8
+ import { evaluateCommand } from "./policy.ts";
9
+ import { HumanWebhookNotifier } from "./notifications.ts";
10
+ import { loadSupervisorEnvironment } from "./config.ts";
11
+ import { DecisionSessionStore, type DecisionSessionRecord } from "./decision-session-store.ts";
12
+
13
+ /**
14
+ * Pi Claude Supervisor.
15
+ *
16
+ * Each task gets an independent Supervisor and Worker process. Multiple task
17
+ * sessions may run concurrently, but active sessions must use different
18
+ * working directories so workers cannot silently overwrite one another.
19
+ */
20
+ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
21
+ loadSupervisorEnvironment();
22
+ const automation = process.env.PI_CLAUDE_SUPERVISOR_MODE === "auto" || process.env.PI_CLAUDE_SUPERVISOR_AUTOMATION === "1";
23
+ const adapter = new ProcessWorkerAdapter({
24
+ // Automatic decisions require Claude's structured event stream. The pipe
25
+ // transport remains available for manual/compatibility sessions.
26
+ mode: automation || process.env.PI_CLAUDE_SUPERVISOR_TRANSPORT === "jsonl" ? "claude-jsonl" : "process-pipe",
27
+ });
28
+ const humanWebhook = new HumanWebhookNotifier({
29
+ url: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_URL,
30
+ format: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_FORMAT === "wecom" ? "wecom" : "generic",
31
+ secret: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_SECRET,
32
+ });
33
+ const stateDir = process.env.PI_CLAUDE_SUPERVISOR_STATE_DIR ?? join(homedir(), ".pi", "agent", "claude-supervisor");
34
+ const events = new EventLog(join(stateDir, "events.jsonl"));
35
+ const decisionStore = new DecisionSessionStore(join(stateDir, "decision-sessions"));
36
+ const sessions = new Map<string, Supervisor>();
37
+ const reservedCwds = new Map<string, string>();
38
+ const pendingCwds = new Set<string>();
39
+ const pendingStarts = new Set<Promise<void>>();
40
+ const pendingStartSessions = new Set<Supervisor>();
41
+ let activeTaskId: string | undefined;
42
+ let shuttingDown = false;
43
+ let shutdownPromise: Promise<void> | undefined;
44
+
45
+ const notify = (ctx: ExtensionContext, message: string, type: "info" | "warning" = "info") => {
46
+ if (ctx.hasUI) ctx.ui.notify(message, type);
47
+ };
48
+ const activeSessions = () => [...sessions.entries()].filter(([, session]) =>
49
+ ["starting", "running", "waiting", "paused"].includes(session.state));
50
+ const releaseSettledReservations = async (): Promise<void> => {
51
+ for (const [taskId, session] of sessions) {
52
+ if (!["completed", "stopped", "failed"].includes(session.state)) continue;
53
+ if (!session.handle) {
54
+ reservedCwds.delete(taskId);
55
+ continue;
56
+ }
57
+ try {
58
+ const status = await adapter.getStatus(session.handle);
59
+ if (!status.running && status.processGroupCleaned === true && !status.cleanupError) reservedCwds.delete(taskId);
60
+ } catch {
61
+ // Keep the reservation when cleanup status cannot be confirmed.
62
+ }
63
+ }
64
+ };
65
+ const stopSession = async (session: Supervisor, reason: string): Promise<void> => {
66
+ let lifecycleError: unknown;
67
+ try {
68
+ await session.stop(reason);
69
+ } catch (error) {
70
+ lifecycleError = error;
71
+ }
72
+
73
+ const handle = session.handle;
74
+ if (!handle) {
75
+ if (lifecycleError) throw lifecycleError;
76
+ return;
77
+ }
78
+
79
+ const deadline = Date.now() + 5_000;
80
+ let cleanupError: unknown;
81
+ while (Date.now() <= deadline) {
82
+ try {
83
+ const status = await adapter.getStatus(handle);
84
+ if (!status.running && status.processGroupCleaned === true) {
85
+ if (lifecycleError) throw lifecycleError;
86
+ return;
87
+ }
88
+ } catch (error) {
89
+ cleanupError = error;
90
+ }
91
+ try {
92
+ await adapter.killProcessGroup(handle, "shutdown cleanup retry");
93
+ } catch (error) {
94
+ cleanupError = error;
95
+ }
96
+ await delay(25);
97
+ }
98
+
99
+ if (lifecycleError) throw lifecycleError;
100
+ throw cleanupError instanceof Error
101
+ ? cleanupError
102
+ : new Error(`worker cleanup did not complete before shutdown deadline: ${handle.id}`);
103
+ }
104
+
105
+ pi.registerCommand("supervise", {
106
+ description: "Manage policy-gated Claude workers and concurrent task sessions",
107
+ handler: async (args, ctx) => {
108
+ try {
109
+ const tokens = args.trim() ? args.trim().split(/\s+/u) : [];
110
+ const [operation = "status", ...rest] = tokens;
111
+ let message = "";
112
+ if (operation === "start") {
113
+ const task = rest.join(" ").trim();
114
+ if (!task) throw new Error("Usage: /supervise start <task>");
115
+ const [command, ...workerArgs] = parseCommand(process.env.PI_CLAUDE_SUPERVISOR_WORKER ?? "claude");
116
+ if (!command) throw new Error("PI_CLAUDE_SUPERVISOR_WORKER must contain an executable");
117
+ const policy = evaluateCommand(command, workerArgs);
118
+ let approval: { actor: "human"; reason: string } | undefined;
119
+ if (policy.decision === "deny") throw new Error(`Worker command denied: ${policy.reason}`);
120
+ if (policy.decision === "review") {
121
+ if (!ctx.hasUI) throw new Error(`Worker command requires interactive approval: ${policy.reason}`);
122
+ const approved = await ctx.ui.confirm(
123
+ "Approve Claude worker command?",
124
+ `${redactText([command, ...workerArgs].join(" "))}\n\nReason: ${redactText(policy.reason)}`,
125
+ );
126
+ if (!approved) throw new Error("Worker command not approved");
127
+ approval = { actor: "human", reason: policy.reason };
128
+ }
129
+ if (shuttingDown) throw new Error("Pi session is shutting down");
130
+ const cwdKey = await canonicalCwd(ctx.cwd);
131
+ await releaseSettledReservations();
132
+ if (shuttingDown) throw new Error("Pi session is shutting down");
133
+ const reservedPaths = [...pendingCwds, ...reservedCwds.values()];
134
+ if (reservedPaths.some((reserved) => pathsOverlap(reserved, cwdKey))) {
135
+ throw new Error("An active, starting, or unreaped worker uses an overlapping cwd; use a separate worktree for concurrent sessions");
136
+ }
137
+ pendingCwds.add(cwdKey);
138
+ const session = new Supervisor(adapter, events, {
139
+ onHumanRequired: async (notice) => {
140
+ if (ctx.hasUI) ctx.ui.notify(`Claude Worker needs human intervention: ${notice.reason}`, "warning");
141
+ if (humanWebhook.enabled) {
142
+ try {
143
+ await humanWebhook.notify(notice);
144
+ } catch (error) {
145
+ console.error(`pi-claude-supervisor human webhook failed: ${error instanceof Error ? error.message : String(error)}`);
146
+ }
147
+ } else {
148
+ console.error(`pi-claude-supervisor human intervention required: ${notice.reason}`);
149
+ }
150
+ },
151
+ });
152
+ pendingStartSessions.add(session);
153
+ const startOperation = (async () => {
154
+ try {
155
+ const handle = await session.start({
156
+ task,
157
+ cwd: ctx.cwd,
158
+ command,
159
+ args: workerArgs,
160
+ env: selectedWorkerEnvironment(),
161
+ approval,
162
+ automation,
163
+ decisionSessionDir: decisionStore.directory,
164
+ onDecisionSessionReady: (info) => decisionStore.save({
165
+ taskId: info.taskId,
166
+ task: info.task,
167
+ cwd: info.cwd,
168
+ command,
169
+ args: workerArgs,
170
+ approval,
171
+ decisionSessionFile: info.sessionFile,
172
+ maxTurns: info.maxTurns,
173
+ deadlineMs: info.deadlineMs,
174
+ noOutputTimeoutMs: info.noOutputTimeoutMs,
175
+ startedAt: info.startedAt,
176
+ turn: info.turn,
177
+ state: "active",
178
+ }),
179
+ onDecisionSessionProgress: (info) => decisionStore.update(info.taskId, { turn: info.turn }),
180
+ onDecisionSessionClosed: (taskId) => decisionStore.close(taskId),
181
+ });
182
+ const taskId = session.task?.taskId;
183
+ if (!taskId) throw new Error("worker started without a task id");
184
+ // Register immediately after spawn so shutdown can retry cleanup if
185
+ // the first stop attempt fails.
186
+ sessions.set(taskId, session);
187
+ reservedCwds.set(taskId, cwdKey);
188
+ activeTaskId = taskId;
189
+ if (shuttingDown) {
190
+ try {
191
+ await stopSession(session, "Pi session shutdown during worker start");
192
+ sessions.delete(taskId);
193
+ reservedCwds.delete(taskId);
194
+ } finally {
195
+ if (session.state !== "stopped") {
196
+ // Keep the session registered for the shutdown retry below.
197
+ sessions.set(taskId, session);
198
+ reservedCwds.set(taskId, cwdKey);
199
+ }
200
+ }
201
+ throw new Error("Pi session shut down during worker start");
202
+ }
203
+ message = `Worker started: task=${taskId} worker=${handle.id} (pid ${handle.pid ?? "unknown"}); transport=${adapter.capabilities().transport}`;
204
+ } catch (error) {
205
+ // Register failed starts before the promise settles, so shutdown
206
+ // cannot snapshot sessions before a returned handle is retained.
207
+ const failedTaskId = session.task?.taskId;
208
+ if (failedTaskId && session.handle) {
209
+ sessions.set(failedTaskId, session);
210
+ reservedCwds.set(failedTaskId, cwdKey);
211
+ activeTaskId = failedTaskId;
212
+ }
213
+ throw error;
214
+ }
215
+ })();
216
+ pendingStarts.add(startOperation);
217
+ try {
218
+ await startOperation;
219
+ } catch (error) {
220
+ // Preserve a failed startup in the registry when the adapter
221
+ // returned a handle but lifecycle/event setup failed.
222
+ const failedTaskId = session.task?.taskId;
223
+ if (failedTaskId && session.handle) {
224
+ sessions.set(failedTaskId, session);
225
+ reservedCwds.set(failedTaskId, cwdKey);
226
+ activeTaskId = failedTaskId;
227
+ }
228
+ throw error;
229
+ } finally {
230
+ pendingStarts.delete(startOperation);
231
+ pendingStartSessions.delete(session);
232
+ pendingCwds.delete(cwdKey);
233
+ }
234
+ } else if (operation === "recover") {
235
+ const taskId = rest[0];
236
+ if (!taskId) throw new Error("Usage: /supervise recover <task-id>");
237
+ if (shuttingDown) throw new Error("Pi session is shutting down");
238
+ if (sessions.has(taskId)) throw new Error(`Task session is already loaded: ${taskId}`);
239
+ const record = await decisionStore.load(taskId);
240
+ if (!record || record.state !== "active") throw new Error(`No recoverable Decision Worker session: ${taskId}`);
241
+ if (!await decisionStore.sessionFileExists(taskId)) throw new Error(`Decision Worker session file is missing or unsafe: ${taskId}`);
242
+ if (record.maxTurns > 0 && record.turn >= record.maxTurns) throw new Error(`Cannot recover task after its turn budget was exhausted: ${taskId}`);
243
+ if (record.deadlineMs > 0 && Date.now() - Date.parse(record.startedAt) >= record.deadlineMs) throw new Error(`Cannot recover task after its wall-clock deadline: ${taskId}`);
244
+ const cwdKey = await canonicalCwd(record.cwd);
245
+ await releaseSettledReservations();
246
+ if (shuttingDown) throw new Error("Pi session is shutting down");
247
+ const reservedPaths = [...pendingCwds, ...reservedCwds.values()];
248
+ if (reservedPaths.some((reserved) => pathsOverlap(reserved, cwdKey))) {
249
+ throw new Error("An active, starting, or unreaped worker uses an overlapping cwd; use a separate worktree for recovery");
250
+ }
251
+ const policy = evaluateCommand(record.command, record.args);
252
+ if (policy.decision === "deny") throw new Error(`Worker command denied: ${policy.reason}`);
253
+ let approval = record.approval;
254
+ if (policy.decision === "review" && !approval) {
255
+ if (!ctx.hasUI) throw new Error(`Worker command requires interactive approval: ${policy.reason}`);
256
+ const approved = await ctx.ui.confirm(
257
+ "Approve recovered Claude worker command?",
258
+ `${redactText([record.command, ...record.args].join(" "))}\n\nReason: ${redactText(policy.reason)}`,
259
+ );
260
+ if (!approved) throw new Error("Worker command not approved");
261
+ approval = { actor: "human", reason: policy.reason };
262
+ }
263
+ pendingCwds.add(cwdKey);
264
+ const session = new Supervisor(adapter, events, {
265
+ onHumanRequired: async (notice) => {
266
+ if (ctx.hasUI) ctx.ui.notify(`Claude Worker needs human intervention: ${notice.reason}`, "warning");
267
+ if (humanWebhook.enabled) {
268
+ try { await humanWebhook.notify(notice); }
269
+ catch (error) { console.error(`pi-claude-supervisor human webhook failed: ${error instanceof Error ? error.message : String(error)}`); }
270
+ } else {
271
+ console.error(`pi-claude-supervisor human intervention required: ${notice.reason}`);
272
+ }
273
+ },
274
+ });
275
+ pendingStartSessions.add(session);
276
+ const recoveryOperation = (async () => {
277
+ try {
278
+ const handle = await session.start({
279
+ taskId: record.taskId,
280
+ // Claude session resume is not supported by this adapter. Start
281
+ // idle so recovery never replays the original task; the operator
282
+ // must explicitly send the next instruction.
283
+ task: record.task,
284
+ initialInput: "",
285
+ cwd: record.cwd,
286
+ command: record.command,
287
+ args: record.args,
288
+ env: selectedWorkerEnvironment(),
289
+ approval,
290
+ automation: true,
291
+ maxTurns: record.maxTurns,
292
+ deadlineMs: record.deadlineMs,
293
+ noOutputTimeoutMs: record.noOutputTimeoutMs,
294
+ startedAt: record.startedAt,
295
+ initialTurn: record.turn,
296
+ decisionSessionFile: record.decisionSessionFile,
297
+ decisionSessionDir: decisionStore.directory,
298
+ onDecisionSessionReady: (info) => decisionStore.save({
299
+ taskId: info.taskId,
300
+ task: info.task,
301
+ cwd: info.cwd,
302
+ command: record.command,
303
+ args: record.args,
304
+ approval,
305
+ decisionSessionFile: info.sessionFile,
306
+ maxTurns: info.maxTurns,
307
+ deadlineMs: info.deadlineMs,
308
+ noOutputTimeoutMs: info.noOutputTimeoutMs,
309
+ startedAt: info.startedAt,
310
+ turn: info.turn,
311
+ state: "active",
312
+ }),
313
+ onDecisionSessionProgress: (info) => decisionStore.update(info.taskId, { turn: info.turn }),
314
+ onDecisionSessionClosed: (closedTaskId) => decisionStore.close(closedTaskId),
315
+ });
316
+ sessions.set(record.taskId, session);
317
+ reservedCwds.set(record.taskId, cwdKey);
318
+ activeTaskId = record.taskId;
319
+ await session.takeover();
320
+ if (shuttingDown) {
321
+ await stopSession(session, "Pi session shutdown during recovery");
322
+ throw new Error("Pi session shut down during recovery");
323
+ }
324
+ message = `Worker recovered idle: task=${record.taskId} worker=${handle.id}; original task was not replayed; send an explicit continuation, then use resume-auto`;
325
+ } catch (error) {
326
+ if (session.handle) {
327
+ sessions.set(record.taskId, session);
328
+ reservedCwds.set(record.taskId, cwdKey);
329
+ activeTaskId = record.taskId;
330
+ }
331
+ throw error;
332
+ }
333
+ })();
334
+ pendingStarts.add(recoveryOperation);
335
+ try {
336
+ await recoveryOperation;
337
+ } finally {
338
+ pendingStarts.delete(recoveryOperation);
339
+ pendingStartSessions.delete(session);
340
+ pendingCwds.delete(cwdKey);
341
+ }
342
+ } else if (operation === "sessions") {
343
+ const recoverable = await decisionStore.list({ activeOnly: true });
344
+ message = formatSessions(sessions, recoverable);
345
+ } else if (operation === "status") {
346
+ const { session, sessionId } = resolveSession(sessions, activeTaskId, rest, true);
347
+ message = session
348
+ ? `task=${sessionId} state=${session.state} worker=${session.handle?.id ?? "none"}`
349
+ : formatSessions(sessions, await decisionStore.list({ activeOnly: true }));
350
+ } else if (operation === "capabilities") {
351
+ message = JSON.stringify(adapter.capabilities(), null, 2);
352
+ } else if (operation === "poll" && rest[0] === "all") {
353
+ const reports = await Promise.all(activeSessions().map(async ([taskId, session]) => {
354
+ const result = await session.poll();
355
+ return `task=${taskId} ${formatStatus(result.status)}${result.output.length ? `\n${result.output.map((chunk) => `[${chunk.stream}] ${chunk.text}`).join("")}` : ""}`;
356
+ }));
357
+ message = reports.join("\n\n") || "No active sessions.";
358
+ } else {
359
+ const strictSelector = ["poll", "pause", "resume", "verify", "approve", "takeover", "resume-auto"].includes(operation);
360
+ const { session, sessionId, remaining } = resolveSession(sessions, activeTaskId, rest, strictSelector);
361
+ if (!session || !sessionId) throw new Error("No active task session. Use /supervise start <task>");
362
+ activeTaskId = sessionId;
363
+ if (operation === "poll") {
364
+ const result = await session.poll();
365
+ message = `${formatStatus(result.status)}\n${result.output.map((chunk) => `[${chunk.stream}] ${chunk.text}`).join("")}`.trim();
366
+ } else if (operation === "send") {
367
+ const text = remaining.join(" ").trim();
368
+ if (!text) throw new Error("Usage: /supervise send [taskId] <message>");
369
+ await session.send(text); message = `Message sent to ${sessionId}.`;
370
+ } else if (operation === "pause") {
371
+ await session.pause(); message = `Worker paused: ${sessionId}.`;
372
+ } else if (operation === "resume") {
373
+ await session.resume(); message = `Worker resumed: ${sessionId}.`;
374
+ } else if (operation === "stop") {
375
+ await session.stop(remaining.join(" ") || "human requested stop"); message = `Worker stopped: ${sessionId}.`;
376
+ } else if (operation === "verify") {
377
+ const result = await session.verify();
378
+ message = `${result.ok ? "Verification passed" : "Verification failed"}: ${result.command}\n${result.output}`.trim();
379
+ } else if (operation === "approve") {
380
+ const behavior = remaining[0];
381
+ if (behavior !== "allow" && behavior !== "deny") throw new Error("Usage: /supervise approve [taskId] <allow|deny> [requestId]");
382
+ await session.approvePermission(behavior, remaining[1]);
383
+ message = `Permission ${behavior} decision sent to ${sessionId}.`;
384
+ } else if (operation === "takeover") {
385
+ await session.takeover(); message = `Human takeover enabled: ${sessionId}.`;
386
+ } else if (operation === "resume-auto") {
387
+ await session.resumeAutomation(); message = `Automatic decisions resumed: ${sessionId}.`;
388
+ } else {
389
+ throw new Error("Usage: /supervise start|recover|sessions|status|poll [all|taskId]|send [taskId]|pause [taskId]|resume [taskId]|stop [taskId]|verify [taskId]|approve [taskId] <allow|deny>|takeover [taskId]|resume-auto [taskId]|capabilities");
390
+ }
391
+ }
392
+ notify(ctx, message);
393
+ } catch (error) {
394
+ notify(ctx, error instanceof Error ? error.message : String(error), "warning");
395
+ }
396
+ },
397
+ });
398
+
399
+ const shutdown = (exitCode?: number): Promise<void> => {
400
+ if (shutdownPromise) return shutdownPromise;
401
+ shuttingDown = true;
402
+ shutdownPromise = (async () => {
403
+ const pending = [...pendingStarts];
404
+ await Promise.race([Promise.allSettled(pending), delay(5_000)]);
405
+ if (pendingStarts.size > 0) {
406
+ await Promise.allSettled([...pendingStartSessions].map((session) => session.abortStart("Pi session shutdown during startup")));
407
+ await Promise.race([Promise.allSettled([...pendingStarts]), delay(5_000)]);
408
+ }
409
+ const results = await Promise.allSettled([...sessions.values()].map((session) => stopSession(session, "Pi session shutdown")));
410
+ const failures = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
411
+ if (failures.length > 0) {
412
+ for (const failure of failures) console.error(`pi-claude-supervisor shutdown cleanup failed: ${failure.reason instanceof Error ? failure.reason.message : String(failure.reason)}`);
413
+ if (exitCode === undefined) process.exitCode = 1;
414
+ }
415
+ if (exitCode !== undefined) process.exitCode = exitCode;
416
+ })();
417
+ return shutdownPromise;
418
+ };
419
+ const onSignal = (signal: NodeJS.Signals) => {
420
+ void shutdown().finally(() => process.exit(signal === "SIGTERM" ? 143 : 130));
421
+ };
422
+ process.once("SIGTERM", onSignal);
423
+ process.once("SIGINT", onSignal);
424
+ pi.on("session_shutdown", async () => {
425
+ await shutdown();
426
+ });
427
+ }
428
+
429
+ function resolveSession(
430
+ sessions: Map<string, Supervisor>,
431
+ activeTaskId: string | undefined,
432
+ requestedArgs: string[],
433
+ strictSelector = false,
434
+ ): { session?: Supervisor; sessionId?: string; remaining: string[] } {
435
+ const requestedId = requestedArgs[0];
436
+ if (requestedId && sessions.has(requestedId)) return { session: sessions.get(requestedId), sessionId: requestedId, remaining: requestedArgs.slice(1) };
437
+ if (requestedId && (strictSelector || looksLikeTaskId(requestedId))) {
438
+ throw new Error(`Unknown task session: ${requestedId}`);
439
+ }
440
+ if (activeTaskId) return { session: sessions.get(activeTaskId), sessionId: activeTaskId, remaining: requestedArgs };
441
+ return { remaining: requestedArgs };
442
+ }
443
+
444
+ function looksLikeTaskId(value: string): boolean {
445
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(value);
446
+ }
447
+
448
+ async function canonicalCwd(cwd: string): Promise<string> {
449
+ return realpath(cwd);
450
+ }
451
+
452
+ function pathsOverlap(first: string, second: string): boolean {
453
+ return isWithin(first, second) || isWithin(second, first);
454
+ }
455
+
456
+ function isWithin(parent: string, child: string): boolean {
457
+ const childRelative = relative(parent, child);
458
+ return childRelative === "" || (childRelative !== ".." && !childRelative.startsWith(`..${sep}`) && !isAbsolute(childRelative));
459
+ }
460
+
461
+ function formatSessions(sessions: Map<string, Supervisor>, recoverable: DecisionSessionRecord[] = []): string {
462
+ const active = [...sessions.entries()]
463
+ .map(([taskId, session]) => `${taskId} state=${session.state} cwd=${session.task?.cwd ?? "-"} worker=${session.handle?.id ?? "-"}`);
464
+ const pending = recoverable
465
+ .filter((record) => !sessions.has(record.taskId))
466
+ .map((record) => `${record.taskId} state=recoverable cwd=${record.cwd} worker=-`);
467
+ return [...active, ...pending].join("\n") || "No task sessions.";
468
+ }
469
+
470
+ function selectedWorkerEnvironment(): NodeJS.ProcessEnv {
471
+ const result: NodeJS.ProcessEnv = {};
472
+ const names = (process.env.PI_CLAUDE_SUPERVISOR_WORKER_ENV ?? "")
473
+ .split(",")
474
+ .map((name) => name.trim())
475
+ .filter(Boolean);
476
+ for (const name of names) {
477
+ if (process.env[name] !== undefined) result[name] = process.env[name];
478
+ }
479
+ return result;
480
+ }
481
+
482
+ function parseCommand(value: string): string[] {
483
+ const parts = value.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/gu) ?? [];
484
+ return parts.map((part) => part.replace(/^("|')|("|')$/gu, ""));
485
+ }
486
+
487
+ function formatStatus(status: Awaited<ReturnType<ProcessWorkerAdapter["getStatus"]>>): string {
488
+ return `running=${status.running} active=${status.activeRequests ?? "unknown"} lastInput=${status.lastInputAt ?? "-"} exit=${status.exitCode ?? "-"} signal=${status.signal ?? "-"} reason=${status.exitReason ?? "-"} cleanup=${status.processGroupCleaned ?? "unknown"}`;
489
+ }
490
+
491
+ function delay(ms: number): Promise<void> {
492
+ return new Promise((resolve) => setTimeout(resolve, ms));
493
+ }
494
+
495
+ function redactText(value: string): string {
496
+ return value
497
+ .replace(/\b(sk-ant-[A-Za-z0-9_-]+)\b/gu, "[REDACTED]")
498
+ .replace(/\b(Bearer\s+)[^\s]+/giu, "$1[REDACTED]")
499
+ .replace(/(--?(?:token|api[-_]?key|secret|password|authorization)(?:=|\s+))[^\s]+/giu, "$1[REDACTED]");
500
+ }