pi-claude-supervisor 0.2.2 → 0.4.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 +22 -2
- package/README.cn.md +36 -3
- package/README.md +43 -0
- package/docs/architecture.md +72 -2
- package/docs/releasing.md +2 -1
- package/docs/testing.md +31 -6
- package/docs/transport-spike-2026-09-12.md +8 -8
- package/package.json +2 -1
- package/src/config.ts +5 -1
- package/src/cwd-lease.ts +345 -0
- package/src/decision-session-store.ts +38 -8
- package/src/decision-worker.ts +51 -15
- package/src/events.ts +2 -16
- package/src/index.ts +233 -58
- package/src/notifications.ts +15 -16
- package/src/redaction.ts +20 -0
- package/src/supervisor.ts +234 -21
- package/src/types.ts +37 -1
- package/src/worker/process-adapter.ts +101 -16
- package/src/worker/tmux-adapter.ts +1053 -0
package/src/index.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
|
-
import {
|
|
4
|
+
import { join } from "node:path";
|
|
4
5
|
import { realpath } from "node:fs/promises";
|
|
5
6
|
import { EventLog } from "./events.ts";
|
|
7
|
+
import { redactSensitive } from "./redaction.ts";
|
|
6
8
|
import { ProcessWorkerAdapter } from "./worker/process-adapter.ts";
|
|
9
|
+
import { TmuxWorkerAdapter, attachCommand } from "./worker/tmux-adapter.ts";
|
|
7
10
|
import { Supervisor } from "./supervisor.ts";
|
|
8
11
|
import { evaluateCommand } from "./policy.ts";
|
|
9
12
|
import { HumanWebhookNotifier } from "./notifications.ts";
|
|
10
13
|
import { loadSupervisorEnvironment } from "./config.ts";
|
|
11
14
|
import { DecisionSessionStore, type DecisionSessionRecord } from "./decision-session-store.ts";
|
|
15
|
+
import { CwdLeaseStore, type CwdLeaseHandle, pathsOverlap, workerIdentity } from "./cwd-lease.ts";
|
|
12
16
|
|
|
13
17
|
/**
|
|
14
18
|
* Pi Claude Supervisor.
|
|
@@ -20,20 +24,38 @@ import { DecisionSessionStore, type DecisionSessionRecord } from "./decision-ses
|
|
|
20
24
|
export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
21
25
|
loadSupervisorEnvironment();
|
|
22
26
|
const automation = process.env.PI_CLAUDE_SUPERVISOR_MODE === "auto" || process.env.PI_CLAUDE_SUPERVISOR_AUTOMATION === "1";
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
const stateDir = process.env.PI_CLAUDE_SUPERVISOR_STATE_DIR ?? join(homedir(), ".pi", "agent", "claude-supervisor");
|
|
28
|
+
const configuredTransport = process.env.PI_CLAUDE_SUPERVISOR_TRANSPORT;
|
|
29
|
+
const transport = configuredTransport ?? (automation ? "jsonl" : "process-pipe");
|
|
30
|
+
const cgroupMode = process.env.PI_CLAUDE_SUPERVISOR_CGROUP_MODE ?? "auto";
|
|
31
|
+
if (!(["process-pipe", "jsonl", "tmux"] as string[]).includes(transport)) {
|
|
32
|
+
throw new Error(`Unsupported PI_CLAUDE_SUPERVISOR_TRANSPORT: ${transport}; expected process-pipe, jsonl, or tmux`);
|
|
33
|
+
}
|
|
34
|
+
if (!["off", "auto", "required"].includes(cgroupMode)) {
|
|
35
|
+
throw new Error(`Unsupported PI_CLAUDE_SUPERVISOR_CGROUP_MODE: ${cgroupMode}; expected off, auto, or required`);
|
|
36
|
+
}
|
|
37
|
+
if (transport === "tmux" && cgroupMode === "required") {
|
|
38
|
+
throw new Error("PI_CLAUDE_SUPERVISOR_CGROUP_MODE=required is unsupported with tmux; use process-pipe/jsonl or set cgroup mode to auto/off");
|
|
39
|
+
}
|
|
40
|
+
const adapter = transport === "tmux"
|
|
41
|
+
? new TmuxWorkerAdapter({ stateDir })
|
|
42
|
+
: new ProcessWorkerAdapter({
|
|
43
|
+
// Automatic decisions require Claude's structured event stream. The pipe
|
|
44
|
+
// transport remains available for manual/compatibility sessions.
|
|
45
|
+
mode: automation || transport === "jsonl" ? "claude-jsonl" : "process-pipe",
|
|
46
|
+
cgroupMode: cgroupMode as "off" | "auto" | "required",
|
|
47
|
+
});
|
|
28
48
|
const humanWebhook = new HumanWebhookNotifier({
|
|
29
49
|
url: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_URL,
|
|
30
50
|
format: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_FORMAT === "wecom" ? "wecom" : "generic",
|
|
31
51
|
secret: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_SECRET,
|
|
32
52
|
});
|
|
33
|
-
const stateDir = process.env.PI_CLAUDE_SUPERVISOR_STATE_DIR ?? join(homedir(), ".pi", "agent", "claude-supervisor");
|
|
34
53
|
const events = new EventLog(join(stateDir, "events.jsonl"));
|
|
35
54
|
const decisionStore = new DecisionSessionStore(join(stateDir, "decision-sessions"));
|
|
55
|
+
const cwdLeaseStore = new CwdLeaseStore(process.env.PI_CLAUDE_SUPERVISOR_CWD_LEASE_DIR ?? join(homedir(), ".pi", "agent", "claude-supervisor", "cwd-leases"));
|
|
36
56
|
const sessions = new Map<string, Supervisor>();
|
|
57
|
+
const cwdLeases = new Map<string, CwdLeaseHandle>();
|
|
58
|
+
const cleanupRequiredTasks = new Set<string>();
|
|
37
59
|
const reservedCwds = new Map<string, string>();
|
|
38
60
|
const pendingCwds = new Set<string>();
|
|
39
61
|
const pendingStarts = new Set<Promise<void>>();
|
|
@@ -43,26 +65,58 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
43
65
|
let shutdownPromise: Promise<void> | undefined;
|
|
44
66
|
|
|
45
67
|
const notify = (ctx: ExtensionContext, message: string, type: "info" | "warning" = "info") => {
|
|
46
|
-
if (ctx.hasUI) ctx.ui.notify(message, type);
|
|
68
|
+
if (ctx.hasUI) ctx.ui.notify(redactText(message), type);
|
|
47
69
|
};
|
|
48
70
|
const activeSessions = () => [...sessions.entries()].filter(([, session]) =>
|
|
49
71
|
["starting", "running", "waiting", "paused"].includes(session.state));
|
|
72
|
+
const releaseLease = async (taskId: string): Promise<boolean> => {
|
|
73
|
+
const lease = cwdLeases.get(taskId);
|
|
74
|
+
if (!lease) return false;
|
|
75
|
+
try {
|
|
76
|
+
await lease.release();
|
|
77
|
+
cwdLeases.delete(taskId);
|
|
78
|
+
return true;
|
|
79
|
+
} catch (error) {
|
|
80
|
+
console.error(`pi-claude-supervisor cwd lease release failed: ${redactText(error instanceof Error ? error.message : String(error))}`);
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
50
84
|
const releaseSettledReservations = async (): Promise<void> => {
|
|
51
85
|
for (const [taskId, session] of sessions) {
|
|
52
86
|
if (!["completed", "stopped", "failed"].includes(session.state)) continue;
|
|
53
87
|
if (!session.handle) {
|
|
54
|
-
|
|
88
|
+
if (await releaseLease(taskId)) {
|
|
89
|
+
reservedCwds.delete(taskId);
|
|
90
|
+
cleanupRequiredTasks.delete(taskId);
|
|
91
|
+
}
|
|
55
92
|
continue;
|
|
56
93
|
}
|
|
57
94
|
try {
|
|
58
95
|
const status = await adapter.getStatus(session.handle);
|
|
59
|
-
if (!status.running && status.processGroupCleaned === true && !status.cleanupError
|
|
96
|
+
if (!status.running && status.processGroupCleaned === true && !status.cleanupError && (!status.cgroupError || status.cgroupRequired === false)) {
|
|
97
|
+
if (await releaseLease(taskId)) {
|
|
98
|
+
reservedCwds.delete(taskId);
|
|
99
|
+
cleanupRequiredTasks.delete(taskId);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
60
102
|
} catch {
|
|
61
103
|
// Keep the reservation when cleanup status cannot be confirmed.
|
|
62
104
|
}
|
|
63
105
|
}
|
|
64
106
|
};
|
|
65
|
-
const stopSession = async (session: Supervisor, reason: string): Promise<void> => {
|
|
107
|
+
const stopSession = async (session: Supervisor, reason: string, releasePersistent = false): Promise<void> => {
|
|
108
|
+
const handle = session.handle;
|
|
109
|
+
const persistent = adapter.capabilities().persistentSession && Boolean(handle);
|
|
110
|
+
const taskId = session.task?.taskId;
|
|
111
|
+
const adoptedPersistent = persistent && handle?.ownership === "adopted";
|
|
112
|
+
const healthyPersistent = persistent && !["failed", "completed", "stopped"].includes(session.state);
|
|
113
|
+
const cleanupRequired = taskId ? cleanupRequiredTasks.has(taskId) : false;
|
|
114
|
+
const markCleanupRequired = () => { if (taskId) cleanupRequiredTasks.add(taskId); };
|
|
115
|
+
if (adoptedPersistent || (releasePersistent && healthyPersistent && !cleanupRequired)) {
|
|
116
|
+
await session.release(reason);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!handle && ["failed", "completed", "stopped"].includes(session.state)) return;
|
|
66
120
|
let lifecycleError: unknown;
|
|
67
121
|
try {
|
|
68
122
|
await session.stop(reason);
|
|
@@ -70,9 +124,11 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
70
124
|
lifecycleError = error;
|
|
71
125
|
}
|
|
72
126
|
|
|
73
|
-
const handle = session.handle;
|
|
74
127
|
if (!handle) {
|
|
75
|
-
if (lifecycleError)
|
|
128
|
+
if (lifecycleError) {
|
|
129
|
+
markCleanupRequired();
|
|
130
|
+
throw lifecycleError;
|
|
131
|
+
}
|
|
76
132
|
return;
|
|
77
133
|
}
|
|
78
134
|
|
|
@@ -81,8 +137,12 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
81
137
|
while (Date.now() <= deadline) {
|
|
82
138
|
try {
|
|
83
139
|
const status = await adapter.getStatus(handle);
|
|
84
|
-
if (!status.running && status.processGroupCleaned === true) {
|
|
85
|
-
if (lifecycleError)
|
|
140
|
+
if (!status.running && status.processGroupCleaned === true && !status.cleanupError && (!status.cgroupError || status.cgroupRequired === false)) {
|
|
141
|
+
if (lifecycleError) {
|
|
142
|
+
markCleanupRequired();
|
|
143
|
+
throw lifecycleError;
|
|
144
|
+
}
|
|
145
|
+
if (taskId) cleanupRequiredTasks.delete(taskId);
|
|
86
146
|
return;
|
|
87
147
|
}
|
|
88
148
|
} catch (error) {
|
|
@@ -96,6 +156,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
96
156
|
await delay(25);
|
|
97
157
|
}
|
|
98
158
|
|
|
159
|
+
markCleanupRequired();
|
|
99
160
|
if (lifecycleError) throw lifecycleError;
|
|
100
161
|
throw cleanupError instanceof Error
|
|
101
162
|
? cleanupError
|
|
@@ -109,9 +170,11 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
109
170
|
const tokens = args.trim() ? args.trim().split(/\s+/u) : [];
|
|
110
171
|
const [operation = "status", ...rest] = tokens;
|
|
111
172
|
let message = "";
|
|
112
|
-
if (operation === "start") {
|
|
173
|
+
if (operation === "start" || operation === "adopt-tmux") {
|
|
174
|
+
const tmuxSession = operation === "adopt-tmux" ? rest.shift() : undefined;
|
|
113
175
|
const task = rest.join(" ").trim();
|
|
114
|
-
if (!task) throw new Error("Usage: /supervise start <task>");
|
|
176
|
+
if (!task) throw new Error(operation === "adopt-tmux" ? "Usage: /supervise adopt-tmux <tmux-session> <task>" : "Usage: /supervise start <task>");
|
|
177
|
+
if (operation === "adopt-tmux" && adapter.capabilities().transport !== "tmux") throw new Error("/supervise adopt-tmux requires PI_CLAUDE_SUPERVISOR_TRANSPORT=tmux");
|
|
115
178
|
const [command, ...workerArgs] = parseCommand(process.env.PI_CLAUDE_SUPERVISOR_WORKER ?? "claude");
|
|
116
179
|
if (!command) throw new Error("PI_CLAUDE_SUPERVISOR_WORKER must contain an executable");
|
|
117
180
|
const policy = evaluateCommand(command, workerArgs);
|
|
@@ -134,32 +197,54 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
134
197
|
if (reservedPaths.some((reserved) => pathsOverlap(reserved, cwdKey))) {
|
|
135
198
|
throw new Error("An active, starting, or unreaped worker uses an overlapping cwd; use a separate worktree for concurrent sessions");
|
|
136
199
|
}
|
|
200
|
+
const taskId = randomUUID();
|
|
201
|
+
const lease = await cwdLeaseStore.acquire(cwdKey, taskId, adapter.capabilities().transport, tmuxSession
|
|
202
|
+
? { handoff: { sessionName: tmuxSession, tmuxSocket: process.env.PI_CLAUDE_SUPERVISOR_TMUX_SOCKET } }
|
|
203
|
+
: {});
|
|
204
|
+
cwdLeases.set(taskId, lease);
|
|
205
|
+
if (shuttingDown) {
|
|
206
|
+
await releaseLease(taskId);
|
|
207
|
+
throw new Error("Pi session is shutting down");
|
|
208
|
+
}
|
|
137
209
|
pendingCwds.add(cwdKey);
|
|
138
210
|
const session = new Supervisor(adapter, events, {
|
|
139
211
|
onHumanRequired: async (notice) => {
|
|
140
|
-
if (ctx.hasUI)
|
|
212
|
+
if (ctx.hasUI) notify(ctx, `Claude Worker needs human intervention: ${notice.reason}`, "warning");
|
|
141
213
|
if (humanWebhook.enabled) {
|
|
142
214
|
try {
|
|
143
215
|
await humanWebhook.notify(notice);
|
|
144
216
|
} catch (error) {
|
|
145
|
-
console.error(`pi-claude-supervisor human webhook failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
217
|
+
console.error(`pi-claude-supervisor human webhook failed: ${redactText(error instanceof Error ? error.message : String(error))}`);
|
|
146
218
|
}
|
|
147
219
|
} else {
|
|
148
|
-
console.error(`pi-claude-supervisor human intervention required: ${notice.reason}`);
|
|
220
|
+
console.error(`pi-claude-supervisor human intervention required: ${redactText(notice.reason)}`);
|
|
149
221
|
}
|
|
150
222
|
},
|
|
151
223
|
});
|
|
152
224
|
pendingStartSessions.add(session);
|
|
225
|
+
let startupCleanupCompleted = false;
|
|
153
226
|
const startOperation = (async () => {
|
|
154
227
|
try {
|
|
155
228
|
const handle = await session.start({
|
|
229
|
+
taskId,
|
|
156
230
|
task,
|
|
157
|
-
cwd:
|
|
231
|
+
cwd: cwdKey,
|
|
158
232
|
command,
|
|
159
233
|
args: workerArgs,
|
|
160
234
|
env: selectedWorkerEnvironment(),
|
|
161
235
|
approval,
|
|
162
236
|
automation,
|
|
237
|
+
tmuxSession,
|
|
238
|
+
tmuxSocket: process.env.PI_CLAUDE_SUPERVISOR_TMUX_SOCKET,
|
|
239
|
+
tmuxExpectedIdentity: tmuxSession && lease.record.worker ? {
|
|
240
|
+
pid: lease.record.worker.pid,
|
|
241
|
+
startTime: lease.record.worker.startTime,
|
|
242
|
+
tmuxTarget: lease.record.worker.tmuxTarget,
|
|
243
|
+
tmuxPaneId: lease.record.worker.tmuxPaneId,
|
|
244
|
+
paneStartTime: lease.record.worker.paneStartTime,
|
|
245
|
+
paneCommand: lease.record.worker.paneCommand,
|
|
246
|
+
} : undefined,
|
|
247
|
+
sendInitialInput: !tmuxSession,
|
|
163
248
|
decisionSessionDir: decisionStore.directory,
|
|
164
249
|
onDecisionSessionReady: (info) => decisionStore.save({
|
|
165
250
|
taskId: info.taskId,
|
|
@@ -179,36 +264,70 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
179
264
|
onDecisionSessionProgress: (info) => decisionStore.update(info.taskId, { turn: info.turn }),
|
|
180
265
|
onDecisionSessionClosed: (taskId) => decisionStore.close(taskId),
|
|
181
266
|
});
|
|
182
|
-
const
|
|
183
|
-
if (!
|
|
267
|
+
const startedTaskId = session.task?.taskId;
|
|
268
|
+
if (!startedTaskId) throw new Error("worker started without a task id");
|
|
269
|
+
try {
|
|
270
|
+
await lease.updateWorker({
|
|
271
|
+
transport: adapter.capabilities().transport,
|
|
272
|
+
...(await workerIdentity(handle)),
|
|
273
|
+
sessionName: handle.sessionName,
|
|
274
|
+
tmuxSocket: handle.tmuxSocket,
|
|
275
|
+
ownership: handle.ownership,
|
|
276
|
+
});
|
|
277
|
+
} catch (error) {
|
|
278
|
+
const registrationError = error instanceof Error ? error : new Error(String(error));
|
|
279
|
+
if (handle.ownership !== "adopted") {
|
|
280
|
+
try {
|
|
281
|
+
await stopSession(session, "cwd lease metadata registration failed");
|
|
282
|
+
startupCleanupCompleted = await releaseLease(taskId);
|
|
283
|
+
} catch (cleanupError) {
|
|
284
|
+
const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
|
|
285
|
+
registrationError.message = `${registrationError.message}; worker cleanup failed: ${message}`;
|
|
286
|
+
Object.defineProperty(registrationError, "workerCleanupRequired", { value: true, enumerable: false });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
throw registrationError;
|
|
290
|
+
}
|
|
184
291
|
// Register immediately after spawn so shutdown can retry cleanup if
|
|
185
292
|
// the first stop attempt fails.
|
|
186
|
-
sessions.set(
|
|
187
|
-
reservedCwds.set(
|
|
188
|
-
activeTaskId =
|
|
293
|
+
sessions.set(startedTaskId, session);
|
|
294
|
+
reservedCwds.set(startedTaskId, cwdKey);
|
|
295
|
+
activeTaskId = startedTaskId;
|
|
189
296
|
if (shuttingDown) {
|
|
190
297
|
try {
|
|
191
|
-
await stopSession(session, "Pi session shutdown during worker start");
|
|
192
|
-
sessions.delete(
|
|
193
|
-
reservedCwds.delete(
|
|
298
|
+
await stopSession(session, "Pi session shutdown during worker start", true);
|
|
299
|
+
sessions.delete(startedTaskId);
|
|
300
|
+
reservedCwds.delete(startedTaskId);
|
|
194
301
|
} finally {
|
|
195
302
|
if (session.state !== "stopped") {
|
|
196
303
|
// Keep the session registered for the shutdown retry below.
|
|
197
|
-
sessions.set(
|
|
198
|
-
reservedCwds.set(
|
|
304
|
+
sessions.set(startedTaskId, session);
|
|
305
|
+
reservedCwds.set(startedTaskId, cwdKey);
|
|
199
306
|
}
|
|
200
307
|
}
|
|
201
308
|
throw new Error("Pi session shut down during worker start");
|
|
202
309
|
}
|
|
203
|
-
|
|
310
|
+
const attach = handle.sessionName ? ` attach=${attachCommand(handle)}` : "";
|
|
311
|
+
message = `${tmuxSession ? "Tmux worker adopted" : "Worker started"}: task=${startedTaskId} worker=${handle.id} (pid ${handle.pid ?? "unknown"}); transport=${adapter.capabilities().transport}${attach}`;
|
|
204
312
|
} catch (error) {
|
|
205
313
|
// Register failed starts before the promise settles, so shutdown
|
|
206
314
|
// cannot snapshot sessions before a returned handle is retained.
|
|
207
315
|
const failedTaskId = session.task?.taskId;
|
|
208
|
-
|
|
316
|
+
const cleanupRequired = requiresWorkerCleanup(error);
|
|
317
|
+
if (cleanupRequired && failedTaskId) cleanupRequiredTasks.add(failedTaskId);
|
|
318
|
+
const retainHandle = !startupCleanupCompleted && failedTaskId && session.handle
|
|
319
|
+
&& (cleanupRequired || session.handle.ownership === "adopted");
|
|
320
|
+
if (retainHandle) {
|
|
209
321
|
sessions.set(failedTaskId, session);
|
|
210
322
|
reservedCwds.set(failedTaskId, cwdKey);
|
|
211
323
|
activeTaskId = failedTaskId;
|
|
324
|
+
} else if (!cleanupRequired && !startupCleanupCompleted) {
|
|
325
|
+
const released = await releaseLease(taskId);
|
|
326
|
+
if (!released && failedTaskId) {
|
|
327
|
+
sessions.set(failedTaskId, session);
|
|
328
|
+
reservedCwds.set(failedTaskId, cwdKey);
|
|
329
|
+
activeTaskId = failedTaskId;
|
|
330
|
+
}
|
|
212
331
|
}
|
|
213
332
|
throw error;
|
|
214
333
|
}
|
|
@@ -220,10 +339,21 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
220
339
|
// Preserve a failed startup in the registry when the adapter
|
|
221
340
|
// returned a handle but lifecycle/event setup failed.
|
|
222
341
|
const failedTaskId = session.task?.taskId;
|
|
223
|
-
|
|
342
|
+
const cleanupRequired = requiresWorkerCleanup(error);
|
|
343
|
+
if (cleanupRequired && failedTaskId) cleanupRequiredTasks.add(failedTaskId);
|
|
344
|
+
const retainHandle = !startupCleanupCompleted && failedTaskId && session.handle
|
|
345
|
+
&& (cleanupRequired || session.handle.ownership === "adopted");
|
|
346
|
+
if (retainHandle) {
|
|
224
347
|
sessions.set(failedTaskId, session);
|
|
225
348
|
reservedCwds.set(failedTaskId, cwdKey);
|
|
226
349
|
activeTaskId = failedTaskId;
|
|
350
|
+
} else if (!cleanupRequired && !startupCleanupCompleted) {
|
|
351
|
+
const released = await releaseLease(taskId);
|
|
352
|
+
if (!released && failedTaskId) {
|
|
353
|
+
sessions.set(failedTaskId, session);
|
|
354
|
+
reservedCwds.set(failedTaskId, cwdKey);
|
|
355
|
+
activeTaskId = failedTaskId;
|
|
356
|
+
}
|
|
227
357
|
}
|
|
228
358
|
throw error;
|
|
229
359
|
} finally {
|
|
@@ -260,19 +390,26 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
260
390
|
if (!approved) throw new Error("Worker command not approved");
|
|
261
391
|
approval = { actor: "human", reason: policy.reason };
|
|
262
392
|
}
|
|
393
|
+
const lease = await cwdLeaseStore.acquire(cwdKey, record.taskId, adapter.capabilities().transport);
|
|
394
|
+
cwdLeases.set(record.taskId, lease);
|
|
395
|
+
if (shuttingDown) {
|
|
396
|
+
await releaseLease(record.taskId);
|
|
397
|
+
throw new Error("Pi session is shutting down");
|
|
398
|
+
}
|
|
263
399
|
pendingCwds.add(cwdKey);
|
|
264
400
|
const session = new Supervisor(adapter, events, {
|
|
265
401
|
onHumanRequired: async (notice) => {
|
|
266
|
-
if (ctx.hasUI)
|
|
402
|
+
if (ctx.hasUI) notify(ctx, `Claude Worker needs human intervention: ${notice.reason}`, "warning");
|
|
267
403
|
if (humanWebhook.enabled) {
|
|
268
404
|
try { await humanWebhook.notify(notice); }
|
|
269
|
-
catch (error) { console.error(`pi-claude-supervisor human webhook failed: ${error instanceof Error ? error.message : String(error)}`); }
|
|
405
|
+
catch (error) { console.error(`pi-claude-supervisor human webhook failed: ${redactText(error instanceof Error ? error.message : String(error))}`); }
|
|
270
406
|
} else {
|
|
271
|
-
console.error(`pi-claude-supervisor human intervention required: ${notice.reason}`);
|
|
407
|
+
console.error(`pi-claude-supervisor human intervention required: ${redactText(notice.reason)}`);
|
|
272
408
|
}
|
|
273
409
|
},
|
|
274
410
|
});
|
|
275
411
|
pendingStartSessions.add(session);
|
|
412
|
+
let recoveryCleanupCompleted = false;
|
|
276
413
|
const recoveryOperation = (async () => {
|
|
277
414
|
try {
|
|
278
415
|
const handle = await session.start({
|
|
@@ -282,7 +419,8 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
282
419
|
// must explicitly send the next instruction.
|
|
283
420
|
task: record.task,
|
|
284
421
|
initialInput: "",
|
|
285
|
-
|
|
422
|
+
sendInitialInput: false,
|
|
423
|
+
cwd: cwdKey,
|
|
286
424
|
command: record.command,
|
|
287
425
|
args: record.args,
|
|
288
426
|
env: selectedWorkerEnvironment(),
|
|
@@ -313,20 +451,51 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
313
451
|
onDecisionSessionProgress: (info) => decisionStore.update(info.taskId, { turn: info.turn }),
|
|
314
452
|
onDecisionSessionClosed: (closedTaskId) => decisionStore.close(closedTaskId),
|
|
315
453
|
});
|
|
454
|
+
try {
|
|
455
|
+
await lease.updateWorker({
|
|
456
|
+
transport: adapter.capabilities().transport,
|
|
457
|
+
...(await workerIdentity(handle)),
|
|
458
|
+
sessionName: handle.sessionName,
|
|
459
|
+
tmuxSocket: handle.tmuxSocket,
|
|
460
|
+
ownership: handle.ownership,
|
|
461
|
+
});
|
|
462
|
+
} catch (error) {
|
|
463
|
+
const registrationError = error instanceof Error ? error : new Error(String(error));
|
|
464
|
+
try {
|
|
465
|
+
await stopSession(session, "cwd lease metadata registration failed");
|
|
466
|
+
recoveryCleanupCompleted = await releaseLease(record.taskId);
|
|
467
|
+
} catch (cleanupError) {
|
|
468
|
+
const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
|
|
469
|
+
registrationError.message = `${registrationError.message}; worker cleanup failed: ${message}`;
|
|
470
|
+
Object.defineProperty(registrationError, "workerCleanupRequired", { value: true, enumerable: false });
|
|
471
|
+
}
|
|
472
|
+
throw registrationError;
|
|
473
|
+
}
|
|
316
474
|
sessions.set(record.taskId, session);
|
|
317
475
|
reservedCwds.set(record.taskId, cwdKey);
|
|
318
476
|
activeTaskId = record.taskId;
|
|
319
477
|
await session.takeover();
|
|
320
478
|
if (shuttingDown) {
|
|
321
|
-
await stopSession(session, "Pi session shutdown during recovery");
|
|
479
|
+
await stopSession(session, "Pi session shutdown during recovery", true);
|
|
322
480
|
throw new Error("Pi session shut down during recovery");
|
|
323
481
|
}
|
|
324
482
|
message = `Worker recovered idle: task=${record.taskId} worker=${handle.id}; original task was not replayed; send an explicit continuation, then use resume-auto`;
|
|
325
483
|
} catch (error) {
|
|
326
|
-
|
|
484
|
+
const cleanupRequired = requiresWorkerCleanup(error);
|
|
485
|
+
if (cleanupRequired) cleanupRequiredTasks.add(record.taskId);
|
|
486
|
+
const retainHandle = !recoveryCleanupCompleted && session.handle
|
|
487
|
+
&& (cleanupRequired || session.handle.ownership === "adopted");
|
|
488
|
+
if (retainHandle) {
|
|
327
489
|
sessions.set(record.taskId, session);
|
|
328
490
|
reservedCwds.set(record.taskId, cwdKey);
|
|
329
491
|
activeTaskId = record.taskId;
|
|
492
|
+
} else if (!cleanupRequired && !recoveryCleanupCompleted) {
|
|
493
|
+
const released = await releaseLease(record.taskId);
|
|
494
|
+
if (!released) {
|
|
495
|
+
sessions.set(record.taskId, session);
|
|
496
|
+
reservedCwds.set(record.taskId, cwdKey);
|
|
497
|
+
activeTaskId = record.taskId;
|
|
498
|
+
}
|
|
330
499
|
}
|
|
331
500
|
throw error;
|
|
332
501
|
}
|
|
@@ -372,9 +541,12 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
372
541
|
} else if (operation === "resume") {
|
|
373
542
|
await session.resume(); message = `Worker resumed: ${sessionId}.`;
|
|
374
543
|
} else if (operation === "stop") {
|
|
375
|
-
await session
|
|
544
|
+
await stopSession(session, remaining.join(" ") || "human requested stop");
|
|
545
|
+
await releaseSettledReservations();
|
|
546
|
+
message = `Worker stopped: ${sessionId}.`;
|
|
376
547
|
} else if (operation === "verify") {
|
|
377
548
|
const result = await session.verify();
|
|
549
|
+
await releaseSettledReservations();
|
|
378
550
|
message = `${result.ok ? "Verification passed" : "Verification failed"}: ${result.command}\n${result.output}`.trim();
|
|
379
551
|
} else if (operation === "approve") {
|
|
380
552
|
const behavior = remaining[0];
|
|
@@ -386,7 +558,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
386
558
|
} else if (operation === "resume-auto") {
|
|
387
559
|
await session.resumeAutomation(); message = `Automatic decisions resumed: ${sessionId}.`;
|
|
388
560
|
} else {
|
|
389
|
-
throw new Error("Usage: /supervise start|recover|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");
|
|
561
|
+
throw new Error("Usage: /supervise start|adopt-tmux|recover|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");
|
|
390
562
|
}
|
|
391
563
|
}
|
|
392
564
|
notify(ctx, message);
|
|
@@ -401,15 +573,26 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
401
573
|
shuttingDown = true;
|
|
402
574
|
shutdownPromise = (async () => {
|
|
403
575
|
const pending = [...pendingStarts];
|
|
576
|
+
let startupFailures: PromiseRejectedResult[] = [];
|
|
404
577
|
await Promise.race([Promise.allSettled(pending), delay(5_000)]);
|
|
405
578
|
if (pendingStarts.size > 0) {
|
|
406
|
-
await Promise.allSettled([...pendingStartSessions].map((session) => session.abortStart("Pi session shutdown during startup")));
|
|
407
|
-
|
|
579
|
+
const abortResults = await Promise.allSettled([...pendingStartSessions].map((session) => session.abortStart("Pi session shutdown during startup")));
|
|
580
|
+
startupFailures = abortResults.filter((result): result is PromiseRejectedResult => result.status === "rejected");
|
|
581
|
+
// Do not snapshot sessions or release leases until every startup has
|
|
582
|
+
// settled. Otherwise a provider that resolves after a fixed timeout
|
|
583
|
+
// could register a live handle after shutdown cleanup had completed.
|
|
584
|
+
// Adapters expose bounded cancellation; an uncooperative provider must
|
|
585
|
+
// keep shutdown fail-closed rather than allowing a late worker escape.
|
|
586
|
+
while (pendingStarts.size > 0) {
|
|
587
|
+
const results = await Promise.allSettled([...pendingStarts]);
|
|
588
|
+
startupFailures.push(...results.filter((result): result is PromiseRejectedResult => result.status === "rejected"));
|
|
589
|
+
}
|
|
408
590
|
}
|
|
409
|
-
const results = await Promise.allSettled([...sessions.values()].map((session) => stopSession(session, "Pi session shutdown")));
|
|
410
|
-
|
|
591
|
+
const results = await Promise.allSettled([...sessions.values()].map((session) => stopSession(session, "Pi session shutdown", true)));
|
|
592
|
+
await releaseSettledReservations();
|
|
593
|
+
const failures = [...startupFailures, ...results.filter((result): result is PromiseRejectedResult => result.status === "rejected")];
|
|
411
594
|
if (failures.length > 0) {
|
|
412
|
-
for (const failure of failures) console.error(`pi-claude-supervisor shutdown cleanup failed: ${failure.reason instanceof Error ? failure.reason.message : String(failure.reason)}`);
|
|
595
|
+
for (const failure of failures) console.error(`pi-claude-supervisor shutdown cleanup failed: ${redactText(failure.reason instanceof Error ? failure.reason.message : String(failure.reason))}`);
|
|
413
596
|
if (exitCode === undefined) process.exitCode = 1;
|
|
414
597
|
}
|
|
415
598
|
if (exitCode !== undefined) process.exitCode = exitCode;
|
|
@@ -449,13 +632,8 @@ async function canonicalCwd(cwd: string): Promise<string> {
|
|
|
449
632
|
return realpath(cwd);
|
|
450
633
|
}
|
|
451
634
|
|
|
452
|
-
function
|
|
453
|
-
return
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
function isWithin(parent: string, child: string): boolean {
|
|
457
|
-
const childRelative = relative(parent, child);
|
|
458
|
-
return childRelative === "" || (childRelative !== ".." && !childRelative.startsWith(`..${sep}`) && !isAbsolute(childRelative));
|
|
635
|
+
function requiresWorkerCleanup(error: unknown): boolean {
|
|
636
|
+
return Boolean(error && typeof error === "object" && (error as { workerCleanupRequired?: unknown }).workerCleanupRequired === true);
|
|
459
637
|
}
|
|
460
638
|
|
|
461
639
|
function formatSessions(sessions: Map<string, Supervisor>, recoverable: DecisionSessionRecord[] = []): string {
|
|
@@ -493,8 +671,5 @@ function delay(ms: number): Promise<void> {
|
|
|
493
671
|
}
|
|
494
672
|
|
|
495
673
|
function redactText(value: string): string {
|
|
496
|
-
return value
|
|
497
|
-
.replace(/\b(sk-ant-[A-Za-z0-9_-]+)\b/gu, "[REDACTED]")
|
|
498
|
-
.replace(/\b(Bearer\s+)[^\s]+/giu, "$1[REDACTED]")
|
|
499
|
-
.replace(/(--?(?:token|api[-_]?key|secret|password|authorization)(?:=|\s+))[^\s]+/giu, "$1[REDACTED]");
|
|
674
|
+
return String(redactSensitive(value));
|
|
500
675
|
}
|
package/src/notifications.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHmac, randomUUID } from "node:crypto";
|
|
2
2
|
import type { HumanInterventionNotice } from "./supervisor.ts";
|
|
3
|
+
import { redactSensitive } from "./redaction.ts";
|
|
3
4
|
|
|
4
5
|
export interface HumanWebhookOptions {
|
|
5
6
|
url?: string;
|
|
@@ -46,38 +47,36 @@ function toGeneric(notice: HumanInterventionNotice): Record<string, unknown> {
|
|
|
46
47
|
eventId: randomUUID(),
|
|
47
48
|
event: "human_intervention_required",
|
|
48
49
|
occurredAt: new Date().toISOString(),
|
|
49
|
-
task: { id: notice.taskId, goal: notice.task, cwd: notice.cwd },
|
|
50
|
-
worker: { id: notice.workerId },
|
|
51
|
-
reason: notice.reason,
|
|
52
|
-
question: notice.question,
|
|
53
|
-
permission: notice.permission ?
|
|
50
|
+
task: { id: sanitize(notice.taskId), goal: sanitize(notice.task), cwd: sanitize(notice.cwd) },
|
|
51
|
+
worker: { id: sanitize(notice.workerId) },
|
|
52
|
+
reason: sanitize(notice.reason),
|
|
53
|
+
question: sanitize(notice.question),
|
|
54
|
+
permission: notice.permission ? sanitize(notice.permission) : undefined,
|
|
54
55
|
actions: ["approve_or_deny_permission", "send_instruction", "stop_worker", "takeover"],
|
|
55
56
|
note: "This is an outbound notification. Use the Pi session or a separately authenticated callback service to approve actions.",
|
|
56
57
|
};
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
function toWeCom(notice: HumanInterventionNotice): Record<string, unknown> {
|
|
60
|
-
const permission = notice.permission ? `\n工具: ${notice.permission.toolName}\n请求 ID: ${notice.permission.requestId}` : "";
|
|
61
|
-
const question = notice.question ? `\n问题: ${notice.question}` : "";
|
|
61
|
+
const permission = notice.permission ? `\n工具: ${safeText(notice.permission.toolName)}\n请求 ID: ${safeText(notice.permission.requestId)}` : "";
|
|
62
|
+
const question = notice.question ? `\n问题: ${safeText(notice.question)}` : "";
|
|
62
63
|
return {
|
|
63
64
|
msgtype: "markdown",
|
|
64
65
|
markdown: {
|
|
65
|
-
content: `### Claude Supervisor 需要人工介入\n> 任务: ${
|
|
66
|
+
content: `### Claude Supervisor 需要人工介入\n> 任务: ${safeText(notice.task)}\n> Task ID: ${safeText(notice.taskId)}\n> 原因: ${safeText(notice.reason)}${escapeMarkdown(question)}${escapeMarkdown(permission)}\n\n请在 Pi 中执行对应的 approve/deny、send、stop 或 takeover 操作。`,
|
|
66
67
|
},
|
|
67
68
|
};
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
function sanitize(value: unknown, key?: string): unknown {
|
|
71
|
-
if (
|
|
72
|
-
if (typeof value === "string") {
|
|
73
|
-
return value
|
|
74
|
-
.replace(/\\b(sk-ant-[A-Za-z0-9_-]+)\\b/gu, "[REDACTED]")
|
|
75
|
-
.replace(/\\b(Bearer\\s+)[^\\s]+/giu, "$1[REDACTED]")
|
|
76
|
-
.slice(0, 4_000);
|
|
77
|
-
}
|
|
72
|
+
if (typeof value === "string") return String(redactSensitive(value, key)).slice(0, 4_000);
|
|
78
73
|
if (Array.isArray(value)) return value.slice(0, 50).map((item) => sanitize(item, key));
|
|
79
74
|
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).slice(0, 50).map(([childKey, childValue]) => [childKey, sanitize(childValue, childKey)]));
|
|
80
|
-
return value;
|
|
75
|
+
return redactSensitive(value, key);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function safeText(value: unknown): string {
|
|
79
|
+
return String(sanitize(value));
|
|
81
80
|
}
|
|
82
81
|
|
|
83
82
|
function escapeMarkdown(value: string): string {
|
package/src/redaction.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const sensitiveKeyPattern = /(password|secret|token|api[-_]?key|authorization|credential)/iu;
|
|
2
|
+
|
|
3
|
+
/** Recursively redact credential-shaped values before persistence or model prompts. */
|
|
4
|
+
export function redactSensitive(value: unknown, key?: string): unknown {
|
|
5
|
+
if (key && sensitiveKeyPattern.test(key)) return "[REDACTED]";
|
|
6
|
+
if (typeof value === "string") {
|
|
7
|
+
return value
|
|
8
|
+
.replace(/\b(sk-ant-[A-Za-z0-9_-]+)\b/gu, "[REDACTED]")
|
|
9
|
+
.replace(/\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{20,}|npm_[A-Za-z0-9]{20,})\b/gu, "[REDACTED]")
|
|
10
|
+
.replace(/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/gu, "[REDACTED]")
|
|
11
|
+
.replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[REDACTED]")
|
|
12
|
+
.replace(/\b(Bearer\s+)[^\s]+/giu, "$1[REDACTED]")
|
|
13
|
+
.replace(/\b((?:AWS_ACCESS_KEY_ID|AWS_SECRET_ACCESS_KEY|AWS_SESSION_TOKEN|[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD)))=([^\s]+)/gu, "$1=[REDACTED]")
|
|
14
|
+
.replace(/\b((?:authorization|x-api-key|api-key)\s*:\s*)[^\s]+/giu, "$1[REDACTED]")
|
|
15
|
+
.replace(/(--?(?:token|api[-_]?key|secret|password|authorization)(?:=|\s+))[^\s]+/giu, "$1[REDACTED]");
|
|
16
|
+
}
|
|
17
|
+
if (Array.isArray(value)) return value.map((item) => redactSensitive(item, key));
|
|
18
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([childKey, childValue]) => [childKey, redactSensitive(childValue, childKey)]));
|
|
19
|
+
return value;
|
|
20
|
+
}
|