pi-claude-supervisor 0.5.0 → 0.5.2

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
@@ -2,12 +2,13 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
+ import { accessSync, chmodSync, constants as fsConstants, lstatSync, mkdirSync } from "node:fs";
5
6
  import { readFile, realpath } from "node:fs/promises";
6
7
  import { EventLog } from "./events.ts";
7
8
  import { redactSensitive } from "./redaction.ts";
8
9
  import { ProcessWorkerAdapter } from "./worker/process-adapter.ts";
9
10
  import { TmuxWorkerAdapter, attachCommand } from "./worker/tmux-adapter.ts";
10
- import { Supervisor } from "./supervisor.ts";
11
+ import { Supervisor, type DecisionSessionClosedInfo, type SupervisorProgress } from "./supervisor.ts";
11
12
  import { evaluateCommand } from "./policy.ts";
12
13
  import { HumanWebhookNotifier } from "./notifications.ts";
13
14
  import { loadSupervisorEnvironment } from "./config.ts";
@@ -15,7 +16,7 @@ import { DecisionSessionStore, type DecisionSessionRecord } from "./decision-ses
15
16
  import { CwdLeaseStore, type CwdLeaseHandle, pathsOverlap, workerIdentity } from "./cwd-lease.ts";
16
17
  import { normalizeTaskSpec } from "./acceptance.ts";
17
18
  import { PiReadOnlyReviewer } from "./reviewer.ts";
18
- import type { TaskSpec } from "./types.ts";
19
+ import type { TaskSpec, WorkerHandle } from "./types.ts";
19
20
 
