pi-claude-supervisor 0.9.0 → 0.9.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/cwd-lease.ts CHANGED
@@ -187,7 +187,20 @@ export class CwdLeaseStore {
187
187
  continue;
188
188
  }
189
189
  }
190
- throw new Error(`working-directory lease is held by task ${existing.taskId}: ${redactText(existing.cwd)}`);
190
+ // A crash leaves the lease of a task that no longer exists
191
+ // anywhere. When its owner and its Worker are provably gone and
192
+ // it holds no cleanup boundary (cgroup, tmux server) that recovery
193
+ // still has to prove empty, it blocks nothing real: drop it rather
194
+ // than strand the directory behind a record no command can free.
195
+ if (await leaseAbandoned(existing)) {
196
+ await rm(this.#path(existing.leaseId), { force: true });
197
+ console.error(`pi-claude-supervisor released an abandoned cwd lease of task ${existing.taskId}: its Pi (pid ${existing.ownerPid}) and Worker are gone`);
198
+ continue;
199
+ }
200
+ const ownerGone = !existing.pendingCleanup && !await processIdentityLive(existing.ownerPid, existing.ownerStartTime);
201
+ throw new Error(ownerGone
202
+ ? `working-directory lease is held by task ${existing.taskId}: ${redactText(existing.cwd)}; its Pi (pid ${existing.ownerPid}) is gone but its Worker (a cgroup or tmux session) may still be running. For an automatic task run /supervise recover --takeover ${existing.taskId}; for a manual one, stop or re-adopt its Worker, then delete ${redactText(this.#path(existing.leaseId))}; or use another worktree`
203
+ : `working-directory lease is held by task ${existing.taskId}: ${redactText(existing.cwd)}`);
191
204
  }
