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.
- package/CHANGELOG.md +40 -0
- package/README.cn.md +342 -148
- package/README.md +394 -241
- package/docs/architecture.md +196 -12
- package/docs/testing.md +5 -4
- package/package.json +1 -1
- package/src/acceptance.ts +7 -1
- package/src/config.ts +110 -0
- package/src/cwd-lease.ts +166 -37
- package/src/decision-session-store.ts +43 -2
- package/src/decision-worker.ts +284 -18
- package/src/events.ts +125 -5
- package/src/hooks/install.ts +187 -0
- package/src/hooks/relay.ts +191 -0
- package/src/hooks/server.ts +260 -0
- package/src/hooks/settings.ts +44 -0
- package/src/hooks/types.ts +83 -0
- package/src/index.ts +267 -26
- package/src/json-extract.ts +49 -0
- package/src/notifications.ts +61 -10
- package/src/pi-model.ts +44 -0
- package/src/policy.ts +394 -21
- package/src/redaction.ts +1 -1
- package/src/reviewer.ts +86 -56
- package/src/supervisor.ts +774 -82
- package/src/types.ts +55 -3
- package/src/verifier.ts +49 -16
- package/src/worker/environment.ts +41 -2
- package/src/worker/process-adapter.ts +32 -7
- package/src/worker/runtime.ts +51 -0
- package/src/worker/tmux-adapter.ts +573 -28
|
@@ -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
|
|
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
|
-
|
|
132
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|