pi-claude-supervisor 0.9.2 → 0.10.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.
@@ -89,6 +89,12 @@ export interface DecisionWorkerOptions {
89
89
  compactionTokens?: number;
90
90
  /** Token accounting for every model call made by this session. */
91
91
  onUsage?: (usage: PiUsageSample) => void;
92
+ /** A decision request failed transiently and will be asked again after `delayMs`. */
93
+ onRetry?: (event: WorkerEvent, info: { attempt: number; delayMs: number; error: unknown }) => void;
94
+ /** True once a later event has made this one moot: it is then dropped, neither retried nor failed. */
95
+ isSuperseded?: (event: WorkerEvent) => boolean;
96
+ /** A moot event was dropped while its decision was failing (for the audit log). */
97
+ onSuperseded?: (event: WorkerEvent, error: unknown) => void;
92
98
  }
93
99
 
94
100
  export type PiModel = NonNullable<NonNullable<Parameters<typeof createAgentSession>[0]>["model"]>;
@@ -105,6 +111,58 @@ export const STALE_VERIFICATION_TURNS = 3;
105
111
  const DEFAULT_DECISION_RETRY_BACKOFF_MS = 5_000;
106
112
  const MAX_DECISION_RETRY_BACKOFF_MS = 60_000;
107
113
 
114
+ /**
115
+ * Errors no amount of waiting fixes: rejected, missing or expired
116
+ * credentials, an exhausted account, a model that does not exist. Matched on
117
+ * words, not bare status codes, which also turn up inside request ids, URLs
118
+ * and wrapped upstream errors.
119
+ */
120
+ const CONFIGURATION_ERROR = /(?:account|organi[sz]ation)[^\n]{0,40}(?:terminated|disabled|suspended|deactivated)|authentication|unauthori[sz]ed|incorrect api key|token has expired|invalid[_ ]?x?-?api[_ -]?key|api[_ ]key[_ ](?:not[_ ]valid|expired|invalid)|no api key found|security token[^\n]{0,40}invalid|UnrecognizedClient|ExpiredToken|AccessDenied|permission[_ ]denied|permission_error|payment[_ ]required|credit[_ ]balance|insufficient[_ ]quota|billing_error|not_found_error|models?\b[^\n]{0,80}\b(?:not[_ ]found|does not exist|is invalid)|model identifier is invalid|unsupported[_ ]model|invalid[_ ]model/iu;
121
+
122
+ /**
123
+ * Errors that pass by themselves: provider load and rate limits (including a
124
+ * quota that resets, whose Gemini message also mentions "billing"), 5xx,
125
+ * gateway and network failures. Based on pi-ai's own retryable list. Anything
126
+ * matching neither list, such as a prompt that is too long, a corrupted
127
+ * session or this worker's own request timeout, fails the same way every time
128
+ * and stays bounded by maxDecisionRetries.
129
+ */
130
+ const TRANSIENT_ERROR = /overloaded|rate.?limit|too many requests|resource.?exhausted|retry in \d|quota exceeded for metric|exceeded your current quota|\b(?:429|500|502|503|504|524|529)\b|service.?unavailable|temporarily unavailable|server.?error|internal.?error|provider.?returned.?error|network.?error|connection.?(?:error|refused|lost|reset)|other side closed|fetch failed|getaddrinfo|ENOTFOUND|EAI_AGAIN|ECONNRESET|ETIMEDOUT|upstream.?connect|reset before headers|socket hang up|socket connection was closed|gateway.?time.?out|websocket.?(?:closed|error)|ended without|stream ended before|http2 request did not get a response|you can retry your request|try your request again|please retry your request|try again in|timed? ?out|timeout|terminated|premature close|\b408\b/iu;
131
+
132
+ /** The Decision Worker's own per-request timeout: the model ran the whole budget. */
133
+ const OWN_TIMEOUT = /^Decision Worker [^\n]* timed out after \d+ms$/u;
134
+
135
+ export type DecisionErrorClass = "configuration" | "transient" | "other";
136
+
137
+ export function classifyDecisionError(error: unknown): DecisionErrorClass {
138
+ if (!(error instanceof Error)) return "other";
139
+ if (error.name === "DecisionWorkerConfigError") return "configuration";
140
+ if (OWN_TIMEOUT.test(error.message)) return "other";
141
+ // Judge the provider's own text, not this worker's "… model request failed:" wrapper.
142
+ const text = error.message.replace(/^[^\n:]*model request failed: /u, "");
143
+ if (CONFIGURATION_ERROR.test(text)) return "configuration";
144
+ // A provider's error turn (pi-ai reports it as a stop reason, not a throw).
145
+ if (error.name === "DecisionWorkerApiError" && TRANSIENT_ERROR.test(text)) return "transient";
146
+ // A thrown transport failure before any provider answered.
147
+ if (/fetch failed|getaddrinfo|ENOTFOUND|EAI_AGAIN|ECONNRESET|ETIMEDOUT|socket hang up|network.?error/iu.test(text)) return "transient";
148
+ return "other";
149
+ }
150
+
151
+ export function isDecisionConfigurationError(error: unknown): boolean {
152
+ return classifyDecisionError(error) === "configuration";
153
+ }
154
+
155
+ function configurationError(error: Error): Error {
156
+ const wrapped = new Error(`Decision Worker configuration error (credentials, billing or model): ${error.message}`, { cause: error });
157
+ wrapped.name = "DecisionWorkerConfigError";
158
+ return wrapped;
159
+ }
160
+
161
+ /** Marks an error thrown while the Supervisor applied an action, as opposed to one from the model. */
162
+ export function isDecisionActionError(error: unknown): boolean {
163
+ return Boolean(error && typeof error === "object" && (error as { decisionActionFailed?: unknown }).decisionActionFailed === true);
164
+ }
165
+
108
166
  /**
109
167
  * A persistent Pi SDK session used only for supervision decisions.
110
168
  * It has read-only repository tools and can request typed actions, but it
@@ -122,6 +180,7 @@ export class PiDecisionWorker implements DecisionWorkerLike {
122
180
  readonly #timeoutMs: number;
123
181
  /** Ends a startup retry backoff early; set only while one is waiting. */