192
205
  if (takeoverLease && takeoverProof) {
193
206
  if (takeoverProof.startupOnly) {
@@ -656,6 +669,26 @@ export async function leaseOwnerLive(lease: CwdLeaseRecord): Promise<boolean> {
656
669
  return processIdentityLive(lease.ownerPid, lease.ownerStartTime);
657
670
  }
658
671
 
672
+ /**
673
+ * A lease nobody can still be using: its owning Pi is gone (pid and start
674
+ * time), no takeover transaction is pending, and its Worker either never
675
+ * started or ran without a cgroup and is dead along with its whole process
676
+ * group — the same evidence the adapter's own cleanup relies on in that mode
677
+ * — with no tmux server left. A Worker that had a cgroup boundary is never
678
+ * reclaimed here: only an explicit, record-backed `recover --takeover` may
679
+ * prove that boundary empty.
680
+ */
681
+ async function leaseAbandoned(lease: CwdLeaseRecord): Promise<boolean> {
682
+ if (lease.pendingCleanup || await processIdentityLive(lease.ownerPid, lease.ownerStartTime)) return false;
683
+ const worker = lease.worker;
684
+ if (!worker) return true;
685
+ // A tmux session is meant to outlive its Pi and be re-adopted or handed
686
+ // off by identity; that record is evidence, never garbage.
687
+ if (worker.transport === "tmux" || worker.ownership === "adopted" || worker.cgroupPath || worker.retainCgroupUntilLeaseRelease) return false;
688
+ if (worker.pid === undefined) return Boolean(lease.pendingStartup) && !worker.workerId;
689
+ return !await processExists(worker.pid) && !await processGroupExists(worker.pid);
690
+ }
691
+
659
692
  async function canTakeoverLease(lease: CwdLeaseRecord): Promise<TakeoverProof | undefined> {
660
693
  // The old supervisor owner must be gone. A dead owner is not enough when
661
694
  // the detached Worker itself is still alive. Compare start time as well as
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { chmod, lstat, mkdir, readdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { currentLockOwner, lockOwnerAlive } from "./lock-owner.ts";
4
5
  import { redactSensitive } from "./redaction.ts";
5
6
  import { normalizeTaskSpec } from "./acceptance.ts";
6
7
  import type { TaskSpec } from "./types.ts";
@@ -363,10 +364,7 @@ export class DecisionSessionStore {
363
364
  while (true) {
364
365
  try {
365
366
  await mkdir(lockPath, { mode: 0o700 });
366
- await writeFile(join(lockPath, "owner.json"), JSON.stringify({
367
- pid: process.pid,
368
- at: new Date().toISOString(),
369
- }), { encoding: "utf8", mode: 0o600 });
367
+ await writeFile(join(lockPath, "owner.json"), JSON.stringify(await currentLockOwner()), { encoding: "utf8", mode: 0o600 });
370
368
  break;
371
369
  } catch (error) {
372
370
  if (!(error instanceof Error) || !/EEXIST/u.test(error.message)) throw error;
@@ -554,17 +552,10 @@ async function removeStaleLock(lockPath: string): Promise<boolean> {
554
552
  throw error;
555
553
  }
556
554
  }
557
- let owner: { pid?: unknown } = {};
558
- try { owner = JSON.parse(await readFile(ownerPath, "utf8")) as { pid?: unknown }; }
555
+ let owner: unknown;
556
+ try { owner = JSON.parse(await readFile(ownerPath, "utf8")); }
559
557
  catch { /* an old/incomplete lock is reclaimable after the grace period */ }
560
- if (typeof owner.pid === "number" && Number.isSafeInteger(owner.pid) && owner.pid > 0) {
561
- try {
562
- process.kill(owner.pid, 0);
563
- return false;
564
- } catch (error) {
565
- if (error instanceof Error && /EPERM/u.test(error.message)) return false;
566
- }
567
- }
558
+ if (await lockOwnerAlive(owner)) return false;
568
559
  await rm(lockPath, { recursive: true, force: true });
569
560
  return true;
570
561
  } catch (error) {
@@ -4,6 +4,7 @@ import { dirname, resolve } from "node:path";
4
4
  import { lstat, open, rename, rm, writeFile, type FileHandle } from "node:fs/promises";
5
5
  import { isDeepStrictEqual } from "node:util";
6
6
  import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager, type AgentSession } from "@earendil-works/pi-coding-agent";
7
+ import { DEFAULT_MAX_DECISION_RETRIES } from "./config.ts";
7
8
  import { extractJsonObjects } from "./json-extract.ts";
8
9
  import type { PiUsageSample, TaskSpec, WorkerEvent } from "./types.ts";
9
10
  import { redactSensitive } from "./redaction.ts";
@@ -28,6 +29,18 @@ export interface DecisionContext {
28
29
  spec?: TaskSpec;
29
30
  /** Wall-clock budget of the task; absent when no deadline is configured. */
30
31
  deadline?: DecisionDeadlineContext;
32
+ /** Outcome of the most recent acceptance/Review round, if one has run. */
33
+ lastVerification?: DecisionVerificationSummary;
34
+ }
35
+
36
+ /** A compact view of the last verification: what failed and what the Reviewer asked for. */
37
+ export interface DecisionVerificationSummary {
38
+ ok: boolean;
39
+ /** The Worker turn it judged: turns after it are not reflected in it. */
40
+ atTurn?: number;
41
+ failedChecks: string[];
42
+ reviewVerdict?: string;
43
+ findings: string[];
31
44
  }
32
45
 
33
46
  export interface DecisionDeadlineContext {
@@ -81,6 +94,16 @@ export interface DecisionWorkerOptions {
81
94
  export type PiModel = NonNullable<NonNullable<Parameters<typeof createAgentSession>[0]>["model"]>;
82
95
 
83
96
  const DEFAULT_COMPACTION_TOKENS = 60_000;
97
+ /**
98
+ * Worker turns after a failed verification before the Supervisor verifies on
99
+ * its own: the Decision Worker only sees that failure as it was, so left to
100
+ * itself it can keep sending "it still fails" guidance to a Worker that has
101
+ * already fixed it. The first of these turns is the repair turn itself.
102
+ */
103
+ export const STALE_VERIFICATION_TURNS = 3;
104
+ /** First retry wait; each later wait triples, capped at MAX_DECISION_RETRY_BACKOFF_MS. */
105
+ const DEFAULT_DECISION_RETRY_BACKOFF_MS = 5_000;
106
+ const MAX_DECISION_RETRY_BACKOFF_MS = 60_000;
84
107
 
85
108
  /**
86
109
  * A persistent Pi SDK session used only for supervision decisions.
@@ -97,6 +120,8 @@ export class PiDecisionWorker implements DecisionWorkerLike {
97
120
  #sessionFile?: string;
98
121
  #context: DecisionContext;
99
122
  readonly #timeoutMs: number;
123
+ /** Ends a startup retry backoff early; set only while one is waiting. */
124
+ #wakeStartupBackoff?: () => void;
100
125
  readonly #retryBackoffMs: number;
101
126
  readonly #compactionTokens: number;
102
127
  /** Set after a compaction; the next primary decision prompt re-sends the startup instructions once. */
@@ -106,7 +131,7 @@ export class PiDecisionWorker implements DecisionWorkerLike {
106
131
  this.#options = options;
107
132
  this.#context = { ...options.context };
108
133
  this.#timeoutMs = options.timeoutMs ?? 120_000;
109
- this.#retryBackoffMs = options.retryBackoffMs ?? 500;
134
+ this.#retryBackoffMs = options.retryBackoffMs ?? DEFAULT_DECISION_RETRY_BACKOFF_MS;
110
135
  this.#compactionTokens = options.compactionTokens ?? DEFAULT_COMPACTION_TOKENS;
111
136
  }
112
137
 
@@ -149,11 +174,31 @@ export class PiDecisionWorker implements DecisionWorkerLike {
149
174
  await this.#options.onSessionReady({ sessionFile: this.#sessionFile, sessionId: session.sessionId, restored });
150
175
  }
151
176
  if (!restored) {
152
- try {
153
- await promptForText(session, decisionInstructions(this.#context), this.#timeoutMs, "Decision Worker startup", MAX_DECISION_RESPONSE_BYTES, { role: "decision", onUsage: this.#options.onUsage });
154
- } catch (error) {
155
- try { await this.#options.onStartupFailure?.(error); } catch { /* preserve the original startup failure */ }
156
- throw error;
177
+ // A provider hiccup (503 overload, 429) here would otherwise fail the
178
+ // whole task before its first turn: retry it like any decision.
179
+ const maxRetries = this.#context.spec?.autonomy.maxDecisionRetries ?? DEFAULT_MAX_DECISION_RETRIES;
180
+ for (let attempt = 0; ; attempt += 1) {
181
+ try {
182
+ await promptForText(session, decisionInstructions(this.#context), this.#timeoutMs, "Decision Worker startup", MAX_DECISION_RESPONSE_BYTES, { role: "decision", onUsage: this.#options.onUsage });
183
+ break;
184
+ } catch (error) {
185
+ // Closed by the Supervisor (an operator stop): not a startup
186
+ // failure to report, whatever the interrupted prompt returned.
187
+ if (this.#closed) throw closedDuringStartup();
188
+ // Only a provider error is worth another attempt: a timeout already
189
+ // spent the whole prompt budget and would only multiply it.
190
+ const retryable = error instanceof Error && error.name === "DecisionWorkerApiError";
191
+ if (attempt >= maxRetries || !retryable) {
192
+ try { await this.#options.onStartupFailure?.(error); } catch { /* preserve the original startup failure */ }
193
+ throw error;
194
+ }
195
+ // close() wakes this wait, so a stop does not sit out the backoff.
196
+ await new Promise<void>((resolveWait) => {
197
+ const timer = setTimeout(() => { this.#wakeStartupBackoff = undefined; resolveWait(); }, Math.min(MAX_DECISION_RETRY_BACKOFF_MS, this.#retryBackoffMs * 3 ** (attempt + 1)));
198
+ this.#wakeStartupBackoff = () => { clearTimeout(timer); this.#wakeStartupBackoff = undefined; resolveWait(); };
199
+ });
200
+ if (this.#closed) throw closedDuringStartup();
201
+ }
157
202
  }
158
203
  }
159
204
  }
@@ -188,7 +233,7 @@ export class PiDecisionWorker implements DecisionWorkerLike {
188
233
 
189
234
  async #processEvent(event: WorkerEvent): Promise<void> {
190
235
  if (!this.#session || this.#closed) return;
191
- const maxRetries = this.#context.spec?.autonomy.maxDecisionRetries ?? 2;
236
+ const maxRetries = this.#context.spec?.autonomy.maxDecisionRetries ?? DEFAULT_MAX_DECISION_RETRIES;
192
237
  let attempt = 0;
193
238
  // undefined selects the primary decision question; a bounded re-prompt
194
239
  // (below) switches this to a corrective follow-up on the same session.
@@ -216,7 +261,8 @@ export class PiDecisionWorker implements DecisionWorkerLike {
216
261
  if (error instanceof Error && error.name === "AbortError") throw error;
217
262
  if (attempt >= maxRetries) throw error;
218
263
  attempt += 1;
219
- await new Promise<void>((resolveWait) => setTimeout(resolveWait, Math.min(30_000, this.#retryBackoffMs * 2 ** attempt)));
264
+ // 429/529 overloads last minutes, not seconds: 15s, 45s, then 60s.
265
+ await new Promise<void>((resolveWait) => setTimeout(resolveWait, Math.min(MAX_DECISION_RETRY_BACKOFF_MS, this.#retryBackoffMs * 3 ** attempt)));
220
266
  if (!this.#session || this.#closed) return;
221
267
  continue;
222
268
  }
@@ -231,6 +277,13 @@ export class PiDecisionWorker implements DecisionWorkerLike {
231
277
  prompt = `Your previous reply was not a single valid JSON action (${action.reason}). Return exactly one JSON object now and nothing else.`;
232
278
  continue;
233
279
  }
280
+ // `noop` on a completed turn or a pending permission leaves the Worker
281
+ // idle forever; the Supervisor parks it. Ask once for a concrete action.
282
+ if (!repromptAttempted && action.action === "noop" && (event.type === "turn_completed" || event.type === "permission_request")) {
283
+ repromptAttempted = true;
284
+ prompt = `noop is not a valid action for a ${event.type} event: the Worker is waiting on you and nothing will happen. Choose a concrete action (for example continue, redirect or verify for a completed turn, or allow_permission/deny_permission for a permission request) and return exactly one JSON object now.`;
285
+ continue;
286
+ }
234
287
  // An action handler may stop or close the session. Do not replay an
235
288
  // already-decoded action if the handler itself fails.
236
289
  await this.#options.onAction(action, event);
@@ -249,11 +302,15 @@ export class PiDecisionWorker implements DecisionWorkerLike {
249
302
  const tokens = this.#session.getContextUsage?.()?.tokens;
250
303
  if (typeof tokens !== "number" || tokens <= this.#compactionTokens) return;
251
304
  try {
252
- await this.#session.compact(
305
+ // Bounded like any other Decision Worker request: compaction runs on the
306
+ // serialized decision tail, so a stalled summarization would otherwise
307
+ // block every later decision.
308
+ await withTimeout(this.#session.compact(
253
309
  "Preserve: the task specification, the policy rules for permissions and boundaries, the current state, and the last three decisions with their reasons.",
254
- );
310
+ ), this.#timeoutMs, "Decision Worker compaction");
255
311
  this.#instructionsStale = true;
256
312
  } catch {
313
+ try { this.#session?.abortCompaction?.(); } catch { /* best effort */ }
257
314
  // A compaction failure must not fail the decision that already succeeded.
258
315
  }
259
316
  }
@@ -274,6 +331,7 @@ export class PiDecisionWorker implements DecisionWorkerLike {
274
331
  // Do not await #tail here: onAction may be closing the worker from inside
275
332
  // the same queued decision, which would otherwise deadlock shutdown.
276
333
  this.#closed = true;
334
+ this.#wakeStartupBackoff?.();
277
335
  const session = this.#session;
278
336
  this.#session = undefined;
279
337
  if (session) await session.abort().catch(() => {});
@@ -281,6 +339,12 @@ export class PiDecisionWorker implements DecisionWorkerLike {
281
339
  }
282
340
  }
283
341
 
342
+ function closedDuringStartup(): Error {
343
+ const error = new Error("Decision Worker was closed during startup");
344
+ error.name = "AbortError";
345
+ return error;
346
+ }
347
+
284
348
  function decisionInstructions(context: DecisionContext): string {
285
349
  return `You are the persistent Pi Decision Worker for a Claude Code implementation task.
286
350
  Your job is to inspect evidence and choose the next typed action. Do not edit files,
@@ -311,8 +375,21 @@ as the answer and continues. A turn_completed whose result has subtype "error" o
311
375
  means the Worker's own API/model call failed mid-turn: choose retry (optionally with a short
312
376
  corrective message) or continue to resume it, and park only after repeated failures; a subtype
313
377
  "idle" result means the turn ended without a normal stop signal, so inspect the repository and
314
- decide as for any other turn. Use verify when a turn result indicates the task is complete, even if
378
+ decide as for any other turn. CURRENT CONTEXT.lastVerification, when present, is the last acceptance
379
+ and Review round (failed check ids, Reviewer verdict and findings) as of Worker turn atTurn. It
380
+ does not change until verification runs again: it is not evidence that the failures remain after
381
+ Claude's later turns. Use it to judge whether Claude's reply addresses those failures; once
382
+ Claude reports them fixed, choose verify rather than re-judging the old result. (After a failed
383
+ verification the Supervisor verifies by itself once ${STALE_VERIFICATION_TURNS} Worker turns, the repair turn included,
384
+ have passed without one; do not rely on it.)
385
+ A continue, redirect or answer message is sent verbatim to Claude Code as its next instruction:
386
+ write it as a direct instruction to Claude, not a description of what you will do yourself (use
387
+ your own read-only tools to inspect the repository before deciding).
388
+ Use verify when a turn result indicates the task is complete, even if
315
389
  Claude says it will stop; choose stop only for an explicit stop or technical containment reason.
390
+ In an unattended task without remote authority, a stop on a completed turn stops the Worker
391
+ and then still verifies its work, but no repair round can follow, so a fixable problem would
392
+ be lost: when the work looks done, choose verify.
316
393
  Use park only when the task cannot safely produce a candidate because required evidence,
317
394
  authority, or runtime capability is unavailable. A parked candidate is asynchronous and must not
318
395
  wait for a human to be online. For an exited event choose verify, park or stop; a noop on an exited event is treated as
@@ -390,6 +467,7 @@ async function askDecision(
390
467
  ? { closeOutRemainingMinutes: Math.round(context.deadline.closeOutRemainingMs / 60_000) }
391
468
  : {}),
392
469
  } : {}),
470
+ ...(context.lastVerification ? { lastVerification: context.lastVerification } : {}),
393
471
  };
394
472
  const prompt = `${options.instructionsPrefix ?? ""}UNTRUSTED SUPERVISOR EVENT:\n${boundedEventJson(event)}\n\nCURRENT CONTEXT:\n${boundedJson(currentContext)}\n\nChoose one action now.`;
395
473
  return promptForText(session, prompt, timeoutMs, "Decision Worker request", MAX_DECISION_RESPONSE_BYTES, { role: "decision", onUsage: options.onUsage });
package/src/events.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { appendFile, chmod, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname, join } from "node:path";
4
+ import { currentLockOwner, lockOwnerAlive } from "./lock-owner.ts";
4
5
  import { redactSensitive } from "./redaction.ts";
5
6
 
6
7
  export interface SupervisorEvent {
@@ -71,7 +72,7 @@ export class EventLog {
71
72
  while (true) {
72
73
  try {
73
74
  await mkdir(lockPath);
74
- await writeFile(`${lockPath}/owner.json`, JSON.stringify({ pid: process.pid, at: new Date().toISOString() }));
75
+ await writeFile(`${lockPath}/owner.json`, JSON.stringify(await currentLockOwner()));
75
76
  break;
76
77
  } catch (error) {
77
78
  if (!(error instanceof Error) || !/EEXIST/u.test(error.message)) throw error;
@@ -91,21 +92,14 @@ export class EventLog {
91
92
  try {
92
93
  const info = await stat(`${lockPath}/owner.json`);
93
94
  if (Date.now() - info.mtimeMs < STALE_LOCK_MS) return false;
94
- let owner: { pid?: unknown };
95
+ let owner: unknown;
95
96
  try {
96
- owner = JSON.parse(await readFile(`${lockPath}/owner.json`, "utf8")) as { pid?: unknown };
97
+ owner = JSON.parse(await readFile(`${lockPath}/owner.json`, "utf8"));
97
98
  } catch {
98
99
  await rm(lockPath, { recursive: true, force: true });
99
100
  return true;
100
101
  }
101
- if (typeof owner.pid === "number") {
102
- try {
103
- process.kill(owner.pid, 0);
104
- return false;
105
- } catch (error) {
106
- if (error instanceof Error && /EPERM/u.test(error.message)) return false;
107
- }
108
- }
102
+ if (await lockOwnerAlive(owner)) return false;
109
103
  await rm(lockPath, { recursive: true, force: true });
110
104
  return true;
111
105
  } catch (error) {
@@ -2,20 +2,11 @@ import { randomUUID } from "node:crypto";
2
2
  import { chmod, mkdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
- import { HOOK_TIMEOUT_SECONDS, type ClaudeHookEventName } from "./types.ts";
5
+ import { CLAUDE_HOOK_EVENT_NAMES, HOOK_TIMEOUT_SECONDS, type ClaudeHookEventName } from "./types.ts";
6
6
  import { HOOK_RELAY_SCRIPT, hookRelayCommand } from "./relay.ts";
7
7
  import { hookSocketDirectory } from "./server.ts";
8
8
 
9
- const HOOK_EVENT_NAMES: readonly ClaudeHookEventName[] = [
10
- "SessionStart",
11
- "SessionEnd",
12
- "UserPromptSubmit",
13
- "PreToolUse",
14
- "PermissionRequest",
15
- "Stop",
16
- "StopFailure",
17
- "Notification",
18
- ];
9
+ const HOOK_EVENT_NAMES: readonly ClaudeHookEventName[] = CLAUDE_HOOK_EVENT_NAMES;
19
10
 
20
11
  /** Substring that marks a hook command entry as ours, regardless of which stateDir produced it. */
21
12
  const RELAY_MARKER = "/hooks/relay.js";
@@ -3,19 +3,11 @@ import { createServer, type Server, type Socket } from "node:net";
3
3
  import { chmod, lstat, mkdir, readdir, readlink, realpath, rename, rm, symlink, unlink } from "node:fs/promises";
4
4
  import { join, resolve } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
- import type { ClaudeHookEvent, ClaudeHookEventName, HookEventSource, HookRelayReply, HookRelayRequest } from "./types.ts";
6
+ import { CLAUDE_HOOK_EVENT_NAMES, type ClaudeHookEvent, type ClaudeHookEventName, type HookEventSource, type HookRelayReply, type HookRelayRequest } from "./types.ts";
7
7
 
8
8
  const MAX_LINE_BYTES = 1024 * 1024;
9
9
 
10
- const VALID_EVENT_NAMES: ReadonlySet<string> = new Set<ClaudeHookEventName>([
11
- "SessionStart",
12
- "SessionEnd",
13
- "UserPromptSubmit",
14
- "PreToolUse",
15
- "PermissionRequest",
16
- "Stop",
17
- "Notification",
18
- ]);
10
+ const VALID_EVENT_NAMES: ReadonlySet<string> = new Set<ClaudeHookEventName>(CLAUDE_HOOK_EVENT_NAMES);
19
11
 
20
12
  type HookHandler = (request: HookRelayRequest) => Promise<HookRelayReply | undefined>;
21
13
 
@@ -1,18 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
3
3
  import { dirname } from "node:path";
4
- import { HOOK_TIMEOUT_SECONDS, type ClaudeHookEventName } from "./types.ts";
4
+ import { CLAUDE_HOOK_EVENT_NAMES, HOOK_TIMEOUT_SECONDS, type ClaudeHookEventName } from "./types.ts";
5
5
 
6
- const HOOK_EVENT_NAMES: readonly ClaudeHookEventName[] = [
7
- "SessionStart",
8
- "SessionEnd",
9
- "UserPromptSubmit",
10
- "PreToolUse",
11
- "PermissionRequest",
12
- "Stop",
13
- "StopFailure",
14
- "Notification",
15
- ];
6
+ const HOOK_EVENT_NAMES: readonly ClaudeHookEventName[] = CLAUDE_HOOK_EVENT_NAMES;
16
7
 
17
8
  interface HookCommandEntry {
18
9
  type: "command";
@@ -5,15 +5,23 @@
5
5
  * event over a unix socket and prints the reply as Claude's hook output.
6
6
  */
7
7
 
8
- export type ClaudeHookEventName =
9
- | "SessionStart"
10
- | "SessionEnd"
11
- | "UserPromptSubmit"
12
- | "PreToolUse"
13
- | "PermissionRequest"
14
- | "Stop"
15
- | "StopFailure"
16
- | "Notification";
8
+ /**
9
+ * Every hook event the Supervisor consumes. The one list that the installed
10
+ * hook settings, the per-session settings and the server's request parser all
11
+ * derive from, so an event cannot be registered and then dropped on arrival.
12
+ */
13
+ export const CLAUDE_HOOK_EVENT_NAMES = [
14
+ "SessionStart",
15
+ "SessionEnd",
16
+ "UserPromptSubmit",
17
+ "PreToolUse",
18
+ "PermissionRequest",
19
+ "Stop",
20
+ "StopFailure",
21
+ "Notification",
22
+ ] as const;
23
+
24
+ export type ClaudeHookEventName = typeof CLAUDE_HOOK_EVENT_NAMES[number];
17
25
 
18
26
  /** The subset of Claude Code hook input the Supervisor consumes (all fields untrusted). */
19
27
  export interface ClaudeHookEvent {
package/src/index.ts CHANGED
@@ -73,6 +73,11 @@ async function userHooksInstalled(settingsPath: string): Promise<boolean> {
73
73
  * sessions may run concurrently, but active sessions must use different
74
74
  * working directories so workers cannot silently overwrite one another.
75
75
  */
76
+ /** The first message a recovered Worker gets: it is a fresh process that never saw the task. */
77
+ function recoveryContinuation(goal: string): string {
78
+ return `The Supervisor restarted this task after an interruption; you are a fresh session and earlier work may already be in the repository. First inspect git status, git log and the uncommitted diff to see what was done. Then continue the original task to completion, run the relevant checks and commit locally. Original task:\n${goal}`;
79
+ }
80
+
76
81
  export default function piClaudeSupervisor(pi: ExtensionAPI): void {
77
82
  loadSupervisorEnvironment();
78
83
  const automation = process.env.PI_CLAUDE_SUPERVISOR_MODE === "auto" || process.env.PI_CLAUDE_SUPERVISOR_AUTOMATION === "1";
@@ -185,6 +190,8 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
185
190
  return new PiReadOnlyReviewer({ timeoutMs: reviewTimeoutMs(), model: await reviewerPiModelPromise });
186
191
  };
187
192
  const sessions = new Map<string, Supervisor>();
193
+ /** The last few tasks that finished and were released, newest last, for `status`/`sessions`. */
194
+ const finishedSessions = new Map<string, { finishedAt: number; detail: string }>();
188
195
  const cwdLeases = new Map<string, CwdLeaseHandle>();
189
196
  const cleanupRequiredTasks = new Set<string>();
190
197
  const reservedCwds = new Map<string, string>();
@@ -230,6 +237,14 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
230
237
  }
231
238
  };
232
239
  const forgetSession = (taskId: string): void => {
240
+ // Keep what a finished task ended as: after an overnight run, `status`
241
+ // is exactly where the operator looks, and the live session is gone.
242
+ const finished = sessions.get(taskId);
243
+ if (finished) {
244
+ finishedSessions.delete(taskId);
245
+ finishedSessions.set(taskId, { finishedAt: Date.now(), detail: formatSessionDetail(taskId, finished, tmuxModeLabel()) });
246
+ while (finishedSessions.size > MAX_FINISHED_SESSIONS) finishedSessions.delete(finishedSessions.keys().next().value!);
247
+ }
233
248
  sessions.delete(taskId);
234
249
  reservedCwds.delete(taskId);
235
250
  cleanupRequiredTasks.delete(taskId);
@@ -885,6 +900,34 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
885
900
  throw new Error("Pi session shut down during recovery");
886
901
  }
887
902
  message = `Worker recovered idle: task=${record.taskId} worker=${handle.id}; original task was not replayed; send an explicit continuation, then use resume-auto`;
903
+ // `--extend` is an explicit request to carry on unattended: hand
904
+ // the task back to automation instead of leaving it under
905
+ // takeover. `--extend 0` needs no message — the close-out
906
+ // verifies the idle Worker on the next watchdog tick; a real
907
+ // extension sends a continuation, since the fresh Worker does
908
+ // not remember the task. A failure here leaves the recovered
909
+ // Worker idle under takeover, exactly as a plain recover would.
910
+ if (automaticRecovery && extendMs !== undefined) {
911
+ const turnBefore = session.turn;
912
+ try {
913
+ await session.resumeAutomation();
914
+ if (extendMs > 0) await session.send(recoveryContinuation(record.spec?.goal ?? record.task));
915
+ message = extendMs > 0
916
+ ? `Worker recovered: task=${record.taskId} worker=${handle.id}; automation resumed with a continuation of the original task; ${formatDurationMs(Math.max(0, recoveryDeadlineMs - elapsedMs))} of budget from now`
917
+ : `Worker recovered: task=${record.taskId} worker=${handle.id}; automation resumed; the close-out verifies and reviews the repository as it stands`;
918
+ } catch (error) {
919
+ const detail = redactText(error instanceof Error ? error.message : String(error));
920
+ if (session.turn > turnBefore) {
921
+ // The continuation reached the Worker (only its audit
922
+ // record failed): it is working under automation now,
923
+ // and taking it over would leave that turn undecided.
924
+ message = `Worker recovered: task=${record.taskId} worker=${handle.id}; automation resumed with a continuation of the original task (warning: ${detail})`;
925
+ } else {
926
+ await session.takeover().catch(() => {});
927
+ message = `${message} (automatic resume failed: ${detail})`;
928
+ }
929
+ }
930
+ }
888
931
  } catch (error) {
889
932
  const handle = session.handle ?? startedHandle;
890
933
  let cleanupConfirmed = !handle;
@@ -960,15 +1003,22 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
960
1003
  } else if (operation === "sessions") {
961
1004
  const recoverable = await decisionStore.list({ activeOnly: true });
962
1005
  message = formatSessions(sessions, recoverable, tmuxModeLabel());
1006
+ if (finishedSessions.size > 0) message += `\nRecently finished:\n${formatFinishedSessions(finishedSessions)}`;
963
1007
  const quarantined = await cwdLeaseStore.quarantined();
964
1008
  if (quarantined.length > 0) {
965
1009
  message += `\nQuarantined cwd lease records (${quarantined.length}) in ${leaseDir}/quarantine: ${redactText(quarantined.join(", "))}`;
966
1010
  }
967
1011
  } else if (operation === "status") {
968
- const { session, sessionId } = resolveSession(sessions, activeTaskId, rest, true);
969
- message = session
970
- ? `task=${sessionId} state=${session.state} worker=${session.handle?.id ?? "none"}${tmuxModeLabel() ? ` mode=${tmuxModeLabel()}` : ""}${formatDeadline(session.deadline)} ${formatUsageDetail(session.usage)}`
971
- : formatSessions(sessions, await decisionStore.list({ activeOnly: true }), tmuxModeLabel());
1012
+ const finished = rest[0] && !sessions.has(rest[0]) ? finishedSessions.get(rest[0]) : undefined;
1013
+ if (finished) {
1014
+ message = `finished ${formatDurationMs(Date.now() - finished.finishedAt)} ago: ${finished.detail}`;
1015
+ } else {
1016
+ const { session, sessionId } = resolveSession(sessions, activeTaskId, rest, true);
1017
+ message = session && sessionId
1018
+ ? formatSessionDetail(sessionId, session, tmuxModeLabel())
1019
+ : formatSessions(sessions, await decisionStore.list({ activeOnly: true }), tmuxModeLabel())
1020
+ + (finishedSessions.size > 0 ? `\nRecently finished:\n${formatFinishedSessions(finishedSessions)}` : "");
1021
+ }
972
1022
  } else if (operation === "capabilities") {
973
1023
  message = JSON.stringify(adapter.capabilities(), null, 2);
974
1024
  } else if (operation === "install-hooks" || operation === "uninstall-hooks") {
@@ -1149,6 +1199,35 @@ function formatDeadline(deadline: Supervisor["deadline"]): string {
1149
1199
  return ` deadline=close-out (${formatDurationMs(deadline.closeOutRemainingMs ?? 0)} left)`;
1150
1200
  }
1151
1201
 
1202
+ const MAX_FINISHED_SESSIONS = 20;
1203
+
1204
+ /**
1205
+ * One task's state in a line: what it is doing, how far it got, and — the
1206
+ * question an operator of an unattended run actually has — whether it is
1207
+ * waiting on them.
1208
+ */
1209
+ function formatSessionDetail(taskId: string, session: Supervisor, tmuxModeLabel?: string): string {
1210
+ const task = session.task;
1211
+ const parts = [`task=${taskId}`, `state=${session.state}`, `worker=${session.handle?.id ?? "none"}`];
1212
+ if (tmuxModeLabel) parts.push(`mode=${tmuxModeLabel}`);
1213
+ if (session.humanRequired) parts.push("automation=paused(resume-auto)");
1214
+ if (session.candidateParked) parts.push("candidate=parked");
1215
+ if (task) {
1216
+ parts.push(`turn=${session.turn}/${task.maxTurns}`);
1217
+ if (session.repairRound > 0) parts.push(`repair=${session.repairRound}`);
1218
+ const startedAt = Date.parse(task.startedAt);
1219
+ if (Number.isFinite(startedAt)) parts.push(`elapsed=${formatDurationMs(Math.max(0, Date.now() - startedAt))}`);
1220
+ }
1221
+ const verification = session.lastVerification;
1222
+ if (verification) parts.push(`lastVerify=${verification.ok ? "pass" : "fail"}${verification.review ? `(review=${verification.review.verdict})` : ""}`);
1223
+ const terminal = ["completed", "blocked", "stopped", "failed"].includes(session.state);
1224
+ return `${parts.join(" ")}${terminal ? "" : formatDeadline(session.deadline)} ${formatUsageDetail(session.usage)}`;
1225
+ }
1226
+
1227
+ function formatFinishedSessions(finished: Map<string, { finishedAt: number; detail: string }>): string {
1228
+ return [...finished.values()].reverse().map((entry) => ` ${formatDurationMs(Date.now() - entry.finishedAt)} ago: ${entry.detail}`).join("\n");
1229
+ }
1230
+
1152
1231
  function formatSessions(sessions: Map<string, Supervisor>, recoverable: DecisionSessionRecord[] = [], tmuxModeLabel?: string): string {
1153
1232
  const modeSuffix = tmuxModeLabel ? ` mode=${tmuxModeLabel}` : "";
1154
1233
  const active = [...sessions.entries()]
@@ -4,7 +4,12 @@
4
4
  * objects: prose is harmless, but ambiguity must remain non-publishable.
5
5
  */
6
6
  export function extractJsonObjects(text: string): unknown[] {
7
- const objects: unknown[] = [];
7
+ return extractJsonObjectSpans(text).map((span) => span.value);
8
+ }
9
+
10
+ /** The same objects, each with the exact source text it was parsed from. */
11
+ export function extractJsonObjectSpans(text: string): Array<{ value: unknown; source: string }> {
12
+ const objects: Array<{ value: unknown; source: string }> = [];
8
13
  let offset = 0;
9
14
  while (offset < text.length) {
10
15
  const start = text.indexOf("{", offset);
@@ -17,7 +22,7 @@ export function extractJsonObjects(text: string): unknown[] {
17
22
  const candidate = text.slice(start, end + 1);
18
23
  try {
19
24
  const value = JSON.parse(candidate) as unknown;
20
- if (value && typeof value === "object" && !Array.isArray(value)) objects.push(value);
25
+ if (value && typeof value === "object" && !Array.isArray(value)) objects.push({ value, source: candidate });
21
26
  offset = end + 1;
22
27
  } catch {
23
28
  offset = start + 1;
@@ -47,3 +52,37 @@ export function balancedObjectEnd(text: string, start: number): number {
47
52
  }
48
53
  return -1;
49
54
  }
55
+
56
+ /**
57
+ * True when any object in this (already valid) JSON text names a key twice.
58
+ * `JSON.parse` silently keeps the last value, so a duplicate is how text
59
+ * spliced into a string can override a field it was never meant to touch.
60
+ * Keys are compared decoded, so `"\u0076erdict"` duplicates `"verdict"`.
61
+ */
62
+ export function jsonHasDuplicateKeys(json: string): boolean {
63
+ const scopes: Array<Set<string> | undefined> = [];
64
+ let index = 0;
65
+ while (index < json.length) {
66
+ const character = json[index];
67
+ if (character === '"') {
68
+ let end = index + 1;
69
+ while (end < json.length && json[end] !== '"') end += json[end] === "\\" ? 2 : 1;
70
+ const token = json.slice(index, end + 1);
71
+ index = end + 1;
72
+ let next = index;
73
+ while (next < json.length && /\s/u.test(json[next]!)) next += 1;
74
+ const scope = scopes.at(-1);
75
+ if (json[next] === ":" && scope) {
76
+ const key = JSON.parse(token) as string;
77
+ if (scope.has(key)) return true;
78
+ scope.add(key);
79
+ }
80
+ continue;
81
+ }
82
+ if (character === "{") scopes.push(new Set());
83
+ else if (character === "[") scopes.push(undefined);
84
+ else if (character === "}" || character === "]") scopes.pop();
85
+ index += 1;
86
+ }
87
+ return false;
88
+ }