pi-claude-supervisor 0.6.0 → 0.7.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.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Contract between the Claude Code hook relay (a tiny script configured in
3
+ * Claude's hook settings) and the Supervisor's hook server. Claude Code runs the
4
+ * relay for every hook event of an interactive session; the relay forwards the
5
+ * event over a unix socket and prints the reply as Claude's hook output.
6
+ */
7
+
8
+ export type ClaudeHookEventName =
9
+ | "SessionStart"
10
+ | "SessionEnd"
11
+ | "UserPromptSubmit"
12
+ | "PreToolUse"
13
+ | "PermissionRequest"
14
+ | "Stop"
15
+ | "StopFailure"
16
+ | "Notification";
17
+
18
+ /** The subset of Claude Code hook input the Supervisor consumes (all fields untrusted). */
19
+ export interface ClaudeHookEvent {
20
+ hook_event_name: ClaudeHookEventName;
21
+ session_id: string;
22
+ cwd: string;
23
+ transcript_path?: string;
24
+ permission_mode?: string;
25
+ /** SessionStart */
26
+ source?: string;
27
+ /** SessionEnd */
28
+ reason?: string;
29
+ /** UserPromptSubmit */
30
+ prompt?: string;
31
+ /** PreToolUse / PermissionRequest */
32
+ tool_name?: string;
33
+ tool_input?: unknown;
34
+ tool_use_id?: string;
35
+ /** Stop */
36
+ last_assistant_message?: string;
37
+ stop_hook_active?: boolean;
38
+ /** StopFailure (an API/model error ended the turn; no Stop fires) */
39
+ error?: unknown;
40
+ /** Notification */
41
+ notification_type?: string;
42
+ message?: string;
43
+ }
44
+
45
+ export interface HookRelayRequest {
46
+ version: 1;
47
+ /** Relay process identity; ppid is the Claude Code process. */
48
+ pid: number;
49
+ ppid: number;
50
+ /** Inherited from Claude's environment when it runs inside tmux. */
51
+ tmuxPane?: string;
52
+ event: ClaudeHookEvent;
53
+ }
54
+
55
+ /**
56
+ * The Supervisor's answer. `undefined`/empty means "no decision": the relay
57
+ * prints nothing and Claude Code proceeds with its own permission mode.
58
+ */
59
+ export interface HookRelayReply {
60
+ /** PreToolUse / PermissionRequest */
61
+ permissionDecision?: "allow" | "deny" | "ask";
62
+ permissionDecisionReason?: string;
63
+ /** Stop: keep Claude working with this instruction instead of ending the turn. */
64
+ block?: boolean;
65
+ blockReason?: string;
66
+ }
67
+
68
+ /** Hook events the relay waits for a reply on; the rest are fire-and-forget. */
69
+ export const BLOCKING_HOOK_EVENTS: ReadonlySet<ClaudeHookEventName> = new Set(["PreToolUse", "PermissionRequest", "Stop"]);
70
+
71
+ /** How long Claude waits for the relay before treating the hook as undecided. */
72
+ export const HOOK_TIMEOUT_SECONDS = 180;
73
+
74
+ /**
75
+ * Socket routing: one socket per Pi process under `<stateDir>/hooks/`, plus a
76
+ * per-cwd symlink `<stateDir>/hooks/by-cwd/<sha256(cwd)>` created when the
77
+ * Supervisor takes the cwd lease, so a relay only needs the cwd to find its
78
+ * supervisor and a cwd with no supervisor is a no-op.
79
+ */
80
+ export interface HookEventSource {
81
+ /** Route every event whose canonical cwd matches to this handler until unsubscribed. */
82
+ subscribe(cwd: string, handler: (request: HookRelayRequest) => Promise<HookRelayReply | undefined>): Promise<() => Promise<void>>;
83
+ }
package/src/index.ts CHANGED
@@ -1,24 +1,71 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
- import { join, resolve } from "node:path";
4
+ import { dirname, join, resolve } from "node:path";
5
5
  import { accessSync, chmodSync, constants as fsConstants, lstatSync, mkdirSync } from "node:fs";
6
- import { readFile, realpath } from "node:fs/promises";
6
+ import { chmod, mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises";
7
7
  import { EventLog } from "./events.ts";
8
8
  import { redactSensitive } from "./redaction.ts";
9
9
  import { ProcessWorkerAdapter } from "./worker/process-adapter.ts";
10
10
  import { automaticWorkerEnvironment } from "./worker/environment.ts";
11
11
  import { TmuxWorkerAdapter, attachCommand } from "./worker/tmux-adapter.ts";
12
- import { Supervisor, type DecisionSessionClosedInfo, type SupervisorProgress } from "./supervisor.ts";
12
+ import { Supervisor, type DecisionSessionClosedInfo, type HumanInterventionNotice, type SupervisorProgress, type SupervisorTokenUsage } from "./supervisor.ts";
13
13
  import { evaluateCommand } from "./policy.ts";
14
14
  import { HumanWebhookNotifier } from "./notifications.ts";
15
- import { autonomyDefaults, loadSupervisorEnvironment } from "./config.ts";
15
+ import { autoInstallHooks, autonomyDefaults, closeWorkerOnCompletion, decisionCompactionTokens, decisionModel, decisionSessionRetentionDays, eventLogMaxBytes, loadSupervisorEnvironment, progressHeartbeatMs, reviewTimeoutMs, reviewerModel, tmuxMode, workerAutocompactTokens, workerMcpConfigPath, workerModel } from "./config.ts";
16
16
  import { DecisionSessionStore, type DecisionSessionRecord } from "./decision-session-store.ts";
17
17
  import { CwdLeaseStore, type CwdLeaseHandle, pathsOverlap, workerIdentity } from "./cwd-lease.ts";
18
18
  import { normalizeTaskSpec } from "./acceptance.ts";
19
19
  import { PiReadOnlyReviewer } from "./reviewer.ts";
20
+ import { resolvePiModel } from "./pi-model.ts";
21
+ import type { PiModel } from "./decision-worker.ts";
22
+ import { HookServer, hookSocketDirectory } from "./hooks/server.ts";
23
+ import { installUserHooks, uninstallUserHooks } from "./hooks/install.ts";
24
+ import { writeHookSettingsFile } from "./hooks/settings.ts";
25
+ import { HOOK_RELAY_SCRIPT, hookRelayCommand } from "./hooks/relay.ts";
20
26
  import type { TaskSpec, WorkerHandle } from "./types.ts";
21
27
 
28
+ /** Substring that marks a hook command entry as ours; kept in sync with src/hooks/install.ts's RELAY_MARKER. */
29
+ const RELAY_HOOK_MARKER = "/hooks/relay.js";
30
+
31
+ function claudeUserSettingsPath(): string {
32
+ return join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "settings.json");
33
+ }
34
+
35
+ /** `src/hooks/install.ts` does not export its relay-script writer; this mirrors it for an owned launch's static relay path. */
36
+ async function writeRelayScript(path: string): Promise<void> {
37
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
38
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
39
+ await writeFile(temporary, HOOK_RELAY_SCRIPT, { encoding: "utf8", mode: 0o600, flag: "wx" });
40
+ await rename(temporary, path);
41
+ await chmod(path, 0o600);
42
+ }
43
+
44
+ /** True when the user's Claude Code settings already register our relay for at least one hook event. */
45
+ async function userHooksInstalled(settingsPath: string): Promise<boolean> {
46
+ let raw: string;
47
+ try {
48
+ raw = await readFile(settingsPath, "utf8");
49
+ } catch {
50
+ return false;
51
+ }
52
+ let parsed: unknown;
53
+ try {
54
+ parsed = JSON.parse(raw);
55
+ } catch {
56
+ return false;
57
+ }
58
+ const hooks = parsed && typeof parsed === "object" ? (parsed as { hooks?: unknown }).hooks : undefined;
59
+ if (!hooks || typeof hooks !== "object") return false;
60
+ return Object.values(hooks as Record<string, unknown>).some((groups) =>
61
+ Array.isArray(groups) && groups.some((group) =>
62
+ group && typeof group === "object" && Array.isArray((group as { hooks?: unknown }).hooks)
63
+ && (group as { hooks: unknown[] }).hooks.some((entry) =>
64
+ entry && typeof entry === "object" && typeof (entry as { command?: unknown }).command === "string"
65
+ && (entry as { command: string }).command.includes(RELAY_HOOK_MARKER)
66
+ && /(?:^|\s)(?:'[^']*\/hooks\/relay\.js'|"[^"]*\/hooks\/relay\.js"|\S*\/hooks\/relay\.js)$/u.test((entry as { command: string }).command.trim()))));
67
+ }
68
+
22
69
  /**
23
70
  * Pi Claude Supervisor.
24
71
  *
@@ -58,13 +105,82 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
58
105
  format: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_FORMAT === "wecom" ? "wecom" : "generic",
59
106
  secret: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_SECRET,
60
107
  });
108
+ const onHumanRequired = (ctx: ExtensionContext) => async (notice: HumanInterventionNotice): Promise<void> => {
109
+ if (ctx.hasUI) notify(ctx, `Human required: ${notice.reason}${notice.question ? ` — ${notice.question}` : ""}${notice.attach ? ` (${notice.attach})` : ""}`, "warning");
110
+ // The person who just typed into the session does not need a WeCom alert
111
+ // telling them so; outbound notices are for decisions nobody is present for.
112
+ if (humanWebhook.enabled && notice.source !== "worker_prompt") {
113
+ try { await humanWebhook.notify(notice); }
114
+ catch (error) { console.error(`pi-claude-supervisor human intervention webhook failed: ${redactText(error instanceof Error ? error.message : String(error))}`); }
115
+ }
116
+ };
117
+ // Hook-driven interactive tmux supervision: the real Claude TUI reports its
118
+ // events (Stop, permission prompts, AskUserQuestion, human input) through
119
+ // Claude Code hooks relayed over a unix socket. See docs/architecture.md.
120
+ const interactiveHooksEnabled = transport === "tmux" && tmuxMode() === "interactive";
121
+ const hookServer = interactiveHooksEnabled ? new HookServer({ directory: hookSocketDirectory(stateDir) }) : undefined;
122
+ const relayPath = join(hookSocketDirectory(stateDir), "relay.js");
123
+ let hookServerReady: Promise<void> | undefined;
124
+ // Adoption needs the relay in the user's own Claude settings (a running
125
+ // Claude only picks hooks up from live-reloaded settings files). Installing
126
+ // at extension load rather than at npm install time keeps it under the
127
+ // operator's explicit transport configuration, idempotent, and reversible
128
+ // with /supervise uninstall-hooks; the relay is a ~1 ms no-op when no
129
+ // Supervisor is listening, so leaving it installed costs nothing.
130
+ let hookInstallNotice: string | undefined;
131
+ if (hookServer) {
132
+ hookServerReady = (async () => {
133
+ await writeRelayScript(relayPath);
134
+ await hookServer.listen();
135
+ if (autoInstallHooks()) {
136
+ try {
137
+ const installed = await installUserHooks({ stateDir, settingsPath: claudeUserSettingsPath() });
138
+ if (installed.changed) hookInstallNotice = `Installed the Claude Code hook relay in ${installed.settingsPath} so running sessions can be adopted (PI_CLAUDE_SUPERVISOR_AUTO_INSTALL_HOOKS=0 disables this; /supervise uninstall-hooks removes it)`;
139
+ } catch (error) {
140
+ console.error(`pi-claude-supervisor automatic hook install failed: ${redactText(error instanceof Error ? error.message : String(error))}`);
141
+ }
142
+ }
143
+ })();
144
+ hookServerReady.catch((error) => {
145
+ console.error(`pi-claude-supervisor hook server startup failed: ${redactText(error instanceof Error ? error.message : String(error))}`);
146
+ });
147
+ }
61
148
  const leaseDir = process.env.PI_CLAUDE_SUPERVISOR_CWD_LEASE_DIR ?? join(homedir(), ".pi", "agent", "claude-supervisor", "cwd-leases");
62
149
  assertRuntimeDirectory(stateDir, "state");
63
150
  assertRuntimeDirectory(leaseDir, "lease");
64
- const events = new EventLog(join(stateDir, "events.jsonl"));
151
+ const events = new EventLog(join(stateDir, "events.jsonl"), { maxBytes: eventLogMaxBytes() });
65
152
  const decisionStore = new DecisionSessionStore(join(stateDir, "decision-sessions"));
153
+ const retentionDays = decisionSessionRetentionDays();
154
+ if (retentionDays > 0) {
155
+ void decisionStore.prune({ maxAgeMs: retentionDays * 86_400_000 }).catch((error) => {
156
+ console.error(`pi-claude-supervisor decision session retention prune failed: ${redactText(error instanceof Error ? error.message : String(error))}`);
157
+ });
158
+ }
66
159
  const cwdLeaseStore = new CwdLeaseStore(leaseDir);
67
- const reviewer = automation ? new PiReadOnlyReviewer() : undefined;
160
+ const decisionModelSpec = decisionModel();
161
+ const reviewerModelSpec = reviewerModel();
162
+ // Resolve once and cache the promise so a misconfigured model fails closed on
163
+ // every subsequent start/recover rather than silently falling back.
164
+ let decisionPiModelPromise: Promise<PiModel | undefined> | undefined;
165
+ // Resolve each model once, but forget a rejected resolution so a transient
166
+ // failure (or a corrected env file) does not poison the rest of the process.
167
+ const resolveDecisionPiModel = (): Promise<PiModel | undefined> => {
168
+ if (!decisionPiModelPromise) {
169
+ const attempt = resolvePiModel(decisionModelSpec);
170
+ decisionPiModelPromise = attempt;
171
+ attempt.catch(() => { if (decisionPiModelPromise === attempt) decisionPiModelPromise = undefined; });
172
+ }
173
+ return decisionPiModelPromise;
174
+ };
175
+ let reviewerPiModelPromise: Promise<PiModel | undefined> | undefined;
176
+ const getReviewer = async (): Promise<PiReadOnlyReviewer> => {
177
+ if (!reviewerPiModelPromise) {
178
+ const attempt = resolvePiModel(reviewerModelSpec);
179
+ reviewerPiModelPromise = attempt;
180
+ attempt.catch(() => { if (reviewerPiModelPromise === attempt) reviewerPiModelPromise = undefined; });
181
+ }
182
+ return new PiReadOnlyReviewer({ timeoutMs: reviewTimeoutMs(), model: await reviewerPiModelPromise });
183
+ };
68
184
  const sessions = new Map<string, Supervisor>();
69
185
  const cwdLeases = new Map<string, CwdLeaseHandle>();
70
186
  const cleanupRequiredTasks = new Set<string>();
@@ -72,6 +188,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
72
188
  const pendingCwds = new Set<string>();
73
189
  const pendingStarts = new Set<Promise<void>>();
74
190
  const pendingStartSessions = new Set<Supervisor>();
191
+ const unconfirmedSweepAttempts = new Map<string, number>();
75
192
  let activeTaskId: string | undefined;
76
193
  let shuttingDown = false;
77
194
  let shutdownPromise: Promise<void> | undefined;
@@ -96,6 +213,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
96
213
  };
97
214
  const activeSessions = () => [...sessions.entries()].filter(([, session]) =>
98
215
  ["starting", "running", "waiting", "paused"].includes(session.state));
216
+ const tmuxModeLabel = (): string | undefined => (adapter.capabilities().transport === "tmux" ? tmuxMode() : undefined);
99
217
  const releaseLease = async (taskId: string): Promise<boolean> => {
100
218
  const lease = cwdLeases.get(taskId);
101
219
  if (!lease) return false;
@@ -112,7 +230,11 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
112
230
  sessions.delete(taskId);
113
231
  reservedCwds.delete(taskId);
114
232
  cleanupRequiredTasks.delete(taskId);
233
+ unconfirmedSweepAttempts.delete(taskId);
115
234
  if (activeTaskId === taskId) activeTaskId = undefined;
235
+ if (interactiveHooksEnabled) {
236
+ void rm(join(hookSocketDirectory(stateDir), `settings-${taskId}.json`), { force: true }).catch(() => {});
237
+ }
116
238
  };
117
239
  const releaseSettledReservations = async (): Promise<void> => {
118
240
  for (const [taskId, session] of sessions) {
@@ -123,17 +245,32 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
123
245
  if (await releaseLease(taskId)) forgetSession(taskId);
124
246
  continue;
125
247
  }
248
+ // A stuck session is otherwise polled at 1 Hz forever; back off to once
249
+ // every 10 sweeps after the first 10 unconfirmed attempts.
250
+ const attempts = unconfirmedSweepAttempts.get(taskId) ?? 0;
251
+ if (!(attempts < 10 || attempts % 10 === 0)) {
252
+ unconfirmedSweepAttempts.set(taskId, attempts + 1);
253
+ continue;
254
+ }
126
255
  try {
127
256
  const status = await adapter.getStatus(session.handle);
128
257
  const adoptedDetachConfirmed = detached
129
258
  && session.handle.ownership === "adopted"
130
259
  && !status.running;
131
- const cleanupConfirmed = status.processGroupCleaned === true || adoptedDetachConfirmed;
132
- if (!status.running && cleanupConfirmed && !status.cleanupError && (!status.cgroupError || status.cgroupRequired === false)) {
260
+ // A hand-back (owned interactive `release()`) leaves the Worker
261
+ // intentionally running; `detached` is its own cleanup confirmation
262
+ // and does not wait for `!status.running`.
263
+ const handedBackConfirmed = status.detached === true;
264
+ const cleanupConfirmed = status.processGroupCleaned === true || adoptedDetachConfirmed || handedBackConfirmed;
265
+ if ((handedBackConfirmed || !status.running) && cleanupConfirmed && !status.cleanupError && (!status.cgroupError || status.cgroupRequired === false)) {
133
266
  if (await releaseLease(taskId)) forgetSession(taskId);
267
+ else unconfirmedSweepAttempts.set(taskId, attempts + 1);
268
+ } else {
269
+ unconfirmedSweepAttempts.set(taskId, attempts + 1);
134
270
  }
135
271
  } catch {
136
272
  // Keep the reservation when cleanup status cannot be confirmed.
273
+ unconfirmedSweepAttempts.set(taskId, attempts + 1);
137
274
  }
138
275
  }
139
276
  };
@@ -146,8 +283,8 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
146
283
  const workerCleanupConfirmed = async (handle: NonNullable<Supervisor["handle"]>): Promise<boolean> => {
147
284
  try {
148
285
  const status = await adapter.getStatus(handle);
149
- return !status.running
150
- && status.processGroupCleaned === true
286
+ return (status.detached === true || !status.running)
287
+ && (status.processGroupCleaned === true || status.detached === true)
151
288
  && !status.cleanupError
152
289
  && (!status.cgroupError || status.cgroupRequired === false);
153
290
  } catch {
@@ -155,6 +292,14 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
155
292
  }
156
293
  };
157
294
  const stopSession = async (session: Supervisor, reason: string, releasePersistent = false, preserveDecisionSession = false): Promise<void> => {
295
+ // Already handed back to the operator (a kept-open interactive session,
296
+ // or an adopted session released earlier in this same shutdown pass) and
297
+ // this call is itself a hand-back sweep (`releasePersistent`, used only
298
+ // at shutdown): there is nothing left to stop, and polling for
299
+ // `!running` would wait out the deadline against a Worker that is alive
300
+ // on purpose. An explicit `/supervise stop` (`releasePersistent` false)
301
+ // must still fall through so it can kill a kept-open session on request.
302
+ if (session.released && releasePersistent) return;
158
303
  const handle = session.handle;
159
304
  const persistent = adapter.capabilities().persistentSession && Boolean(handle);
160
305
  const taskId = session.task?.taskId;
@@ -232,17 +377,28 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
232
377
  const fileSpec = specPath ? await readTaskSpecFile(specPath, ctx.cwd) : undefined;
233
378
  const spec = fileSpec ?? { autonomy: autonomyDefaults() };
234
379
  const goal = fileSpec?.goal ?? task;
235
- // Adopted sessions are explicit manual compatibility controls. They
380
+ // Adopted sessions are explicit manual compatibility controls; they
236
381
  // never enter the automatic Reviewer/decision loop, even when the
237
- // extension is globally configured for unattended starts.
238
- const taskAutomation = operation !== "adopt-tmux" && automation && spec.autonomy.unattended;
382
+ // extension is globally configured for unattended starts — except
383
+ // in interactive tmux mode, where an adopted session reports its
384
+ // own events through hooks exactly like an owned one.
385
+ const taskAutomation = (operation !== "adopt-tmux" || tmuxMode() === "interactive") && automation && spec.autonomy.unattended;
386
+ const interactive = taskAutomation && adapter.capabilities().transport === "tmux" && tmuxMode() === "interactive";
239
387
  if (!goal) throw new Error(operation === "adopt-tmux" ? "Usage: /supervise adopt-tmux [--spec <file>] <tmux-session> <task>" : "Usage: /supervise start [--spec <file>] <task>");
240
388
  if (operation === "adopt-tmux" && adapter.capabilities().transport !== "tmux") throw new Error("/supervise adopt-tmux requires PI_CLAUDE_SUPERVISOR_TRANSPORT=tmux");
389
+ if (operation === "adopt-tmux" && interactive && !await userHooksInstalled(claudeUserSettingsPath())) {
390
+ await hookServerReady?.catch(() => {});
391
+ if (!await userHooksInstalled(claudeUserSettingsPath())) throw new Error("run /supervise install-hooks first so the adopted session can report its events");
392
+ }
241
393
  const [command, ...workerArgs] = parseCommand(process.env.PI_CLAUDE_SUPERVISOR_WORKER ?? "claude");
242
394
  if (!command) throw new Error("PI_CLAUDE_SUPERVISOR_WORKER must contain an executable");
243
395
  const policy = evaluateCommand(command, workerArgs);
244
396
  const approval: { actor: "human"; reason: string } | undefined = undefined;
245
397
  if (policy.decision === "deny") throw new Error(`Worker command denied: ${policy.reason}`);
398
+ // Resolve models before any lease/reservation state is mutated below, so
399
+ // a misconfigured model spec fails closed without leaking a reservation.
400
+ const decisionPiModel = await resolveDecisionPiModel();
401
+ const reviewer = taskAutomation ? await getReviewer() : undefined;
246
402
  if (shuttingDown) throw new Error("Pi session is shutting down");
247
403
  const cwdKey = await canonicalCwd(ctx.cwd);
248
404
  await releaseSettledReservations();
@@ -263,19 +419,30 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
263
419
  }
264
420
  pendingCwds.add(cwdKey);
265
421
  const session = new Supervisor(adapter, events, {
266
- reviewer: taskAutomation ? reviewer : undefined,
422
+ reviewer,
423
+ reviewTimeoutMs: reviewTimeoutMs(),
424
+ progressHeartbeatMs: progressHeartbeatMs(),
425
+ decisionModel: decisionPiModel,
426
+ decisionCompactionTokens: decisionCompactionTokens(),
267
427
  onCandidate: async (notice) => {
268
- if (ctx.hasUI) notify(ctx, `Candidate ${notice.status}: ${notice.reason}`, notice.status === "ready" ? "info" : "warning");
428
+ if (ctx.hasUI) notify(ctx, `Candidate ${notice.status}: ${notice.reason}${formatUsageSuffix(notice.usage)}`, notice.status === "ready" ? "info" : "warning");
269
429
  if (humanWebhook.enabled) {
270
430
  try { await humanWebhook.notifyCandidate(notice); }
271
431
  catch (error) { console.error(`pi-claude-supervisor candidate webhook failed: ${redactText(error instanceof Error ? error.message : String(error))}`); }
272
432
  }
273
433
  },
434
+ onHumanRequired: onHumanRequired(ctx),
274
435
  });
275
436
  pendingStartSessions.add(session);
276
437
  let startupCleanupCompleted = false;
438
+ const hookSettingsPath = interactive && !tmuxSession ? join(hookSocketDirectory(stateDir), `settings-${taskId}.json`) : undefined;
277
439
  const startOperation = (async () => {
278
440
  try {
441
+ if (interactive) {
442
+ if (!hookServer || !hookServerReady) throw new Error("interactive tmux supervision requires PI_CLAUDE_SUPERVISOR_TRANSPORT=tmux");
443
+ await hookServerReady;
444
+ if (hookSettingsPath) await writeHookSettingsFile(hookSettingsPath, hookRelayCommand(relayPath));
445
+ }
279
446
  const handle = await session.start({
280
447
  taskId,
281
448
  onProgress: progress(ctx),
@@ -287,7 +454,14 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
287
454
  env: selectedWorkerEnvironment(taskAutomation),
288
455
  approval,
289
456
  automation: taskAutomation,
457
+ interactive,
458
+ hookSource: interactive ? hookServer : undefined,
459
+ hookSettingsPath,
460
+ keepWorkerOnCompletion: interactive ? !closeWorkerOnCompletion() : undefined,
290
461
  retainCgroupUntilLeaseRelease: taskAutomation,
462
+ workerArgOptions: taskAutomation
463
+ ? { model: workerModel(), autocompactTokens: workerAutocompactTokens(), mcpConfigPath: workerMcpConfigPath() }
464
+ : undefined,
291
465
  onWorkerStartup: taskAutomation
292
466
  ? async (startupHandle) => {
293
467
  await lease.updateWorker({
@@ -364,7 +538,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
364
538
  ...(current?.recoveryWorker ? { recoveryWorker: current.recoveryWorker } : {}),
365
539
  });
366
540
  },
367
- onDecisionSessionProgress: (info) => decisionStore.update(info.taskId, { turn: info.turn, repairRound: info.repairRound, ...(info.lastFindingSignature ? { lastFindingSignature: info.lastFindingSignature } : {}) }),
541
+ onDecisionSessionProgress: (info) => decisionStore.update(info.taskId, { turn: info.turn, repairRound: info.repairRound, workerCostUsd: info.workerCostUsd, ...(info.lastFindingSignature ? { lastFindingSignature: info.lastFindingSignature } : {}) }),
368
542
  onDecisionSessionClosed: handleDecisionSessionClosed,
369
543
  });
370
544
  const startedTaskId = session.task?.taskId;
@@ -489,6 +663,15 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
489
663
  if (policy.decision === "deny") throw new Error(`Worker command denied: ${policy.reason}`);
490
664
  const automaticRecovery = record.spec?.autonomy.unattended !== false;
491
665
  if (automaticRecovery && !record.resolvedExecutable) throw new Error("automatic recovery requires a persisted resolved Claude executable identity");
666
+ // The decision-session record does not persist whether the original
667
+ // task was interactive; the current transport/mode configuration is
668
+ // used instead, so it must not change between start and recovery.
669
+ const recoveryInteractive = automaticRecovery && adapter.capabilities().transport === "tmux" && tmuxMode() === "interactive";
670
+ const recoveryHookSettingsPath = recoveryInteractive ? join(hookSocketDirectory(stateDir), `settings-${record.taskId}.json`) : undefined;
671
+ // Resolve models before any lease/recovery state is mutated below, so a
672
+ // misconfigured model spec fails closed without leaking a reservation.
673
+ const decisionPiModel = await resolveDecisionPiModel();
674
+ const reviewer = automaticRecovery ? await getReviewer() : undefined;
492
675
  const approval = record.approval;
493
676
  const lease = await cwdLeaseStore.acquire(cwdKey, record.taskId, adapter.capabilities().transport, {
494
677
  startup: automaticRecovery,
@@ -516,19 +699,29 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
516
699
  }
517
700
  pendingCwds.add(cwdKey);
518
701
  const session = new Supervisor(adapter, events, {
519
- reviewer: automaticRecovery ? reviewer : undefined,
702
+ reviewer,
703
+ reviewTimeoutMs: reviewTimeoutMs(),
704
+ progressHeartbeatMs: progressHeartbeatMs(),
705
+ decisionModel: decisionPiModel,
706
+ decisionCompactionTokens: decisionCompactionTokens(),
520
707
  onCandidate: async (notice) => {
521
- if (ctx.hasUI) notify(ctx, `Candidate ${notice.status}: ${notice.reason}`, notice.status === "ready" ? "info" : "warning");
708
+ if (ctx.hasUI) notify(ctx, `Candidate ${notice.status}: ${notice.reason}${formatUsageSuffix(notice.usage)}`, notice.status === "ready" ? "info" : "warning");
522
709
  if (humanWebhook.enabled) {
523
710
  try { await humanWebhook.notifyCandidate(notice); }
524
711
  catch (error) { console.error(`pi-claude-supervisor candidate webhook failed: ${redactText(error instanceof Error ? error.message : String(error))}`); }
525
712
  }
526
713
  },
714
+ onHumanRequired: onHumanRequired(ctx),
527
715
  });
528
716
  pendingStartSessions.add(session);
529
717
  const recoveryOperation = (async () => {
530
718
  let startedHandle: WorkerHandle | undefined;
531
719
  try {
720
+ if (recoveryInteractive) {
721
+ if (!hookServer || !hookServerReady) throw new Error("interactive tmux supervision requires PI_CLAUDE_SUPERVISOR_TRANSPORT=tmux");
722
+ await hookServerReady;
723
+ if (recoveryHookSettingsPath) await writeHookSettingsFile(recoveryHookSettingsPath, hookRelayCommand(relayPath));
724
+ }
532
725
  const handle = await session.start({
533
726
  taskId: record.taskId,
534
727
  onProgress: progress(ctx),
@@ -545,7 +738,14 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
545
738
  env: selectedWorkerEnvironment(automaticRecovery),
546
739
  approval,
547
740
  automation: automaticRecovery,
741
+ interactive: recoveryInteractive,
742
+ hookSource: recoveryInteractive ? hookServer : undefined,
743
+ hookSettingsPath: recoveryHookSettingsPath,
744
+ keepWorkerOnCompletion: recoveryInteractive ? !closeWorkerOnCompletion() : undefined,
548
745
  retainCgroupUntilLeaseRelease: automaticRecovery,
746
+ workerArgOptions: automaticRecovery
747
+ ? { model: workerModel(), autocompactTokens: workerAutocompactTokens(), mcpConfigPath: workerMcpConfigPath() }
748
+ : undefined,
549
749
  onWorkerStartup: automaticRecovery
550
750
  ? async (startupHandle) => {
551
751
  await lease.updateWorker({
@@ -591,6 +791,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
591
791
  initialTurn: record.turn,
592
792
  initialRepairRound: record.repairRound,
593
793
  initialFindingSignature: record.lastFindingSignature,
794
+ initialWorkerCostUsd: record.workerCostUsd,
594
795
  decisionSessionFile: record.decisionSessionFile,
595
796
  decisionSessionDir: decisionStore.directory,
596
797
  onDecisionSessionReady: async (info) => {
@@ -623,7 +824,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
623
824
  ...(current.recoveryWorker ? { recoveryWorker: current.recoveryWorker } : {}),
624
825
  });
625
826
  },
626
- onDecisionSessionProgress: (info) => decisionStore.update(info.taskId, { turn: info.turn, repairRound: info.repairRound, ...(info.lastFindingSignature ? { lastFindingSignature: info.lastFindingSignature } : {}) }),
827
+ onDecisionSessionProgress: (info) => decisionStore.update(info.taskId, { turn: info.turn, repairRound: info.repairRound, workerCostUsd: info.workerCostUsd, ...(info.lastFindingSignature ? { lastFindingSignature: info.lastFindingSignature } : {}) }),
627
828
  onDecisionSessionClosed: handleDecisionSessionClosed,
628
829
  });
629
830
  startedHandle = handle;
@@ -704,14 +905,24 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
704
905
  }
705
906
  } else if (operation === "sessions") {
706
907
  const recoverable = await decisionStore.list({ activeOnly: true });
707
- message = formatSessions(sessions, recoverable);
908
+ message = formatSessions(sessions, recoverable, tmuxModeLabel());
909
+ const quarantined = await cwdLeaseStore.quarantined();
910
+ if (quarantined.length > 0) {
911
+ message += `\nQuarantined cwd lease records (${quarantined.length}) in ${leaseDir}/quarantine: ${redactText(quarantined.join(", "))}`;
912
+ }
708
913
  } else if (operation === "status") {
709
914
  const { session, sessionId } = resolveSession(sessions, activeTaskId, rest, true);
710
915
  message = session
711
- ? `task=${sessionId} state=${session.state} worker=${session.handle?.id ?? "none"}`
712
- : formatSessions(sessions, await decisionStore.list({ activeOnly: true }));
916
+ ? `task=${sessionId} state=${session.state} worker=${session.handle?.id ?? "none"}${tmuxModeLabel() ? ` mode=${tmuxModeLabel()}` : ""} ${formatUsageDetail(session.usage)}`
917
+ : formatSessions(sessions, await decisionStore.list({ activeOnly: true }), tmuxModeLabel());
713
918
  } else if (operation === "capabilities") {
714
919
  message = JSON.stringify(adapter.capabilities(), null, 2);
920
+ } else if (operation === "install-hooks" || operation === "uninstall-hooks") {
921
+ const settingsPath = claudeUserSettingsPath();
922
+ const result = operation === "install-hooks"
923
+ ? await installUserHooks({ stateDir, settingsPath })
924
+ : await uninstallUserHooks({ stateDir, settingsPath });
925
+ message = `${operation}: ${result.changed ? "updated" : "already up to date"} ${result.settingsPath}`;
715
926
  } else if (operation === "poll" && rest[0] === "all") {
716
927
  const reports = await Promise.all(activeSessions().map(async ([taskId, session]) => {
717
928
  const result = await session.poll();
@@ -752,9 +963,10 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
752
963
  } else if (operation === "resume-auto") {
753
964
  await session.resumeAutomation(); message = `Automatic decisions resumed: ${sessionId}.`;
754
965
  } else {
755
- 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");
966
+ 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|install-hooks|uninstall-hooks");
756
967
  }
757
968
  }
969
+ if (hookInstallNotice) { notify(ctx, hookInstallNotice); hookInstallNotice = undefined; }
758
970
  notify(ctx, message);
759
971
  } catch (error) {
760
972
  notify(ctx, error instanceof Error ? error.message : String(error), "warning");
@@ -788,6 +1000,18 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
788
1000
  }
789
1001
  const results = await Promise.allSettled([...sessions.values()].map((session) => stopSession(session, "Pi session shutdown", true, true)));
790
1002
  await releaseSettledReservations();
1003
+ if (hookServer) {
1004
+ try {
1005
+ // listen() may still be in flight (e.g. shutdown racing startup, or
1006
+ // a task that never reached an interactive start); close() only
1007
+ // tears down the socket once #server is set, so an unawaited
1008
+ // listen() here would leak an open listening socket forever.
1009
+ if (hookServerReady) await hookServerReady.catch(() => {});
1010
+ await hookServer.close();
1011
+ } catch (error) {
1012
+ console.error(`pi-claude-supervisor hook server shutdown failed: ${redactText(error instanceof Error ? error.message : String(error))}`);
1013
+ }
1014
+ }
791
1015
  const failures = [...startupFailures, ...results.filter((result): result is PromiseRejectedResult => result.status === "rejected")];
792
1016
  if (failures.length > 0) {
793
1017
  for (const failure of failures) console.error(`pi-claude-supervisor shutdown cleanup failed: ${redactText(failure.reason instanceof Error ? failure.reason.message : String(failure.reason))}`);
@@ -832,15 +1056,32 @@ function requiresWorkerCleanup(error: unknown): boolean {
832
1056
  return Boolean(error && typeof error === "object" && (error as { workerCleanupRequired?: unknown }).workerCleanupRequired === true);
833
1057
  }
834
1058
 
835
- function formatSessions(sessions: Map<string, Supervisor>, recoverable: DecisionSessionRecord[] = []): string {
1059
+ function formatSessions(sessions: Map<string, Supervisor>, recoverable: DecisionSessionRecord[] = [], tmuxModeLabel?: string): string {
1060
+ const modeSuffix = tmuxModeLabel ? ` mode=${tmuxModeLabel}` : "";
836
1061
  const active = [...sessions.entries()]
837
- .map(([taskId, session]) => `${taskId} state=${session.state} cwd=${session.task?.cwd ?? "-"} worker=${session.handle?.id ?? "-"}`);
1062
+ .map(([taskId, session]) => `${taskId} state=${session.state} cwd=${session.task?.cwd ?? "-"} worker=${session.handle?.id ?? "-"}${modeSuffix}`);
838
1063
  const pending = recoverable
839
1064
  .filter((record) => !sessions.has(record.taskId))
840
- .map((record) => `${record.taskId} state=recoverable recovery=${record.recoveryState} cwd=${record.cwd} worker=${record.recoveryWorker?.id ?? "-"}`);
1065
+ .map((record) => `${record.taskId} state=recoverable recovery=${record.recoveryState} cwd=${record.cwd} worker=${record.recoveryWorker?.id ?? "-"}${modeSuffix}`);
841
1066
  return [...active, ...pending].join("\n") || "No task sessions.";
842
1067
  }
843
1068
 
1069
+ function tokenTotal(tokens: { input: number; output: number; cacheRead: number; cacheWrite: number }): number {
1070
+ return tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite;
1071
+ }
1072
+
1073
+ function formatUsageDetail(usage: SupervisorTokenUsage): string {
1074
+ const workerTokens = tokenTotal(usage.workerTokens);
1075
+ const piTokens = tokenTotal(usage.decision) + tokenTotal(usage.reviewer);
1076
+ return `cost=$${usage.workerCostUsd.toFixed(2)} workerTurns=${usage.workerTurns} workerTokens=${workerTokens} piTokens=${piTokens} decisionCalls=${usage.decision.calls} reviewerCalls=${usage.reviewer.calls}`;
1077
+ }
1078
+
1079
+ function formatUsageSuffix(usage?: SupervisorTokenUsage): string {
1080
+ if (!usage) return "";
1081
+ const piTokens = tokenTotal(usage.decision) + tokenTotal(usage.reviewer);
1082
+ return ` (worker $${usage.workerCostUsd.toFixed(2)}, Pi ${piTokens} tokens)`;
1083
+ }
1084
+
844
1085
  function selectedWorkerEnvironment(automatic = false): NodeJS.ProcessEnv {
845
1086
  if (automatic) return automaticWorkerEnvironment(process.env);
846
1087
  const result: NodeJS.ProcessEnv = {};
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Claude occasionally wraps its answer in a fence, a sentence, or repeats
3
+ * the same JSON object. Extract one bounded object while rejecting conflicting
4
+ * objects: prose is harmless, but ambiguity must remain non-publishable.
5
+ */
6
+ export function extractJsonObjects(text: string): unknown[] {
7
+ const objects: unknown[] = [];
8
+ let offset = 0;
9
+ while (offset < text.length) {
10
+ const start = text.indexOf("{", offset);
11
+ if (start < 0) break;
12
+ const end = balancedObjectEnd(text, start);
13
+ if (end < 0) {
14
+ offset = start + 1;
15
+ continue;
16
+ }
17
+ const candidate = text.slice(start, end + 1);
18
+ try {
19
+ const value = JSON.parse(candidate) as unknown;
20
+ if (value && typeof value === "object" && !Array.isArray(value)) objects.push(value);
21
+ offset = end + 1;
22
+ } catch {
23
+ offset = start + 1;
24
+ }
25
+ }
26
+ return objects;
27
+ }
28
+
29
+ export function balancedObjectEnd(text: string, start: number): number {
30
+ let depth = 0;
31
+ let inString = false;
32
+ let escaped = false;
33
+ for (let index = start; index < text.length; index += 1) {
34
+ const character = text[index];
35
+ if (inString) {
36
+ if (escaped) escaped = false;
37
+ else if (character === "\\") escaped = true;
38
+ else if (character === '"') inString = false;
39
+ continue;
40
+ }
41
+ if (character === '"') {
42
+ inString = true;
43
+ continue;
44
+ }
45
+ if (character === "{") depth += 1;
46
+ else if (character === "}" && --depth === 0) return index;
47
+ }
48
+ return -1;
49
+ }