20
21
  /**
21
22
  * Pi Claude Supervisor.
@@ -40,6 +41,9 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
40
41
  if (transport === "tmux" && cgroupMode === "required") {
41
42
  throw new Error("PI_CLAUDE_SUPERVISOR_CGROUP_MODE=required is unsupported with tmux; use process-pipe/jsonl or set cgroup mode to auto/off");
42
43
  }
44
+ if (automation && transport === "process-pipe") {
45
+ throw new Error("automatic supervision requires PI_CLAUDE_SUPERVISOR_TRANSPORT=jsonl or tmux; explicit process-pipe is manual-only");
46
+ }
43
47
  const adapter = transport === "tmux"
44
48
  ? new TmuxWorkerAdapter({ stateDir })
45
49
  : new ProcessWorkerAdapter({
@@ -53,9 +57,12 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
53
57
  format: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_FORMAT === "wecom" ? "wecom" : "generic",
54
58
  secret: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_SECRET,
55
59
  });
60
+ const leaseDir = process.env.PI_CLAUDE_SUPERVISOR_CWD_LEASE_DIR ?? join(homedir(), ".pi", "agent", "claude-supervisor", "cwd-leases");
61
+ assertRuntimeDirectory(stateDir, "state");
62
+ assertRuntimeDirectory(leaseDir, "lease");
56
63
  const events = new EventLog(join(stateDir, "events.jsonl"));
57
64
  const decisionStore = new DecisionSessionStore(join(stateDir, "decision-sessions"));
58
- const cwdLeaseStore = new CwdLeaseStore(process.env.PI_CLAUDE_SUPERVISOR_CWD_LEASE_DIR ?? join(homedir(), ".pi", "agent", "claude-supervisor", "cwd-leases"));
65
+ const cwdLeaseStore = new CwdLeaseStore(leaseDir);
59
66
  const reviewer = automation ? new PiReadOnlyReviewer() : undefined;
60
67
  const sessions = new Map<string, Supervisor>();
61
68
  const cwdLeases = new Map<string, CwdLeaseHandle>();
@@ -69,9 +76,23 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
69
76
  let shutdownPromise: Promise<void> | undefined;
70
77
  let detachedLeaseSweepTimer: NodeJS.Timeout | undefined;
71
78
 
79
+ const handleDecisionSessionClosed = async (taskId: string, info: DecisionSessionClosedInfo): Promise<void> => {
80
+ const record = await decisionStore.load(taskId);
81
+ if (!record || record.state !== "active") return;
82
+ const terminal = info.reason === "completed" || info.reason === "human_stop";
83
+ if (terminal && info.cleanupConfirmed) {
84
+ await decisionStore.close(taskId);
85
+ return;
86
+ }
87
+ await decisionStore.markRecoveryInterrupted(taskId);
88
+ };
89
+
72
90
  const notify = (ctx: ExtensionContext, message: string, type: "info" | "warning" = "info") => {
73
91
  if (ctx.hasUI) ctx.ui.notify(redactText(message), type);
74
92
  };
93
+ const progress = (ctx: ExtensionContext) => (info: SupervisorProgress) => {
94
+ notify(ctx, `Worker progress: task=${info.taskId} phase=${info.phase} ${info.message}`, info.phase === "human" || info.phase === "failed" ? "warning" : "info");
95
+ };
75
96
  const activeSessions = () => [...sessions.entries()].filter(([, session]) =>
76
97
  ["starting", "running", "waiting", "paused"].includes(session.state));
77
98
  const releaseLease = async (taskId: string): Promise<boolean> => {
@@ -121,7 +142,18 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
121
142
  });
122
143
  }, 1_000);
123
144
  detachedLeaseSweepTimer.unref();
124
- const stopSession = async (session: Supervisor, reason: string, releasePersistent = false): Promise<void> => {
145
+ const workerCleanupConfirmed = async (handle: NonNullable<Supervisor["handle"]>): Promise<boolean> => {
146
+ try {
147
+ const status = await adapter.getStatus(handle);
148
+ return !status.running
149
+ && status.processGroupCleaned === true
150
+ && !status.cleanupError
151
+ && (!status.cgroupError || status.cgroupRequired === false);
152
+ } catch {
153
+ return false;
154
+ }
155
+ };
156
+ const stopSession = async (session: Supervisor, reason: string, releasePersistent = false, preserveDecisionSession = false): Promise<void> => {
125
157
  const handle = session.handle;
126
158
  const persistent = adapter.capabilities().persistentSession && Boolean(handle);
127
159
  const taskId = session.task?.taskId;
@@ -136,7 +168,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
136
168
  if (!handle && ["failed", "completed", "stopped"].includes(session.state)) return;
137
169
  let lifecycleError: unknown;
138
170
  try {
139
- await session.stop(reason);
171
+ await session.stop(reason, { preserveDecisionSession });
140
172
  } catch (error) {
141
173
  lifecycleError = error;
142
174
  }
@@ -253,6 +285,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
253
285
  try {
254
286
  const handle = await session.start({
255
287
  taskId,
288
+ onProgress: progress(ctx),
256
289
  task: goal,
257
290
  spec,
258
291
  cwd: cwdKey,
@@ -273,26 +306,34 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
273
306
  } : undefined,
274
307
  sendInitialInput: !tmuxSession,
275
308
  decisionSessionDir: decisionStore.directory,
276
- onDecisionSessionReady: (info) => decisionStore.save({
277
- taskId: info.taskId,
278
- task: info.task,
279
- spec: info.spec,
280
- cwd: info.cwd,
281
- command,
282
- args: workerArgs,
283
- approval,
284
- decisionSessionFile: info.sessionFile,
285
- maxTurns: info.maxTurns,
286
- deadlineMs: info.deadlineMs,
287
- noOutputTimeoutMs: info.noOutputTimeoutMs,
288
- startedAt: info.startedAt,
289
- turn: info.turn,
290
- repairRound: info.repairRound,
291
- ...(info.lastFindingSignature ? { lastFindingSignature: info.lastFindingSignature } : {}),
292
- state: "active",
293
- }),
309
+ onDecisionSessionReady: async (info) => {
310
+ const current = await decisionStore.load(info.taskId);
311
+ await decisionStore.save({
312
+ taskId: info.taskId,
313
+ task: info.task,
314
+ spec: info.spec,
315
+ cwd: info.cwd,
316
+ command,
317
+ args: workerArgs,
318
+ approval,
319
+ decisionSessionFile: info.sessionFile,
320
+ maxTurns: info.maxTurns,
321
+ deadlineMs: info.deadlineMs,
322
+ noOutputTimeoutMs: info.noOutputTimeoutMs,
323
+ startedAt: info.startedAt,
324
+ turn: info.turn,
325
+ repairRound: info.repairRound,
326
+ ...(info.lastFindingSignature ? { lastFindingSignature: info.lastFindingSignature } : {}),
327
+ state: "active",
328
+ recoveryState: current?.recoveryState ?? "ready",
329
+ recoveryAttempt: current?.recoveryAttempt ?? 0,
330
+ ...(current?.recoveryOwnerPid !== undefined ? { recoveryOwnerPid: current.recoveryOwnerPid } : {}),
331
+ ...(current?.recoveryOwnerStartTime ? { recoveryOwnerStartTime: current.recoveryOwnerStartTime } : {}),
332
+ ...(current?.recoveryWorker ? { recoveryWorker: current.recoveryWorker } : {}),
333
+ });
334
+ },
294
335
  onDecisionSessionProgress: (info) => decisionStore.update(info.taskId, { turn: info.turn, repairRound: info.repairRound, ...(info.lastFindingSignature ? { lastFindingSignature: info.lastFindingSignature } : {}) }),
295
- onDecisionSessionClosed: (taskId) => decisionStore.close(taskId),
336
+ onDecisionSessionClosed: handleDecisionSessionClosed,
296
337
  });
297
338
  const startedTaskId = session.task?.taskId;
298
339
  if (!startedTaskId) throw new Error("worker started without a task id");
@@ -318,6 +359,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
318
359
  }
319
360
  throw registrationError;
320
361
  }
362
+ if (lease.replacedTaskId) await decisionStore.close(lease.replacedTaskId);
321
363
  // Register immediately after spawn so shutdown can retry cleanup if
322
364
  // the first stop attempt fails.
323
365
  sessions.set(startedTaskId, session);
@@ -325,7 +367,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
325
367
  activeTaskId = startedTaskId;
326
368
  if (shuttingDown) {
327
369
  try {
328
- await stopSession(session, "Pi session shutdown during worker start", true);
370
+ await stopSession(session, "Pi session shutdown during worker start", true, true);
329
371
  sessions.delete(startedTaskId);
330
372
  reservedCwds.delete(startedTaskId);
331
373
  } finally {
@@ -392,12 +434,15 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
392
434
  pendingCwds.delete(cwdKey);
393
435
  }
394
436
  } else if (operation === "recover") {
395
- const taskId = rest[0];
396
- if (!taskId) throw new Error("Usage: /supervise recover <task-id>");
437
+ const takeover = rest.includes("--takeover");
438
+ const taskId = rest.find((value) => value !== "--takeover");
439
+ if (!taskId || rest.some((value) => value !== "--takeover" && value !== taskId)) throw new Error("Usage: /supervise recover [--takeover] <task-id>");
397
440
  if (shuttingDown) throw new Error("Pi session is shutting down");
398
441
  if (sessions.has(taskId)) throw new Error(`Task session is already loaded: ${taskId}`);
399
442
  const record = await decisionStore.load(taskId);
400
443
  if (!record || record.state !== "active") throw new Error(`No recoverable Decision Worker session: ${taskId}`);
444
+ const staleRecovery = !["ready", "interrupted"].includes(record.recoveryState);
445
+ if (staleRecovery && !takeover) throw new Error(`Decision Worker recovery is stale (${record.recoveryState}); retry with --takeover only after verifying the old Worker is gone: ${taskId}`);
401
446
  if (!await decisionStore.sessionFileExists(taskId)) throw new Error(`Decision Worker session file is missing or unsafe: ${taskId}`);
402
447
  if (record.maxTurns > 0 && record.turn >= record.maxTurns) throw new Error(`Cannot recover task after its turn budget was exhausted: ${taskId}`);
403
448
  if (record.deadlineMs > 0 && Date.now() - Date.parse(record.startedAt) >= record.deadlineMs) throw new Error(`Cannot recover task after its wall-clock deadline: ${taskId}`);
@@ -420,11 +465,28 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
420
465
  if (!approved) throw new Error("Worker command not approved");
421
466
  approval = { actor: "human", reason: policy.reason };
422
467
  }
423
- const lease = await cwdLeaseStore.acquire(cwdKey, record.taskId, adapter.capabilities().transport);
468
+ const lease = await cwdLeaseStore.acquire(cwdKey, record.taskId, adapter.capabilities().transport, takeover
469
+ ? {
470
+ takeover: {
471
+ taskId: record.taskId,
472
+ ...(staleRecovery ? { beforeReplace: () => decisionStore.reconcileStaleRecovery(record.taskId) } : {}),
473
+ },
474
+ }
475
+ : {});
476
+ if (staleRecovery && lease.replacedTaskId !== record.taskId) {
477
+ await lease.release();
478
+ throw new Error(`Stale Decision Worker recovery has no verified old lease to take over: ${record.taskId}`);
479
+ }
424
480
  cwdLeases.set(record.taskId, lease);
425
- if (shuttingDown) {
481
+ let recoveryClaimed = false;
482
+ try {
483
+ await decisionStore.beginRecovery(record.taskId);
484
+ recoveryClaimed = true;
485
+ if (shuttingDown) throw new Error("Pi session is shutting down");
486
+ } catch (error) {
487
+ if (recoveryClaimed) await decisionStore.markRecoveryInterrupted(record.taskId).catch(() => {});
426
488
  await releaseLease(record.taskId);
427
- throw new Error("Pi session is shutting down");
489
+ throw error;
428
490
  }
429
491
  pendingCwds.add(cwdKey);
430
492
  const session = new Supervisor(adapter, events, {
@@ -440,11 +502,12 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
440
502
  },
441
503
  });
442
504
  pendingStartSessions.add(session);
443
- let recoveryCleanupCompleted = false;
444
505
  const recoveryOperation = (async () => {
506
+ let startedHandle: WorkerHandle | undefined;
445
507
  try {
446
508
  const handle = await session.start({
447
509
  taskId: record.taskId,
510
+ onProgress: progress(ctx),
448
511
  // Claude session resume is not supported by this adapter. Start
449
512
  // idle so recovery never replays the original task; the operator
450
513
  // must explicitly send the next instruction.
@@ -467,72 +530,100 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
467
530
  initialFindingSignature: record.lastFindingSignature,
468
531
  decisionSessionFile: record.decisionSessionFile,
469
532
  decisionSessionDir: decisionStore.directory,
470
- onDecisionSessionReady: (info) => decisionStore.save({
471
- taskId: info.taskId,
472
- task: info.task,
473
- spec: info.spec,
474
- cwd: info.cwd,
475
- command: record.command,
476
- args: record.args,
477
- approval,
478
- decisionSessionFile: info.sessionFile,
479
- maxTurns: info.maxTurns,
480
- deadlineMs: info.deadlineMs,
481
- noOutputTimeoutMs: info.noOutputTimeoutMs,
482
- startedAt: info.startedAt,
483
- turn: info.turn,
484
- repairRound: info.repairRound,
485
- ...(info.lastFindingSignature ? { lastFindingSignature: info.lastFindingSignature } : {}),
486
- state: "active",
487
- }),
533
+ onDecisionSessionReady: async (info) => {
534
+ const current = await decisionStore.load(info.taskId);
535
+ if (!current || current.state !== "active") throw new Error("Decision Worker recovery record disappeared before session registration");
536
+ await decisionStore.save({
537
+ taskId: info.taskId,
538
+ task: info.task,
539
+ spec: info.spec,
540
+ cwd: info.cwd,
541
+ command: record.command,
542
+ args: record.args,
543
+ approval,
544
+ decisionSessionFile: info.sessionFile,
545
+ maxTurns: info.maxTurns,
546
+ deadlineMs: info.deadlineMs,
547
+ noOutputTimeoutMs: info.noOutputTimeoutMs,
548
+ startedAt: info.startedAt,
549
+ turn: info.turn,
550
+ repairRound: info.repairRound,
551
+ ...(info.lastFindingSignature ? { lastFindingSignature: info.lastFindingSignature } : {}),
552
+ state: "active",
553
+ recoveryState: current.recoveryState,
554
+ recoveryAttempt: current.recoveryAttempt,
555
+ ...(current.recoveryOwnerPid !== undefined ? { recoveryOwnerPid: current.recoveryOwnerPid } : {}),
556
+ ...(current.recoveryOwnerStartTime ? { recoveryOwnerStartTime: current.recoveryOwnerStartTime } : {}),
557
+ ...(current.recoveryWorker ? { recoveryWorker: current.recoveryWorker } : {}),
558
+ });
559
+ },
488
560
  onDecisionSessionProgress: (info) => decisionStore.update(info.taskId, { turn: info.turn, repairRound: info.repairRound, ...(info.lastFindingSignature ? { lastFindingSignature: info.lastFindingSignature } : {}) }),
489
- onDecisionSessionClosed: (closedTaskId) => decisionStore.close(closedTaskId),
561
+ onDecisionSessionClosed: handleDecisionSessionClosed,
490
562
  });
491
- try {
492
- await lease.updateWorker({
493
- transport: adapter.capabilities().transport,
494
- ...(await workerIdentity(handle)),
495
- sessionName: handle.sessionName,
496
- tmuxSocket: handle.tmuxSocket,
497
- ownership: handle.ownership,
498
- });
499
- } catch (error) {
500
- const registrationError = error instanceof Error ? error : new Error(String(error));
501
- try {
502
- await stopSession(session, "cwd lease metadata registration failed");
503
- recoveryCleanupCompleted = await releaseLease(record.taskId);
504
- } catch (cleanupError) {
505
- const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
506
- registrationError.message = `${registrationError.message}; worker cleanup failed: ${message}`;
507
- Object.defineProperty(registrationError, "workerCleanupRequired", { value: true, enumerable: false });
508
- }
509
- throw registrationError;
563
+ startedHandle = handle;
564
+ await lease.updateWorker({
565
+ transport: adapter.capabilities().transport,
566
+ ...(await workerIdentity(handle)),
567
+ sessionName: handle.sessionName,
568
+ tmuxSocket: handle.tmuxSocket,
569
+ ownership: handle.ownership,
570
+ });
571
+ await decisionStore.recordRecoveryWorker(record.taskId, {
572
+ id: handle.id,
573
+ pid: handle.pid,
574
+ startedAt: handle.startedAt,
575
+ });
576
+ const registered = await decisionStore.load(record.taskId);
577
+ if (!registered || registered.state !== "active" || registered.recoveryState !== "registered" || registered.recoveryWorker?.id !== handle.id) {
578
+ throw new Error("Recovered Worker registration was not durably confirmed");
510
579
  }
511
580
  sessions.set(record.taskId, session);
512
581
  reservedCwds.set(record.taskId, cwdKey);
513
582
  activeTaskId = record.taskId;
514
583
  await session.takeover();
584
+ await decisionStore.markRecoveryIdle(record.taskId);
585
+ const idle = await decisionStore.load(record.taskId);
586
+ if (!idle || idle.state !== "active" || idle.recoveryState !== "recovered_idle" || idle.recoveryWorker?.id !== handle.id) {
587
+ throw new Error("Recovered idle state was not durably confirmed");
588
+ }
515
589
  if (shuttingDown) {
516
- await stopSession(session, "Pi session shutdown during recovery", true);
590
+ await stopSession(session, "Pi session shutdown during recovery", true, true);
517
591
  throw new Error("Pi session shut down during recovery");
518
592
  }
519
593
  message = `Worker recovered idle: task=${record.taskId} worker=${handle.id}; original task was not replayed; send an explicit continuation, then use resume-auto`;
520
594
  } catch (error) {
521
- const cleanupRequired = requiresWorkerCleanup(error);
522
- if (cleanupRequired) cleanupRequiredTasks.add(record.taskId);
523
- const retainHandle = !recoveryCleanupCompleted && session.handle
524
- && (cleanupRequired || session.handle.ownership === "adopted");
525
- if (retainHandle) {
526
- sessions.set(record.taskId, session);
527
- reservedCwds.set(record.taskId, cwdKey);
528
- activeTaskId = record.taskId;
529
- } else if (!cleanupRequired && !recoveryCleanupCompleted) {
595
+ const handle = session.handle ?? startedHandle;
596
+ let cleanupConfirmed = !handle;
597
+ const retainedWorker = handle?.ownership === "adopted";
598
+ if (handle && !retainedWorker) {
599
+ try {
600
+ await stopSession(session, "Decision Worker recovery cleanup", false, true);
601
+ cleanupConfirmed = await workerCleanupConfirmed(handle);
602
+ } catch {
603
+ cleanupConfirmed = false;
604
+ }
605
+ }
606
+ if (!cleanupConfirmed) cleanupRequiredTasks.add(record.taskId);
607
+ try {
608
+ await decisionStore.markRecoveryInterrupted(record.taskId);
609
+ } catch (stateError) {
610
+ cleanupConfirmed = false;
611
+ console.error(`pi-claude-supervisor recovery state update failed: ${redactText(stateError instanceof Error ? stateError.message : String(stateError))}`);
612
+ }
613
+ if (cleanupConfirmed && !retainedWorker) {
530
614
  const released = await releaseLease(record.taskId);
531
- if (!released) {
615
+ if (released) {
616
+ forgetSession(record.taskId);
617
+ } else {
618
+ cleanupRequiredTasks.add(record.taskId);
532
619
  sessions.set(record.taskId, session);
533
620
  reservedCwds.set(record.taskId, cwdKey);
534
621
  activeTaskId = record.taskId;
535
622
  }
623
+ } else {
624
+ sessions.set(record.taskId, session);
625
+ reservedCwds.set(record.taskId, cwdKey);
626
+ activeTaskId = record.taskId;
536
627
  }
537
628
  throw error;
538
629
  }
@@ -595,7 +686,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
595
686
  } else if (operation === "resume-auto") {
596
687
  await session.resumeAutomation(); message = `Automatic decisions resumed: ${sessionId}.`;
597
688
  } else {
598
- 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");
689
+ throw new Error("Usage: /supervise start|adopt-tmux|recover [--takeover]|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");
599
690
  }
600
691
  }
601
692
  notify(ctx, message);
@@ -629,7 +720,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
629
720
  startupFailures.push(...results.filter((result): result is PromiseRejectedResult => result.status === "rejected"));
630
721
  }
631
722
  }
632
- const results = await Promise.allSettled([...sessions.values()].map((session) => stopSession(session, "Pi session shutdown", true)));
723
+ const results = await Promise.allSettled([...sessions.values()].map((session) => stopSession(session, "Pi session shutdown", true, true)));
633
724
  await releaseSettledReservations();
634
725
  const failures = [...startupFailures, ...results.filter((result): result is PromiseRejectedResult => result.status === "rejected")];
635
726
  if (failures.length > 0) {
@@ -640,11 +731,9 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
640
731
  })();
641
732
  return shutdownPromise;
642
733
  };
643
- const onSignal = (signal: NodeJS.Signals) => {
644
- void shutdown().finally(() => process.exit(signal === "SIGTERM" ? 143 : 130));
645
- };
646
- process.once("SIGTERM", onSignal);
647
- process.once("SIGINT", onSignal);
734
+ // Pi owns process signal handling and invokes session_shutdown. Installing a
735
+ // second extension-level process.exit() handler races Pi's terminal restore and
736
+ // other extension shutdown hooks.
648
737
  pi.on("session_shutdown", async () => {
649
738
  await shutdown();
650
739
  });
@@ -682,7 +771,7 @@ function formatSessions(sessions: Map<string, Supervisor>, recoverable: Decision
682
771
  .map(([taskId, session]) => `${taskId} state=${session.state} cwd=${session.task?.cwd ?? "-"} worker=${session.handle?.id ?? "-"}`);
683
772
  const pending = recoverable
684
773
  .filter((record) => !sessions.has(record.taskId))
685
- .map((record) => `${record.taskId} state=recoverable cwd=${record.cwd} worker=-`);
774
+ .map((record) => `${record.taskId} state=recoverable recovery=${record.recoveryState} cwd=${record.cwd} worker=${record.recoveryWorker?.id ?? "-"}`);
686
775
  return [...active, ...pending].join("\n") || "No task sessions.";
687
776
  }
688
777
 
@@ -717,6 +806,18 @@ function delay(ms: number): Promise<void> {
717
806
  return new Promise((resolve) => setTimeout(resolve, ms));
718
807
  }
719
808
 
809
+ function assertRuntimeDirectory(path: string, label: string): void {
810
+ try {
811
+ mkdirSync(path, { recursive: true, mode: 0o700 });
812
+ const info = lstatSync(path);
813
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`${label} directory is not a real directory`);
814
+ chmodSync(path, 0o700);
815
+ accessSync(path, fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK);
816
+ } catch (error) {
817
+ throw new Error(`${label} directory preflight failed for ${path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
818
+ }
819
+ }
820
+
720
821
  function redactText(value: string): string {
721
822
  return String(redactSensitive(value));
722
823
  }
package/src/redaction.ts CHANGED
@@ -10,8 +10,9 @@ export function redactSensitive(value: unknown, key?: string): unknown {
10
10
  .replace(/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/gu, "[REDACTED]")
11
11
  .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[REDACTED]")
12
12
  .replace(/\b(Bearer\s+)[^\s]+/giu, "$1[REDACTED]")
13
+ .replace(/\b((?:authorization)\s*:\s*(?:[A-Za-z]+\s+)?)[^\s,;)}\]]+/giu, "$1[REDACTED]")
14
+ .replace(/\b((?:x-api-key|api[-_]?key|token|secret|password|credential)\s*[:=]\s*)[^\s,;)}\]]+/giu, "$1[REDACTED]")
13
15
  .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
16
  .replace(/(--?(?:token|api[-_]?key|secret|password|authorization)(?:=|\s+))[^\s]+/giu, "$1[REDACTED]");
16
17
  }
17
18
  if (Array.isArray(value)) return value.map((item) => redactSensitive(item, key));
package/src/reviewer.ts CHANGED
@@ -15,6 +15,8 @@ export interface ReviewInput {
15
15
  workerOutput?: string;
16
16
  workerResult?: Record<string, unknown>;
17
17
  round: number;
18
+ /** Abort a review when the operator stops or shuts down the Supervisor. */
19
+ signal?: AbortSignal;
18
20
  }
