pi-claude-supervisor 0.2.2 → 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/src/index.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { homedir } from "node:os";
3
- import { dirname, isAbsolute, join, relative, sep } from "node:path";
4
+ import { join } from "node:path";
4
5
  import { realpath } from "node:fs/promises";
5
6
  import { EventLog } from "./events.ts";
7
+ import { redactSensitive } from "./redaction.ts";
6
8
  import { ProcessWorkerAdapter } from "./worker/process-adapter.ts";
9
+ import { TmuxWorkerAdapter, attachCommand } from "./worker/tmux-adapter.ts";
7
10
  import { Supervisor } from "./supervisor.ts";
8
11
  import { evaluateCommand } from "./policy.ts";
9
12
  import { HumanWebhookNotifier } from "./notifications.ts";
10
13
  import { loadSupervisorEnvironment } from "./config.ts";
11
14
  import { DecisionSessionStore, type DecisionSessionRecord } from "./decision-session-store.ts";
15
+ import { CwdLeaseStore, type CwdLeaseHandle, pathsOverlap, workerIdentity } from "./cwd-lease.ts";
12
16
 
13
17
  /**
14
18
  * Pi Claude Supervisor.
@@ -20,20 +24,38 @@ import { DecisionSessionStore, type DecisionSessionRecord } from "./decision-ses
20
24
  export default function piClaudeSupervisor(pi: ExtensionAPI): void {
21
25
  loadSupervisorEnvironment();
22
26
  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
- });
27
+ const stateDir = process.env.PI_CLAUDE_SUPERVISOR_STATE_DIR ?? join(homedir(), ".pi", "agent", "claude-supervisor");
28
+ const configuredTransport = process.env.PI_CLAUDE_SUPERVISOR_TRANSPORT;
29
+ const transport = configuredTransport ?? (automation ? "jsonl" : "process-pipe");
30
+ const cgroupMode = process.env.PI_CLAUDE_SUPERVISOR_CGROUP_MODE ?? "auto";
31
+ if (!(["process-pipe", "jsonl", "tmux"] as string[]).includes(transport)) {
32
+ throw new Error(`Unsupported PI_CLAUDE_SUPERVISOR_TRANSPORT: ${transport}; expected process-pipe, jsonl, or tmux`);
33
+ }
34
+ if (!["off", "auto", "required"].includes(cgroupMode)) {
35
+ throw new Error(`Unsupported PI_CLAUDE_SUPERVISOR_CGROUP_MODE: ${cgroupMode}; expected off, auto, or required`);
36
+ }
37
+ if (transport === "tmux" && cgroupMode === "required") {
38
+ throw new Error("PI_CLAUDE_SUPERVISOR_CGROUP_MODE=required is unsupported with tmux; use process-pipe/jsonl or set cgroup mode to auto/off");
39
+ }
40
+ const adapter = transport === "tmux"
41
+ ? new TmuxWorkerAdapter({ stateDir })
42
+ : new ProcessWorkerAdapter({
43
+ // Automatic decisions require Claude's structured event stream. The pipe
44
+ // transport remains available for manual/compatibility sessions.
45
+ mode: automation || transport === "jsonl" ? "claude-jsonl" : "process-pipe",
46
+ cgroupMode: cgroupMode as "off" | "auto" | "required",
47
+ });
28
48
  const humanWebhook = new HumanWebhookNotifier({
29
49
  url: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_URL,
30
50
  format: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_FORMAT === "wecom" ? "wecom" : "generic",
31
51
  secret: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_SECRET,
32
52
  });
33
- const stateDir = process.env.PI_CLAUDE_SUPERVISOR_STATE_DIR ?? join(homedir(), ".pi", "agent", "claude-supervisor");
34
53
  const events = new EventLog(join(stateDir, "events.jsonl"));
35
54
  const decisionStore = new DecisionSessionStore(join(stateDir, "decision-sessions"));
55
+ const cwdLeaseStore = new CwdLeaseStore(process.env.PI_CLAUDE_SUPERVISOR_CWD_LEASE_DIR ?? join(homedir(), ".pi", "agent", "claude-supervisor", "cwd-leases"));
36
56
  const sessions = new Map<string, Supervisor>();
57
+ const cwdLeases = new Map<string, CwdLeaseHandle>();
58
+ const cleanupRequiredTasks = new Set<string>();
37
59
  const reservedCwds = new Map<string, string>();
38
60
  const pendingCwds = new Set<string>();
39
61
  const pendingStarts = new Set<Promise<void>>();
@@ -43,26 +65,58 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
43
65
  let shutdownPromise: Promise<void> | undefined;
44
66
 
45
67
  const notify = (ctx: ExtensionContext, message: string, type: "info" | "warning" = "info") => {
46
- if (ctx.hasUI) ctx.ui.notify(message, type);
68
+ if (ctx.hasUI) ctx.ui.notify(redactText(message), type);
47
69
  };
48
70
  const activeSessions = () => [...sessions.entries()].filter(([, session]) =>
49
71
  ["starting", "running", "waiting", "paused"].includes(session.state));
72
+ const releaseLease = async (taskId: string): Promise<boolean> => {
73
+ const lease = cwdLeases.get(taskId);
74
+ if (!lease) return false;
75
+ try {
76
+ await lease.release();
77
+ cwdLeases.delete(taskId);
78
+ return true;
79
+ } catch (error) {
80
+ console.error(`pi-claude-supervisor cwd lease release failed: ${redactText(error instanceof Error ? error.message : String(error))}`);
81
+ return false;
82
+ }
83
+ };
50
84
  const releaseSettledReservations = async (): Promise<void> => {
51
85
  for (const [taskId, session] of sessions) {
52
86
  if (!["completed", "stopped", "failed"].includes(session.state)) continue;
53
87
  if (!session.handle) {
54
- reservedCwds.delete(taskId);
88
+ if (await releaseLease(taskId)) {
89
+ reservedCwds.delete(taskId);
90
+ cleanupRequiredTasks.delete(taskId);
91
+ }
55
92
  continue;
56
93
  }
57
94
  try {
58
95
  const status = await adapter.getStatus(session.handle);
59
- if (!status.running && status.processGroupCleaned === true && !status.cleanupError) reservedCwds.delete(taskId);
96
+ if (!status.running && status.processGroupCleaned === true && !status.cleanupError && (!status.cgroupError || status.cgroupRequired === false)) {
97
+ if (await releaseLease(taskId)) {
98
+ reservedCwds.delete(taskId);
99
+ cleanupRequiredTasks.delete(taskId);
100
+ }
101
+ }
60
102
  } catch {
61
103
  // Keep the reservation when cleanup status cannot be confirmed.
62
104
  }
63
105
  }
64
106
  };
65
- const stopSession = async (session: Supervisor, reason: string): Promise<void> => {
107
+ const stopSession = async (session: Supervisor, reason: string, releasePersistent = false): Promise<void> => {
108
+ const handle = session.handle;
109
+ const persistent = adapter.capabilities().persistentSession && Boolean(handle);
110
+ const taskId = session.task?.taskId;
111
+ const adoptedPersistent = persistent && handle?.ownership === "adopted";
112
+ const healthyPersistent = persistent && !["failed", "completed", "stopped"].includes(session.state);
113
+ const cleanupRequired = taskId ? cleanupRequiredTasks.has(taskId) : false;
114
+ const markCleanupRequired = () => { if (taskId) cleanupRequiredTasks.add(taskId); };
115
+ if (adoptedPersistent || (releasePersistent && healthyPersistent && !cleanupRequired)) {
116
+ await session.release(reason);
117
+ return;
118
+ }
119
+ if (!handle && ["failed", "completed", "stopped"].includes(session.state)) return;
66
120
  let lifecycleError: unknown;
67
121
  try {
68
122
  await session.stop(reason);
@@ -70,9 +124,11 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
70
124
  lifecycleError = error;
71
125
  }
72
126
 
73
- const handle = session.handle;
74
127
  if (!handle) {
75
- if (lifecycleError) throw lifecycleError;
128
+ if (lifecycleError) {
129
+ markCleanupRequired();
130
+ throw lifecycleError;
131
+ }
76
132
  return;
77
133
  }
78
134
 
@@ -81,8 +137,12 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
81
137
  while (Date.now() <= deadline) {
82
138
  try {
83
139
  const status = await adapter.getStatus(handle);
84
- if (!status.running && status.processGroupCleaned === true) {
85
- if (lifecycleError) throw lifecycleError;
140
+ if (!status.running && status.processGroupCleaned === true && !status.cleanupError && (!status.cgroupError || status.cgroupRequired === false)) {
141
+ if (lifecycleError) {
142
+ markCleanupRequired();
143
+ throw lifecycleError;
144
+ }
145
+ if (taskId) cleanupRequiredTasks.delete(taskId);
86
146
  return;
87
147
  }
88
148
  } catch (error) {
@@ -96,6 +156,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
96
156
  await delay(25);
97
157
  }
98
158
 
159
+ markCleanupRequired();
99
160
  if (lifecycleError) throw lifecycleError;
100
161
  throw cleanupError instanceof Error
101
162
  ? cleanupError
@@ -109,9 +170,11 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
109
170
  const tokens = args.trim() ? args.trim().split(/\s+/u) : [];
110
171
  const [operation = "status", ...rest] = tokens;
111
172
  let message = "";
112
- if (operation === "start") {
173
+ if (operation === "start" || operation === "adopt-tmux") {
174
+ const tmuxSession = operation === "adopt-tmux" ? rest.shift() : undefined;
113
175
  const task = rest.join(" ").trim();
114
- if (!task) throw new Error("Usage: /supervise start <task>");
176
+ if (!task) throw new Error(operation === "adopt-tmux" ? "Usage: /supervise adopt-tmux <tmux-session> <task>" : "Usage: /supervise start <task>");
177
+ if (operation === "adopt-tmux" && adapter.capabilities().transport !== "tmux") throw new Error("/supervise adopt-tmux requires PI_CLAUDE_SUPERVISOR_TRANSPORT=tmux");
115
178
  const [command, ...workerArgs] = parseCommand(process.env.PI_CLAUDE_SUPERVISOR_WORKER ?? "claude");
116
179
  if (!command) throw new Error("PI_CLAUDE_SUPERVISOR_WORKER must contain an executable");
117
180
  const policy = evaluateCommand(command, workerArgs);
@@ -134,32 +197,54 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
134
197
  if (reservedPaths.some((reserved) => pathsOverlap(reserved, cwdKey))) {
135
198
  throw new Error("An active, starting, or unreaped worker uses an overlapping cwd; use a separate worktree for concurrent sessions");
136
199
  }
200
+ const taskId = randomUUID();
201
+ const lease = await cwdLeaseStore.acquire(cwdKey, taskId, adapter.capabilities().transport, tmuxSession
202
+ ? { handoff: { sessionName: tmuxSession, tmuxSocket: process.env.PI_CLAUDE_SUPERVISOR_TMUX_SOCKET } }
203
+ : {});
204
+ cwdLeases.set(taskId, lease);
205
+ if (shuttingDown) {
206
+ await releaseLease(taskId);
207
+ throw new Error("Pi session is shutting down");
208
+ }
137
209
  pendingCwds.add(cwdKey);
138
210
  const session = new Supervisor(adapter, events, {
139
211
  onHumanRequired: async (notice) => {
140
- if (ctx.hasUI) ctx.ui.notify(`Claude Worker needs human intervention: ${notice.reason}`, "warning");
212
+ if (ctx.hasUI) notify(ctx, `Claude Worker needs human intervention: ${notice.reason}`, "warning");
141
213
  if (humanWebhook.enabled) {
142
214
  try {
143
215
  await humanWebhook.notify(notice);
144
216
  } catch (error) {
145
- console.error(`pi-claude-supervisor human webhook failed: ${error instanceof Error ? error.message : String(error)}`);
217
+ console.error(`pi-claude-supervisor human webhook failed: ${redactText(error instanceof Error ? error.message : String(error))}`);
146
218
  }
147
219
  } else {
148
- console.error(`pi-claude-supervisor human intervention required: ${notice.reason}`);
220
+ console.error(`pi-claude-supervisor human intervention required: ${redactText(notice.reason)}`);
149
221
  }
150
222
  },
151
223
  });
152
224
  pendingStartSessions.add(session);
225
+ let startupCleanupCompleted = false;
153
226
  const startOperation = (async () => {
154
227
  try {
155
228
  const handle = await session.start({
229
+ taskId,
156
230
  task,
157
- cwd: ctx.cwd,
231
+ cwd: cwdKey,
158
232
  command,
159
233
  args: workerArgs,
160
234
  env: selectedWorkerEnvironment(),
161
235
  approval,
162
236
  automation,
237
+ tmuxSession,
238
+ tmuxSocket: process.env.PI_CLAUDE_SUPERVISOR_TMUX_SOCKET,
239
+ tmuxExpectedIdentity: tmuxSession && lease.record.worker ? {
240
+ pid: lease.record.worker.pid,
241
+ startTime: lease.record.worker.startTime,
242
+ tmuxTarget: lease.record.worker.tmuxTarget,
243
+ tmuxPaneId: lease.record.worker.tmuxPaneId,
244
+ paneStartTime: lease.record.worker.paneStartTime,
245
+ paneCommand: lease.record.worker.paneCommand,
246
+ } : undefined,
247
+ sendInitialInput: !tmuxSession,
163
248
  decisionSessionDir: decisionStore.directory,
164
249
  onDecisionSessionReady: (info) => decisionStore.save({
165
250
  taskId: info.taskId,
@@ -179,36 +264,70 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
179
264
  onDecisionSessionProgress: (info) => decisionStore.update(info.taskId, { turn: info.turn }),
180
265
  onDecisionSessionClosed: (taskId) => decisionStore.close(taskId),
181
266
  });
182
- const taskId = session.task?.taskId;
183
- if (!taskId) throw new Error("worker started without a task id");
267
+ const startedTaskId = session.task?.taskId;
268
+ if (!startedTaskId) throw new Error("worker started without a task id");
269
+ try {
270
+ await lease.updateWorker({
271
+ transport: adapter.capabilities().transport,
272
+ ...(await workerIdentity(handle)),
273
+ sessionName: handle.sessionName,
274
+ tmuxSocket: handle.tmuxSocket,
275
+ ownership: handle.ownership,
276
+ });
277
+ } catch (error) {
278
+ const registrationError = error instanceof Error ? error : new Error(String(error));
279
+ if (handle.ownership !== "adopted") {
280
+ try {
281
+ await stopSession(session, "cwd lease metadata registration failed");
282
+ startupCleanupCompleted = await releaseLease(taskId);
283
+ } catch (cleanupError) {
284
+ const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
285
+ registrationError.message = `${registrationError.message}; worker cleanup failed: ${message}`;
286
+ Object.defineProperty(registrationError, "workerCleanupRequired", { value: true, enumerable: false });
287
+ }
288
+ }
289
+ throw registrationError;
290
+ }
184
291
  // Register immediately after spawn so shutdown can retry cleanup if
185
292
  // the first stop attempt fails.
186
- sessions.set(taskId, session);
187
- reservedCwds.set(taskId, cwdKey);
188
- activeTaskId = taskId;
293
+ sessions.set(startedTaskId, session);
294
+ reservedCwds.set(startedTaskId, cwdKey);
295
+ activeTaskId = startedTaskId;
189
296
  if (shuttingDown) {
190
297
  try {
191
- await stopSession(session, "Pi session shutdown during worker start");
192
- sessions.delete(taskId);
193
- reservedCwds.delete(taskId);
298
+ await stopSession(session, "Pi session shutdown during worker start", true);
299
+ sessions.delete(startedTaskId);
300
+ reservedCwds.delete(startedTaskId);
194
301
  } finally {
195
302
  if (session.state !== "stopped") {
196
303
  // Keep the session registered for the shutdown retry below.
197
- sessions.set(taskId, session);
198
- reservedCwds.set(taskId, cwdKey);
304
+ sessions.set(startedTaskId, session);
305
+ reservedCwds.set(startedTaskId, cwdKey);
199
306
  }
200
307
  }
201
308
  throw new Error("Pi session shut down during worker start");
202
309
  }
203
- message = `Worker started: task=${taskId} worker=${handle.id} (pid ${handle.pid ?? "unknown"}); transport=${adapter.capabilities().transport}`;
310
+ const attach = handle.sessionName ? ` attach=${attachCommand(handle)}` : "";
311
+ message = `${tmuxSession ? "Tmux worker adopted" : "Worker started"}: task=${startedTaskId} worker=${handle.id} (pid ${handle.pid ?? "unknown"}); transport=${adapter.capabilities().transport}${attach}`;
204
312
  } catch (error) {
205
313
  // Register failed starts before the promise settles, so shutdown
206
314
  // cannot snapshot sessions before a returned handle is retained.
207
315
  const failedTaskId = session.task?.taskId;
208
- if (failedTaskId && session.handle) {
316
+ const cleanupRequired = requiresWorkerCleanup(error);
317
+ if (cleanupRequired && failedTaskId) cleanupRequiredTasks.add(failedTaskId);
318
+ const retainHandle = !startupCleanupCompleted && failedTaskId && session.handle
319
+ && (cleanupRequired || session.handle.ownership === "adopted");
320
+ if (retainHandle) {
209
321
  sessions.set(failedTaskId, session);
210
322
  reservedCwds.set(failedTaskId, cwdKey);
211
323
  activeTaskId = failedTaskId;
324
+ } else if (!cleanupRequired && !startupCleanupCompleted) {
325
+ const released = await releaseLease(taskId);
326
+ if (!released && failedTaskId) {
327
+ sessions.set(failedTaskId, session);
328
+ reservedCwds.set(failedTaskId, cwdKey);
329
+ activeTaskId = failedTaskId;
330
+ }
212
331
  }
213
332
  throw error;
214
333
  }
@@ -220,10 +339,21 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
220
339
  // Preserve a failed startup in the registry when the adapter
221
340
  // returned a handle but lifecycle/event setup failed.
222
341
  const failedTaskId = session.task?.taskId;
223
- if (failedTaskId && session.handle) {
342
+ const cleanupRequired = requiresWorkerCleanup(error);
343
+ if (cleanupRequired && failedTaskId) cleanupRequiredTasks.add(failedTaskId);
344
+ const retainHandle = !startupCleanupCompleted && failedTaskId && session.handle
345
+ && (cleanupRequired || session.handle.ownership === "adopted");
346
+ if (retainHandle) {
224
347
  sessions.set(failedTaskId, session);
225
348
  reservedCwds.set(failedTaskId, cwdKey);
226
349
  activeTaskId = failedTaskId;
350
+ } else if (!cleanupRequired && !startupCleanupCompleted) {
351
+ const released = await releaseLease(taskId);
352
+ if (!released && failedTaskId) {
353
+ sessions.set(failedTaskId, session);
354
+ reservedCwds.set(failedTaskId, cwdKey);
355
+ activeTaskId = failedTaskId;
356
+ }
227
357
  }
228
358
  throw error;
229
359
  } finally {
@@ -260,19 +390,26 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
260
390
  if (!approved) throw new Error("Worker command not approved");
261
391
  approval = { actor: "human", reason: policy.reason };
262
392
  }
393
+ const lease = await cwdLeaseStore.acquire(cwdKey, record.taskId, adapter.capabilities().transport);
394
+ cwdLeases.set(record.taskId, lease);
395
+ if (shuttingDown) {
396
+ await releaseLease(record.taskId);
397
+ throw new Error("Pi session is shutting down");
398
+ }
263
399
  pendingCwds.add(cwdKey);
264
400
  const session = new Supervisor(adapter, events, {
265
401
  onHumanRequired: async (notice) => {
266
- if (ctx.hasUI) ctx.ui.notify(`Claude Worker needs human intervention: ${notice.reason}`, "warning");
402
+ if (ctx.hasUI) notify(ctx, `Claude Worker needs human intervention: ${notice.reason}`, "warning");
267
403
  if (humanWebhook.enabled) {
268
404
  try { await humanWebhook.notify(notice); }
269
- catch (error) { console.error(`pi-claude-supervisor human webhook failed: ${error instanceof Error ? error.message : String(error)}`); }
405
+ catch (error) { console.error(`pi-claude-supervisor human webhook failed: ${redactText(error instanceof Error ? error.message : String(error))}`); }
270
406
  } else {
271
- console.error(`pi-claude-supervisor human intervention required: ${notice.reason}`);
407
+ console.error(`pi-claude-supervisor human intervention required: ${redactText(notice.reason)}`);
272
408
  }
273
409
  },
274
410
  });
275
411
  pendingStartSessions.add(session);
412
+ let recoveryCleanupCompleted = false;
276
413
  const recoveryOperation = (async () => {
277
414
  try {
278
415
  const handle = await session.start({
@@ -282,7 +419,8 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
282
419
  // must explicitly send the next instruction.
283
420
  task: record.task,
284
421
  initialInput: "",
285
- cwd: record.cwd,
422
+ sendInitialInput: false,
423
+ cwd: cwdKey,
286
424
  command: record.command,
287
425
  args: record.args,
288
426
  env: selectedWorkerEnvironment(),
@@ -313,20 +451,51 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
313
451
  onDecisionSessionProgress: (info) => decisionStore.update(info.taskId, { turn: info.turn }),
314
452
  onDecisionSessionClosed: (closedTaskId) => decisionStore.close(closedTaskId),
315
453
  });
454
+ try {
455
+ await lease.updateWorker({
456
+ transport: adapter.capabilities().transport,
457
+ ...(await workerIdentity(handle)),
458
+ sessionName: handle.sessionName,
459
+ tmuxSocket: handle.tmuxSocket,
460
+ ownership: handle.ownership,
461
+ });
462
+ } catch (error) {
463
+ const registrationError = error instanceof Error ? error : new Error(String(error));
464
+ try {
465
+ await stopSession(session, "cwd lease metadata registration failed");
466
+ recoveryCleanupCompleted = await releaseLease(record.taskId);
467
+ } catch (cleanupError) {
468
+ const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
469
+ registrationError.message = `${registrationError.message}; worker cleanup failed: ${message}`;
470
+ Object.defineProperty(registrationError, "workerCleanupRequired", { value: true, enumerable: false });
471
+ }
472
+ throw registrationError;
473
+ }
316
474
  sessions.set(record.taskId, session);
317
475
  reservedCwds.set(record.taskId, cwdKey);
318
476
  activeTaskId = record.taskId;
319
477
  await session.takeover();
320
478
  if (shuttingDown) {
321
- await stopSession(session, "Pi session shutdown during recovery");
479
+ await stopSession(session, "Pi session shutdown during recovery", true);
322
480
  throw new Error("Pi session shut down during recovery");
323
481
  }
324
482
  message = `Worker recovered idle: task=${record.taskId} worker=${handle.id}; original task was not replayed; send an explicit continuation, then use resume-auto`;
325
483
  } catch (error) {
326
- if (session.handle) {
484
+ const cleanupRequired = requiresWorkerCleanup(error);
485
+ if (cleanupRequired) cleanupRequiredTasks.add(record.taskId);
486
+ const retainHandle = !recoveryCleanupCompleted && session.handle
487
+ && (cleanupRequired || session.handle.ownership === "adopted");
488
+ if (retainHandle) {
327
489
  sessions.set(record.taskId, session);
328
490
  reservedCwds.set(record.taskId, cwdKey);
329
491
  activeTaskId = record.taskId;
492
+ } else if (!cleanupRequired && !recoveryCleanupCompleted) {
493
+ const released = await releaseLease(record.taskId);
494
+ if (!released) {
495
+ sessions.set(record.taskId, session);
496
+ reservedCwds.set(record.taskId, cwdKey);
497
+ activeTaskId = record.taskId;
498
+ }
330
499
  }
331
500
  throw error;
332
501
  }
@@ -372,9 +541,12 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
372
541
  } else if (operation === "resume") {
373
542
  await session.resume(); message = `Worker resumed: ${sessionId}.`;
374
543
  } else if (operation === "stop") {
375
- await session.stop(remaining.join(" ") || "human requested stop"); message = `Worker stopped: ${sessionId}.`;
544
+ await stopSession(session, remaining.join(" ") || "human requested stop");
545
+ await releaseSettledReservations();
546
+ message = `Worker stopped: ${sessionId}.`;
376
547
  } else if (operation === "verify") {
377
548
  const result = await session.verify();
549
+ await releaseSettledReservations();
378
550
  message = `${result.ok ? "Verification passed" : "Verification failed"}: ${result.command}\n${result.output}`.trim();
379
551
  } else if (operation === "approve") {
380
552
  const behavior = remaining[0];
@@ -386,7 +558,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
386
558
  } else if (operation === "resume-auto") {
387
559
  await session.resumeAutomation(); message = `Automatic decisions resumed: ${sessionId}.`;
388
560
  } 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");
561
+ throw new Error("Usage: /supervise start|adopt-tmux|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
562
  }
391
563
  }
392
564
  notify(ctx, message);
@@ -401,15 +573,26 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
401
573
  shuttingDown = true;
402
574
  shutdownPromise = (async () => {
403
575
  const pending = [...pendingStarts];
576
+ let startupFailures: PromiseRejectedResult[] = [];
404
577
  await Promise.race([Promise.allSettled(pending), delay(5_000)]);
405
578
  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)]);
579
+ const abortResults = await Promise.allSettled([...pendingStartSessions].map((session) => session.abortStart("Pi session shutdown during startup")));
580
+ startupFailures = abortResults.filter((result): result is PromiseRejectedResult => result.status === "rejected");
581
+ // Do not snapshot sessions or release leases until every startup has
582
+ // settled. Otherwise a provider that resolves after a fixed timeout
583
+ // could register a live handle after shutdown cleanup had completed.
584
+ // Adapters expose bounded cancellation; an uncooperative provider must
585
+ // keep shutdown fail-closed rather than allowing a late worker escape.
586
+ while (pendingStarts.size > 0) {
587
+ const results = await Promise.allSettled([...pendingStarts]);
588
+ startupFailures.push(...results.filter((result): result is PromiseRejectedResult => result.status === "rejected"));
589
+ }
408
590
  }
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");
591
+ const results = await Promise.allSettled([...sessions.values()].map((session) => stopSession(session, "Pi session shutdown", true)));
592
+ await releaseSettledReservations();
593
+ const failures = [...startupFailures, ...results.filter((result): result is PromiseRejectedResult => result.status === "rejected")];
411
594
  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)}`);
595
+ for (const failure of failures) console.error(`pi-claude-supervisor shutdown cleanup failed: ${redactText(failure.reason instanceof Error ? failure.reason.message : String(failure.reason))}`);
413
596
  if (exitCode === undefined) process.exitCode = 1;
414
597
  }
415
598
  if (exitCode !== undefined) process.exitCode = exitCode;
@@ -449,13 +632,8 @@ async function canonicalCwd(cwd: string): Promise<string> {
449
632
  return realpath(cwd);
450
633
  }
451
634
 
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));
635
+ function requiresWorkerCleanup(error: unknown): boolean {
636
+ return Boolean(error && typeof error === "object" && (error as { workerCleanupRequired?: unknown }).workerCleanupRequired === true);
459
637
  }
460
638
 
461
639
  function formatSessions(sessions: Map<string, Supervisor>, recoverable: DecisionSessionRecord[] = []): string {
@@ -493,8 +671,5 @@ function delay(ms: number): Promise<void> {
493
671
  }
494
672
 
495
673
  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]");
674
+ return String(redactSensitive(value));
500
675
  }
@@ -1,5 +1,6 @@
1
1
  import { createHmac, randomUUID } from "node:crypto";
2
2
  import type { HumanInterventionNotice } from "./supervisor.ts";
3
+ import { redactSensitive } from "./redaction.ts";
3
4
 
4
5
  export interface HumanWebhookOptions {
5
6
  url?: string;
@@ -46,38 +47,36 @@ function toGeneric(notice: HumanInterventionNotice): Record<string, unknown> {
46
47
  eventId: randomUUID(),
47
48
  event: "human_intervention_required",
48
49
  occurredAt: new Date().toISOString(),
49
- task: { id: notice.taskId, goal: notice.task, cwd: notice.cwd },
50
- worker: { id: notice.workerId },
51
- reason: notice.reason,
52
- question: notice.question,
53
- permission: notice.permission ? { ...notice.permission, input: sanitize(notice.permission.input) } : undefined,
50
+ task: { id: sanitize(notice.taskId), goal: sanitize(notice.task), cwd: sanitize(notice.cwd) },
51
+ worker: { id: sanitize(notice.workerId) },
52
+ reason: sanitize(notice.reason),
53
+ question: sanitize(notice.question),
54
+ permission: notice.permission ? sanitize(notice.permission) : undefined,
54
55
  actions: ["approve_or_deny_permission", "send_instruction", "stop_worker", "takeover"],
55
56
  note: "This is an outbound notification. Use the Pi session or a separately authenticated callback service to approve actions.",
56
57
  };
57
58
  }
58
59
 
59
60
  function toWeCom(notice: HumanInterventionNotice): Record<string, unknown> {
60
- const permission = notice.permission ? `\n工具: ${notice.permission.toolName}\n请求 ID: ${notice.permission.requestId}` : "";
61
- const question = notice.question ? `\n问题: ${notice.question}` : "";
61
+ const permission = notice.permission ? `\n工具: ${safeText(notice.permission.toolName)}\n请求 ID: ${safeText(notice.permission.requestId)}` : "";
62
+ const question = notice.question ? `\n问题: ${safeText(notice.question)}` : "";
62
63
  return {
63
64
  msgtype: "markdown",
64
65
  markdown: {
65
- content: `### Claude Supervisor 需要人工介入\n> 任务: ${escapeMarkdown(notice.task)}\n> Task ID: ${notice.taskId}\n> 原因: ${escapeMarkdown(notice.reason)}${escapeMarkdown(question)}${escapeMarkdown(permission)}\n\n请在 Pi 中执行对应的 approve/deny、send、stop 或 takeover 操作。`,
66
+ content: `### Claude Supervisor 需要人工介入\n> 任务: ${safeText(notice.task)}\n> Task ID: ${safeText(notice.taskId)}\n> 原因: ${safeText(notice.reason)}${escapeMarkdown(question)}${escapeMarkdown(permission)}\n\n请在 Pi 中执行对应的 approve/deny、send、stop 或 takeover 操作。`,
66
67
  },
67
68
  };
68
69
  }
69
70
 
70
71
  function sanitize(value: unknown, key?: string): unknown {
71
- if (key && /(password|secret|token|api[-_]?key|authorization|credential)/iu.test(key)) return "[REDACTED]";
72
- if (typeof value === "string") {
73
- return value
74
- .replace(/\\b(sk-ant-[A-Za-z0-9_-]+)\\b/gu, "[REDACTED]")
75
- .replace(/\\b(Bearer\\s+)[^\\s]+/giu, "$1[REDACTED]")
76
- .slice(0, 4_000);
77
- }
72
+ if (typeof value === "string") return String(redactSensitive(value, key)).slice(0, 4_000);
78
73
  if (Array.isArray(value)) return value.slice(0, 50).map((item) => sanitize(item, key));
79
74
  if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).slice(0, 50).map(([childKey, childValue]) => [childKey, sanitize(childValue, childKey)]));
80
- return value;
75
+ return redactSensitive(value, key);
76
+ }
77
+
78
+ function safeText(value: unknown): string {
79
+ return String(sanitize(value));
81
80
  }
82
81
 
83
82
  function escapeMarkdown(value: string): string {
@@ -0,0 +1,20 @@
1
+ const sensitiveKeyPattern = /(password|secret|token|api[-_]?key|authorization|credential)/iu;
2
+
3
+ /** Recursively redact credential-shaped values before persistence or model prompts. */
4
+ export function redactSensitive(value: unknown, key?: string): unknown {
5
+ if (key && sensitiveKeyPattern.test(key)) return "[REDACTED]";
6
+ if (typeof value === "string") {
7
+ return value
8
+ .replace(/\b(sk-ant-[A-Za-z0-9_-]+)\b/gu, "[REDACTED]")
9
+ .replace(/\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{20,}|npm_[A-Za-z0-9]{20,})\b/gu, "[REDACTED]")
10
+ .replace(/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/gu, "[REDACTED]")
11
+ .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[REDACTED]")
12
+ .replace(/\b(Bearer\s+)[^\s]+/giu, "$1[REDACTED]")
13
+ .replace(/\b((?:AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN|[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)))=([^\s]+)/gu, "$1=[REDACTED]")
14
+ .replace(/\b((?:authorization|x-api-key|api-key)\s*:\s*)[^\s]+/giu, "$1[REDACTED]")
15
+ .replace(/(--?(?:token|api[-_]?key|secret|password|authorization)(?:=|\s+))[^\s]+/giu, "$1[REDACTED]");
16
+ }
17
+ if (Array.isArray(value)) return value.map((item) => redactSensitive(item, key));
18
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([childKey, childValue]) => [childKey, redactSensitive(childValue, childKey)]));
19
+ return value;
20
+ }