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/CHANGELOG.md +16 -0
- package/README.cn.md +16 -8
- package/README.md +19 -8
- package/docs/architecture.md +42 -10
- package/docs/autonomy-target.md +1 -1
- package/docs/testing.md +40 -3
- package/package.json +2 -1
- package/src/acceptance.ts +2 -1
- package/src/config.ts +78 -32
- package/src/cwd-lease.ts +34 -1
- package/src/decision-session-store.ts +5 -14
- package/src/decision-worker.ts +89 -11
- package/src/events.ts +5 -11
- package/src/hooks/install.ts +2 -11
- package/src/hooks/server.ts +2 -10
- package/src/hooks/settings.ts +2 -11
- package/src/hooks/types.ts +17 -9
- package/src/index.ts +83 -4
- package/src/json-extract.ts +41 -2
- package/src/lock-owner.ts +55 -0
- package/src/redaction.ts +15 -0
- package/src/reviewer.ts +228 -53
- package/src/supervisor.ts +197 -16
- package/src/worker/process-adapter.ts +40 -5
- package/src/worker/tmux-adapter.ts +105 -21
package/src/cwd-lease.ts
CHANGED
|
@@ -187,7 +187,20 @@ export class CwdLeaseStore {
|
|
|
187
187
|
continue;
|
|
188
188
|
}
|
|
189
189
|
}
|
|
190
|
-
|
|
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:
|
|
558
|
-
try { owner = JSON.parse(await readFile(ownerPath, "utf8"))
|
|
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 (
|
|
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) {
|
package/src/decision-worker.ts
CHANGED
|
@@ -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 ??
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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:
|
|
95
|
+
let owner: unknown;
|
|
95
96
|
try {
|
|
96
|
-
owner = JSON.parse(await readFile(`${lockPath}/owner.json`, "utf8"))
|
|
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 (
|
|
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) {
|
package/src/hooks/install.ts
CHANGED
|
@@ -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";
|
package/src/hooks/server.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/hooks/settings.ts
CHANGED
|
@@ -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";
|
package/src/hooks/types.ts
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
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()]
|
package/src/json-extract.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|