pi-claude-supervisor 0.9.0 → 0.9.1
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 +8 -0
- package/README.cn.md +16 -8
- package/README.md +19 -8
- package/docs/architecture.md +29 -8
- package/docs/autonomy-target.md +1 -1
- package/docs/testing.md +3 -3
- package/package.json +1 -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 +35 -6
- 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/reviewer.ts +228 -53
- package/src/supervisor.ts +133 -14
- package/src/worker/process-adapter.ts +40 -5
- package/src/worker/tmux-adapter.ts +105 -21
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,16 @@ 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
|
+
failedChecks: string[];
|
|
40
|
+
reviewVerdict?: string;
|
|
41
|
+
findings: string[];
|
|
31
42
|
}
|
|
32
43
|
|
|
33
44
|
export interface DecisionDeadlineContext {
|
|
@@ -81,6 +92,9 @@ export interface DecisionWorkerOptions {
|
|
|
81
92
|
export type PiModel = NonNullable<NonNullable<Parameters<typeof createAgentSession>[0]>["model"]>;
|
|
82
93
|
|
|
83
94
|
const DEFAULT_COMPACTION_TOKENS = 60_000;
|
|
95
|
+
/** First retry wait; each later wait triples, capped at MAX_DECISION_RETRY_BACKOFF_MS. */
|
|
96
|
+
const DEFAULT_DECISION_RETRY_BACKOFF_MS = 5_000;
|
|
97
|
+
const MAX_DECISION_RETRY_BACKOFF_MS = 60_000;
|
|
84
98
|
|
|
85
99
|
/**
|
|
86
100
|
* A persistent Pi SDK session used only for supervision decisions.
|
|
@@ -106,7 +120,7 @@ export class PiDecisionWorker implements DecisionWorkerLike {
|
|
|
106
120
|
this.#options = options;
|
|
107
121
|
this.#context = { ...options.context };
|
|
108
122
|
this.#timeoutMs = options.timeoutMs ?? 120_000;
|
|
109
|
-
this.#retryBackoffMs = options.retryBackoffMs ??
|
|
123
|
+
this.#retryBackoffMs = options.retryBackoffMs ?? DEFAULT_DECISION_RETRY_BACKOFF_MS;
|
|
110
124
|
this.#compactionTokens = options.compactionTokens ?? DEFAULT_COMPACTION_TOKENS;
|
|
111
125
|
}
|
|
112
126
|
|
|
@@ -188,7 +202,7 @@ export class PiDecisionWorker implements DecisionWorkerLike {
|
|
|
188
202
|
|
|
189
203
|
async #processEvent(event: WorkerEvent): Promise<void> {
|
|
190
204
|
if (!this.#session || this.#closed) return;
|
|
191
|
-
const maxRetries = this.#context.spec?.autonomy.maxDecisionRetries ??
|
|
205
|
+
const maxRetries = this.#context.spec?.autonomy.maxDecisionRetries ?? DEFAULT_MAX_DECISION_RETRIES;
|
|
192
206
|
let attempt = 0;
|
|
193
207
|
// undefined selects the primary decision question; a bounded re-prompt
|
|
194
208
|
// (below) switches this to a corrective follow-up on the same session.
|
|
@@ -216,7 +230,8 @@ export class PiDecisionWorker implements DecisionWorkerLike {
|
|
|
216
230
|
if (error instanceof Error && error.name === "AbortError") throw error;
|
|
217
231
|
if (attempt >= maxRetries) throw error;
|
|
218
232
|
attempt += 1;
|
|
219
|
-
|
|
233
|
+
// 429/529 overloads last minutes, not seconds: 15s, 45s, then 60s.
|
|
234
|
+
await new Promise<void>((resolveWait) => setTimeout(resolveWait, Math.min(MAX_DECISION_RETRY_BACKOFF_MS, this.#retryBackoffMs * 3 ** attempt)));
|
|
220
235
|
if (!this.#session || this.#closed) return;
|
|
221
236
|
continue;
|
|
222
237
|
}
|
|
@@ -231,6 +246,13 @@ export class PiDecisionWorker implements DecisionWorkerLike {
|
|
|
231
246
|
prompt = `Your previous reply was not a single valid JSON action (${action.reason}). Return exactly one JSON object now and nothing else.`;
|
|
232
247
|
continue;
|
|
233
248
|
}
|
|
249
|
+
// `noop` on a completed turn or a pending permission leaves the Worker
|
|
250
|
+
// idle forever; the Supervisor parks it. Ask once for a concrete action.
|
|
251
|
+
if (!repromptAttempted && action.action === "noop" && (event.type === "turn_completed" || event.type === "permission_request")) {
|
|
252
|
+
repromptAttempted = true;
|
|
253
|
+
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.`;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
234
256
|
// An action handler may stop or close the session. Do not replay an
|
|
235
257
|
// already-decoded action if the handler itself fails.
|
|
236
258
|
await this.#options.onAction(action, event);
|
|
@@ -249,11 +271,15 @@ export class PiDecisionWorker implements DecisionWorkerLike {
|
|
|
249
271
|
const tokens = this.#session.getContextUsage?.()?.tokens;
|
|
250
272
|
if (typeof tokens !== "number" || tokens <= this.#compactionTokens) return;
|
|
251
273
|
try {
|
|
252
|
-
|
|
274
|
+
// Bounded like any other Decision Worker request: compaction runs on the
|
|
275
|
+
// serialized decision tail, so a stalled summarization would otherwise
|
|
276
|
+
// block every later decision.
|
|
277
|
+
await withTimeout(this.#session.compact(
|
|
253
278
|
"Preserve: the task specification, the policy rules for permissions and boundaries, the current state, and the last three decisions with their reasons.",
|
|
254
|
-
);
|
|
279
|
+
), this.#timeoutMs, "Decision Worker compaction");
|
|
255
280
|
this.#instructionsStale = true;
|
|
256
281
|
} catch {
|
|
282
|
+
try { this.#session?.abortCompaction?.(); } catch { /* best effort */ }
|
|
257
283
|
// A compaction failure must not fail the decision that already succeeded.
|
|
258
284
|
}
|
|
259
285
|
}
|
|
@@ -311,7 +337,9 @@ as the answer and continues. A turn_completed whose result has subtype "error" o
|
|
|
311
337
|
means the Worker's own API/model call failed mid-turn: choose retry (optionally with a short
|
|
312
338
|
corrective message) or continue to resume it, and park only after repeated failures; a subtype
|
|
313
339
|
"idle" result means the turn ended without a normal stop signal, so inspect the repository and
|
|
314
|
-
decide as for any other turn.
|
|
340
|
+
decide as for any other turn. CURRENT CONTEXT.lastVerification, when present, is the last acceptance
|
|
341
|
+
and Review round (failed check ids, Reviewer verdict and findings): after a repair turn, check
|
|
342
|
+
Claude's claim against it before choosing verify. Use verify when a turn result indicates the task is complete, even if
|
|
315
343
|
Claude says it will stop; choose stop only for an explicit stop or technical containment reason.
|
|
316
344
|
Use park only when the task cannot safely produce a candidate because required evidence,
|
|
317
345
|
authority, or runtime capability is unavailable. A parked candidate is asynchronous and must not
|
|
@@ -390,6 +418,7 @@ async function askDecision(
|
|
|
390
418
|
? { closeOutRemainingMinutes: Math.round(context.deadline.closeOutRemainingMs / 60_000) }
|
|
391
419
|
: {}),
|
|
392
420
|
} : {}),
|
|
421
|
+
...(context.lastVerification ? { lastVerification: context.lastVerification } : {}),
|
|
393
422
|
};
|
|
394
423
|
const prompt = `${options.instructionsPrefix ?? ""}UNTRUSTED SUPERVISOR EVENT:\n${boundedEventJson(event)}\n\nCURRENT CONTEXT:\n${boundedJson(currentContext)}\n\nChoose one action now.`;
|
|
395
424
|
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
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Identity written into a short-lived lock directory's `owner.json`. The pid
|
|
5
|
+
* alone is not an identity: after a crash and a container or VM restart the
|
|
6
|
+
* next Pi often gets the same pid, and a lock judged only by `kill(pid, 0)`
|
|
7
|
+
* then looks held forever. The pid's start time (Linux) makes it one.
|
|
8
|
+
*/
|
|
9
|
+
export interface LockOwner {
|
|
10
|
+
pid: number;
|
|
11
|
+
startTime?: string;
|
|
12
|
+
at: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let ownStartTime: Promise<string | undefined> | undefined;
|
|
16
|
+
|
|
17
|
+
/** The owner record for a lock taken by this process. */
|
|
18
|
+
export async function currentLockOwner(): Promise<LockOwner> {
|
|
19
|
+
ownStartTime ??= processStartTime(process.pid);
|
|
20
|
+
const startTime = await ownStartTime;
|
|
21
|
+
return { pid: process.pid, ...(startTime ? { startTime } : {}), at: new Date().toISOString() };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Whether the process that wrote `owner` still exists. A missing or malformed
|
|
26
|
+
* record, a dead pid, or a live pid with a different start time (a reused
|
|
27
|
+
* pid) all mean the lock is abandoned; only a live pid whose start time still
|
|
28
|
+
* matches (or cannot be compared) keeps it.
|
|
29
|
+
*/
|
|
30
|
+
export async function lockOwnerAlive(owner: unknown): Promise<boolean> {
|
|
31
|
+
if (!owner || typeof owner !== "object") return false;
|
|
32
|
+
const { pid, startTime } = owner as { pid?: unknown; startTime?: unknown };
|
|
33
|
+
if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
34
|
+
try {
|
|
35
|
+
process.kill(pid, 0);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (!(error instanceof Error && /EPERM/u.test(error.message))) return false;
|
|
38
|
+
}
|
|
39
|
+
if (typeof startTime !== "string") return true;
|
|
40
|
+
const current = await processStartTime(pid);
|
|
41
|
+
return current === undefined || current === startTime;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Linux process start time in clock ticks since boot (field 22 of /proc/<pid>/stat). */
|
|
45
|
+
export async function processStartTime(pid: number): Promise<string | undefined> {
|
|
46
|
+
try {
|
|
47
|
+
const statText = await readFile(`/proc/${pid}/stat`, "utf8");
|
|
48
|
+
const closeParen = statText.lastIndexOf(")");
|
|
49
|
+
const fields = closeParen >= 0 ? statText.slice(closeParen + 2).trim().split(/\s+/u) : [];
|
|
50
|
+
const startTime = fields[19];
|
|
51
|
+
return startTime && /^\d+$/u.test(startTime) ? startTime : undefined;
|
|
52
|
+
} catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|