19
21
 
20
22
  export interface TaskReviewer {
@@ -38,6 +40,9 @@ export class PiReadOnlyReviewer implements TaskReviewer {
38
40
  }
39
41
 
40
42
  async review(input: ReviewInput): Promise<ReviewReport> {
43
+ if (input.evidence.complete === false || input.evidence.truncated === true) {
44
+ return invalidReview("repository evidence is incomplete or truncated", input.round, new Date().toISOString());
45
+ }
41
46
  const resourceLoader = new DefaultResourceLoader({
42
47
  cwd: input.cwd,
43
48
  agentDir: getAgentDir(),
@@ -55,20 +60,40 @@ export class PiReadOnlyReviewer implements TaskReviewer {
55
60
  thinkingLevel: "low",
56
61
  tools: ["read", "grep", "find", "ls"],
57
62
  });
58
- let text = "";
59
- let responseTooLarge = false;
63
+ let current = "";
64
+ let finalMessage = "";
65
+ let capturingAssistant = false;
66
+ let currentTooLarge = false;
67
+ let finalTooLarge = false;
60
68
  const unsubscribe = session.subscribe((value) => {
61
- const record = value as unknown as { type?: string; assistantMessageEvent?: { type?: string; delta?: string } };
62
- if (record.type !== "message_update" || record.assistantMessageEvent?.type !== "text_delta" || responseTooLarge) return;
63
- const delta = record.assistantMessageEvent.delta ?? "";
64
- if (Buffer.byteLength(text, "utf8") + Buffer.byteLength(delta, "utf8") > MAX_REVIEW_RESPONSE_BYTES) {
65
- responseTooLarge = true;
69
+ const record = value as unknown as { type?: string; message?: { role?: string; content?: unknown }; assistantMessageEvent?: { type?: string; delta?: string } };
70
+ const role = record.message?.role;
71
+ if (record.type === "message_start" && role === "assistant") {
72
+ current = "";
73
+ currentTooLarge = false;
74
+ capturingAssistant = true;
75
+ return;
76
+ }
77
+ if (record.type === "message_update" && record.assistantMessageEvent?.type === "text_delta" && (capturingAssistant || role === "assistant" || role === undefined)) {
78
+ capturingAssistant = true;
79
+ const delta = record.assistantMessageEvent.delta ?? "";
80
+ if (Buffer.byteLength(current, "utf8") + Buffer.byteLength(delta, "utf8") > MAX_REVIEW_RESPONSE_BYTES) {
81
+ currentTooLarge = true;
82
+ return;
83
+ }
84
+ current += delta;
66
85
  return;
67
86
  }
68
- text += delta;
87
+ if (record.type === "message_end" && role === "assistant") {
88
+ finalMessage = current || textFromMessage(record.message?.content);
89
+ finalTooLarge = currentTooLarge;
90
+ current = "";
91
+ currentTooLarge = false;
92
+ capturingAssistant = false;
93
+ }
69
94
  });
70
95
  try {
71
- await withTimeout(session.prompt(reviewPrompt(input)), this.#timeoutMs, "independent Reviewer");
96
+ await withTimeout(session.prompt(reviewPrompt(input)), this.#timeoutMs, "independent Reviewer", input.signal);
72
97
  } catch (error) {
73
98
  await session.abort().catch(() => {});
74
99
  throw error;
@@ -76,9 +101,9 @@ export class PiReadOnlyReviewer implements TaskReviewer {
76
101
  unsubscribe();
77
102
  session.dispose();
78
103
  }
79
- return responseTooLarge
104
+ return finalTooLarge
80
105
  ? invalidReview(`Reviewer response exceeded ${MAX_REVIEW_RESPONSE_BYTES} bytes`, input.round, new Date().toISOString())
81
- : parseReview(text, input.round);
106
+ : parseReview(finalMessage || current, input.round);
82
107
  }
83
108
  }
84
109
 
@@ -103,10 +128,17 @@ ACCEPTANCE REPORT:
103
128
  ${boundedJson(input.acceptance)}
104
129
 
105
130
  REPOSITORY STATUS:
106
- ${boundText(input.evidence.status, 8_000)}
131
+ ${boundText(redactText(input.evidence.status), 8_000)}
132
+
133
+ REPOSITORY DIFF (HEAD-RELATIVE, UNTRUSTED):
134
+ ${boundText(redactText(input.evidence.diff), 16_000)}
107
135
 
108
- REPOSITORY DIFF (UNTRUSTED):
109
- ${boundText(input.evidence.diff, 16_000)}
136
+ UNTRACKED FILE EVIDENCE (UNTRUSTED):
137
+ ${boundText(redactText(input.evidence.untracked ?? "(none)"), 16_000)}
138
+
139
+ EVIDENCE COMPLETE:
140
+ ${String(input.evidence.complete !== false && input.evidence.truncated !== true)}
141
+ If repository evidence is incomplete or truncated, do not return pass; return human and explain which evidence is unavailable.
110
142
 
111
143
  WORKER OUTPUT (UNTRUSTED):
112
144
  ${boundTailText(redactSensitive(input.workerOutput ?? "(none)"), 8_000)}
@@ -118,6 +150,15 @@ REVIEW ROUND:
118
150
  ${input.round}`;
119
151
  }
120
152
 
153
+ function textFromMessage(content: unknown): string {
154
+ if (!Array.isArray(content)) return "";
155
+ return content
156
+ .filter((block): block is { type?: unknown; text?: unknown } => Boolean(block && typeof block === "object"))
157
+ .filter((block) => block.type === "text" && typeof block.text === "string")
158
+ .map((block) => block.text as string)
159
+ .join("");
160
+ }
161
+
121
162
  export function parseReview(text: string, round: number): ReviewReport {
122
163
  const checkedAt = new Date().toISOString();
123
164
  if (Buffer.byteLength(text, "utf8") > MAX_REVIEW_RESPONSE_BYTES) return invalidReview(`Reviewer response exceeded ${MAX_REVIEW_RESPONSE_BYTES} bytes`, round, checkedAt);
@@ -179,16 +220,28 @@ function redactText(value: string): string {
179
220
  return String(redactSensitive(value));
180
221
  }
181
222
 
182
- async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
223
+ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string, signal?: AbortSignal): Promise<T> {
183
224
  let timer: NodeJS.Timeout | undefined;
225
+ let onAbort: (() => void) | undefined;
184
226
  const timeout = new Promise<never>((_, reject) => {
185
227
  timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
186
228
  timer.unref();
187
229
  });
230
+ const aborted = new Promise<never>((_, reject) => {
231
+ if (!signal) return;
232
+ onAbort = () => {
233
+ const error = new Error(`${label} aborted`);
234
+ error.name = "AbortError";
235
+ reject(error);
236
+ };
237
+ if (signal.aborted) onAbort();
238
+ else signal.addEventListener("abort", onAbort, { once: true });
239
+ });
188
240
  try {
189
- return await Promise.race([promise, timeout]);
241
+ return await Promise.race([promise, timeout, aborted]);
190
242
  } finally {
191
243
  if (timer) clearTimeout(timer);
244
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
192
245
  }
193
246
  }
194
247