124
182
  #wakeStartupBackoff?: () => void;
183
+ #wakeRetryBackoff?: () => void;
125
184
  readonly #retryBackoffMs: number;
126
185
  readonly #compactionTokens: number;
127
186
  /** Set after a compaction; the next primary decision prompt re-sends the startup instructions once. */
@@ -187,7 +246,7 @@ export class PiDecisionWorker implements DecisionWorkerLike {
187
246
  if (this.#closed) throw closedDuringStartup();
188
247
  // Only a provider error is worth another attempt: a timeout already
189
248
  // spent the whole prompt budget and would only multiply it.
190
- const retryable = error instanceof Error && error.name === "DecisionWorkerApiError";
249
+ const retryable = error instanceof Error && error.name === "DecisionWorkerApiError" && classifyDecisionError(error) !== "configuration";
191
250
  if (attempt >= maxRetries || !retryable) {
192
251
  try { await this.#options.onStartupFailure?.(error); } catch { /* preserve the original startup failure */ }
193
252
  throw error;
@@ -259,11 +318,34 @@ export class PiDecisionWorker implements DecisionWorkerLike {
259
318
  } catch (error) {
260
319
  if (this.#closed) return;
261
320
  if (error instanceof Error && error.name === "AbortError") throw error;
262
- if (attempt >= maxRetries) throw error;
321
+ // A later event made this one moot: its failure decides nothing, and
322
+ // failing it would park a task whose current turn is still undecided.
323
+ if (this.#dropIfSuperseded(event, error)) return;
324
+ const kind = classifyDecisionError(error);
325
+ // Waiting does not fix credentials, billing or a missing model: park now, with that reason.
326
+ if (kind === "configuration") throw configurationError(error as Error);
327
+ // A transient provider or network outage on a completed turn is
328
+ // waited out rather than parking the task: nobody is there to resume
329
+ // it, and the Supervisor's idle watchdog verifies the Worker's
330
+ // finished work if no decision ever lands. Nothing like that backs up
331
+ // any other event: a permission request blocks the Worker mid-turn
332
+ // (on some transports nothing else answers it), and after an exit
333
+ // only this decision starts verification. Those, a turn a later event
334
+ // has made moot, and anything that fails the same way on every
335
+ // attempt stay bounded.
336
+ const waitOut = kind === "transient" && event.type === "turn_completed";
337
+ if (!waitOut && attempt >= maxRetries) throw error;
263
338
  attempt += 1;
264
339
  // 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)));
340
+ const delayMs = Math.min(MAX_DECISION_RETRY_BACKOFF_MS, this.#retryBackoffMs * 3 ** Math.min(attempt, 10));
341
+ try { this.#options.onRetry?.(event, { attempt, delayMs, error }); } catch { /* observability only */ }
342
+ await new Promise<void>((resolveWait) => {
343
+ const timer = setTimeout(() => { this.#wakeRetryBackoff = undefined; resolveWait(); }, delayMs);
344
+ // close() wakes this wait, so a stop does not sit out the backoff.
345
+ this.#wakeRetryBackoff = () => { clearTimeout(timer); this.#wakeRetryBackoff = undefined; resolveWait(); };
346
+ });
266
347
  if (!this.#session || this.#closed) return;
348
+ if (this.#dropIfSuperseded(event, error)) return;
267
349
  continue;
268
350
  }
269
351
  if (this.#closed) return;
@@ -285,8 +367,14 @@ export class PiDecisionWorker implements DecisionWorkerLike {
285
367
  continue;
286
368
  }
287
369
  // An action handler may stop or close the session. Do not replay an
288
- // already-decoded action if the handler itself fails.
289
- await this.#options.onAction(action, event);
370
+ // already-decoded action if the handler itself fails, and do not let
371
+ // its failure read as a model failure.
372
+ try {
373
+ await this.#options.onAction(action, event);
374
+ } catch (error) {
375
+ if (error && typeof error === "object") (error as { decisionActionFailed?: boolean }).decisionActionFailed = true;
376
+ throw error;
377
+ }
290
378
  await this.#maybeCompact();
291
379
  return;
292
380
  }
@@ -327,11 +415,23 @@ export class PiDecisionWorker implements DecisionWorkerLike {
327
415
  return Boolean(this.#options.sessionFile && this.#sessionFile === this.#options.sessionFile);
328
416
  }
329
417
 
418
+ #dropIfSuperseded(event: WorkerEvent, error: unknown): boolean {
419
+ if (this.#options.isSuperseded?.(event) !== true) return false;
420
+ try { this.#options.onSuperseded?.(event, error); } catch { /* observability only */ }
421
+ return true;
422
+ }
423
+
424
+ /** Resolves once every queued decision has finished or given up (shutdown and tests). */
425
+ async settled(): Promise<void> {
426
+ await this.#tail.catch(() => {});
427
+ }
428
+
330
429
  async close(): Promise<void> {
331
430
  // Do not await #tail here: onAction may be closing the worker from inside
332
431
  // the same queued decision, which would otherwise deadlock shutdown.
333
432
  this.#closed = true;
334
433
  this.#wakeStartupBackoff?.();
434
+ this.#wakeRetryBackoff?.();
335
435
  const session = this.#session;
336
436
  this.#session = undefined;
337
437
  if (session) await session.abort().catch(() => {});
@@ -12,6 +12,14 @@ import { shellQuote } from "../policy.ts";
12
12
  * with empty stdout, so a relay bug never blocks Claude Code. The fast path
13
13
  * (no Supervisor listening for this cwd) never requires "net", matching the
14
14
  * expectation that it costs about a millisecond.
15
+ *
16
+ * Routing prefers `CLAUDE_PROJECT_DIR`, which Claude Code sets on every hook
17
+ * command to the directory the session was launched in, over the event's
18
+ * `cwd`, which follows a persisted Bash `cd` into a subdirectory. Routing by
19
+ * `cwd` alone silently drops every later event (Stop, PreToolUse,
20
+ * PermissionRequest) of a Worker that changed directory. A nested Claude
21
+ * sets its own project directory, so it is not routed to its parent's
22
+ * Supervisor by this.
15
23
  */
16
24
  export const HOOK_RELAY_SCRIPT = `
17
25
  (function () {
@@ -73,22 +81,26 @@ export const HOOK_RELAY_SCRIPT = `
73
81
  var hookDir = process.env.PI_CLAUDE_SUPERVISOR_HOOK_DIR
74
82
  || (/[\\/]relay\.js$/.test(scriptPath) ? path.dirname(scriptPath) : "")
75
83
  || path.join(os.homedir(), ".pi", "agent", "claude-supervisor", "hooks");
76
- var canonicalCwd = event.cwd;
77
- try {
78
- canonicalCwd = fs.realpathSync(event.cwd);
79
- } catch (e) { /* fall back to the raw cwd */ }
80
- var hash = crypto.createHash("sha256").update(canonicalCwd).digest("hex");
81
- var socketPath = path.join(hookDir, "by-cwd", hash);
82
-
83
- // Fast path: no Supervisor owns this cwd. Check before requiring "net" so
84
- // the common case (no hooks configured / no owning Supervisor) is cheap.
85
- var socketInfo;
86
- try {
87
- socketInfo = fs.statSync(socketPath);
88
- } catch (e) {
89
- return;
84
+ function socketFor(dir) {
85
+ if (typeof dir !== "string" || !path.isAbsolute(dir)) return undefined;
86
+ var canonical = dir;
87
+ try {
88
+ canonical = fs.realpathSync(dir);
89
+ } catch (e) { /* fall back to the raw path */ }
90
+ var candidate = path.join(hookDir, "by-cwd", crypto.createHash("sha256").update(canonical).digest("hex"));
91
+ try {
92
+ return fs.statSync(candidate).isSocket() ? { socketPath: candidate, routeCwd: canonical } : undefined;
93
+ } catch (e) {
94
+ return undefined;
95
+ }
90
96
  }
91
- if (!socketInfo.isSocket()) return;
97
+
98
+ // Fast path: no Supervisor owns the project directory or the cwd. Check
99
+ // before requiring "net" so the common case (no hooks configured / no
100
+ // owning Supervisor) is cheap.
101
+ var route = socketFor(process.env.CLAUDE_PROJECT_DIR) || socketFor(event.cwd);
102
+ if (!route) return;
103
+ var socketPath = route.socketPath;
92
104
 
93
105
  var BLOCKING_EVENTS = ${JSON.stringify([...BLOCKING_HOOK_EVENTS])};
94
106
  var blocking = BLOCKING_EVENTS.indexOf(event.hook_event_name) !== -1;
@@ -97,6 +109,7 @@ export const HOOK_RELAY_SCRIPT = `
97
109
  pid: process.pid,
98
110
  ppid: process.ppid,
99
111
  tmuxPane: process.env.TMUX_PANE,
112
+ routeCwd: route.routeCwd,
100
113
  event: event,
101
114
  }) + "\\n";
102
115
 
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  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
- import { join, resolve } from "node:path";
4
+ import { isAbsolute, join, resolve } from "node:path";
5
5
  import { tmpdir } from "node:os";
6
6
  import { CLAUDE_HOOK_EVENT_NAMES, type ClaudeHookEvent, type ClaudeHookEventName, type HookEventSource, type HookRelayReply, type HookRelayRequest } from "./types.ts";
7
7
 
@@ -33,7 +33,7 @@ export function hookSocketRuntimeDirectory(env: NodeJS.ProcessEnv = process.env)
33
33
 
34
34
  /**
35
35
  * One socket per Pi process, with a per-cwd symlink under `by-cwd/` so a
36
- * relay only needs the event's cwd to find its Supervisor. See
36
+ * relay only needs the task directory to find its Supervisor. See
37
37
  * src/hooks/types.ts for the full routing contract.
38
38
  */
39
39
  export class HookServer implements HookEventSource {
@@ -197,7 +197,7 @@ export class HookServer implements HookEventSource {
197
197
  try {
198
198
  const request = parseRequest(line);
199
199
  if (request) {
200
- const handler = this.#handlers.get(hashCwd(await canonicalize(request.event.cwd)));
200
+ const handler = this.#handlers.get(hashCwd(await canonicalize(request.routeCwd ?? request.event.cwd)));
201
201
  if (handler) reply = (await handler(request)) ?? {};
202
202
  }
203
203
  } catch {
@@ -243,6 +243,7 @@ function parseRequest(line: string): HookRelayRequest | undefined {
243
243
  const request = value as Partial<HookRelayRequest>;
244
244
  if (request.version !== 1) return undefined;
245
245
  if (typeof request.pid !== "number" || typeof request.ppid !== "number") return undefined;
246
+ if (request.routeCwd !== undefined && (typeof request.routeCwd !== "string" || !isAbsolute(request.routeCwd))) return undefined;
246
247
  const event = request.event as Partial<ClaudeHookEvent> | undefined;
247
248
  if (!event || typeof event !== "object") return undefined;
248
249
  if (typeof event.cwd !== "string" || !event.cwd) return undefined;
@@ -13,15 +13,20 @@ interface HookCommandEntry {
13
13
 
14
14
  export interface HookSettingsDocument {
15
15
  hooks: Record<ClaudeHookEventName, Array<{ hooks: HookCommandEntry[] }>>;
16
+ /**
17
+ * Off for a supervised session: a suggestion is ghost text in the input
18
+ * box, which reads as leftover input and holds every send back.
19
+ */
20
+ promptSuggestionEnabled: false;
16
21
  }
17
22
 
18
- /** A Claude Code settings document that runs `relayCommand` for every hook event the relay supports. */
23
+ /** A Claude Code settings document for a supervised session: runs `relayCommand` for every hook event the relay supports. */
19
24
  export function hookSettingsDocument(relayCommand: string): HookSettingsDocument {
20
25
  const hooks = {} as HookSettingsDocument["hooks"];
21
26
  for (const name of HOOK_EVENT_NAMES) {
22
27
  hooks[name] = [{ hooks: [{ type: "command", command: relayCommand, timeout: HOOK_TIMEOUT_SECONDS }] }];
23
28
  }
24
- return { hooks };
29
+ return { hooks, promptSuggestionEnabled: false };
25
30
  }
26
31
 
27
32
  /** Writes a standalone settings file (e.g. for `--settings <path>` on an owned interactive session). */
@@ -59,6 +59,13 @@ export interface HookRelayRequest {
59
59
  ppid: number;
60
60
  /** Inherited from Claude's environment when it runs inside tmux. */
61
61
  tmuxPane?: string;
62
+ /**
63
+ * The canonical directory the relay routed by: Claude's `CLAUDE_PROJECT_DIR`
64
+ * when a Supervisor owns it, else the event's `cwd`. The server routes by
65
+ * this when present, so a Worker that `cd`s into a subdirectory stays
66
+ * supervised. Absent from older relays, which routed by `cwd`.
67
+ */
68
+ routeCwd?: string;
62
69
  event: ClaudeHookEvent;
63
70
  }
64
71
 
@@ -84,10 +91,11 @@ export const HOOK_TIMEOUT_SECONDS = 180;
84
91
  /**
85
92
  * Socket routing: one socket per Pi process under `<stateDir>/hooks/`, plus a
86
93
  * per-cwd symlink `<stateDir>/hooks/by-cwd/<sha256(cwd)>` created when the
87
- * Supervisor takes the cwd lease, so a relay only needs the cwd to find its
88
- * supervisor and a cwd with no supervisor is a no-op.
94
+ * Supervisor takes the cwd lease, so a relay only needs the task directory
95
+ * (Claude's `CLAUDE_PROJECT_DIR`, else the event's `cwd`) to find its
96
+ * supervisor and a directory with no supervisor is a no-op.
89
97
  */
90
98
  export interface HookEventSource {
91
- /** Route every event whose canonical cwd matches to this handler until unsubscribed. */
99
+ /** Route every event whose canonical routing directory (`routeCwd`, else `event.cwd`) matches to this handler until unsubscribed. */
92
100
  subscribe(cwd: string, handler: (request: HookRelayRequest) => Promise<HookRelayReply | undefined>): Promise<() => Promise<void>>;
93
101
  }
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@ import { TmuxWorkerAdapter, attachCommand, sweepDeadTmuxSockets } from "./worker
12
12
  import { Supervisor, extendedDeadlineMs, 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 { autoInstallHooks, autonomyDefaults, closeWorkerOnCompletion, deadlineGraceMs, deadlineMs, deadlineWarningMs, decisionCompactionTokens, decisionModel, decisionSessionRetentionDays, eventLogMaxBytes, formatDurationMs, loadSupervisorEnvironment, noOutputTimeoutMs, parseDurationMs, progressHeartbeatMs, reviewTimeoutMs, reviewerModel, tmuxMode, workerAutocompactTokens, workerMcpConfigPath, workerModel } from "./config.ts";
15
+ import { autoInstallHooks, autonomyDefaults, closeWorkerOnCompletion, deadlineGraceMs, deadlineMs, deadlineWarningMs, decisionCompactionTokens, decisionModel, decisionSessionRetentionDays, eventLogMaxBytes, formatDurationMs, humanIdleResumeMs, loadSupervisorEnvironment, noOutputTimeoutMs, parseDurationMs, 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, leaseOwnerLive, pathsOverlap, workerIdentity } from "./cwd-lease.ts";
18
18
  import { normalizeTaskSpec } from "./acceptance.ts";
@@ -478,6 +478,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
478
478
  deadlineGraceMs: deadlineGraceMs(),
479
479
  deadlineWarningMs: deadlineWarningMs(),
480
480
  noOutputTimeoutMs: noOutputTimeoutMs(),
481
+ humanIdleResumeMs: humanIdleResumeMs(),
481
482
  hookSource: interactive ? hookServer : undefined,
482
483
  hookSettingsPath,
483
484
  keepWorkerOnCompletion: interactive ? !closeWorkerOnCompletion() : undefined,
@@ -825,6 +826,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
825
826
  deadlineGraceMs: deadlineGraceMs(),
826
827
  deadlineWarningMs: deadlineWarningMs(),
827
828
  noOutputTimeoutMs: record.noOutputTimeoutMs,
829
+ humanIdleResumeMs: humanIdleResumeMs(),
828
830
  startedAt: record.startedAt,
829
831
  baseCommit: record.baseCommit,
830
832
  baseBranch: record.baseBranch,