pi-claude-supervisor 0.2.2 → 0.3.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 +9 -1
- package/README.cn.md +33 -0
- package/README.md +42 -0
- package/docs/architecture.md +38 -0
- package/docs/testing.md +16 -0
- package/package.json +1 -1
- package/src/config.ts +3 -1
- 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 +52 -27
- package/src/notifications.ts +15 -16
- package/src/redaction.ts +20 -0
- package/src/supervisor.ts +148 -13
- package/src/types.ts +16 -1
- package/src/worker/process-adapter.ts +15 -2
- package/src/worker/tmux-adapter.ts +1017 -0
package/src/index.ts
CHANGED
|
@@ -3,7 +3,9 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { dirname, isAbsolute, join, relative, sep } from "node:path";
|
|
4
4
|
import { realpath } from "node:fs/promises";
|
|
5
5
|
import { EventLog } from "./events.ts";
|
|
6
|
+
import { redactSensitive } from "./redaction.ts";
|
|
6
7
|
import { ProcessWorkerAdapter } from "./worker/process-adapter.ts";
|
|
8
|
+
import { TmuxWorkerAdapter, attachCommand } from "./worker/tmux-adapter.ts";
|
|
7
9
|
import { Supervisor } from "./supervisor.ts";
|
|
8
10
|
import { evaluateCommand } from "./policy.ts";
|
|
9
11
|
import { HumanWebhookNotifier } from "./notifications.ts";
|
|
@@ -20,17 +22,24 @@ import { DecisionSessionStore, type DecisionSessionRecord } from "./decision-ses
|
|
|
20
22
|
export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
21
23
|
loadSupervisorEnvironment();
|
|
22
24
|
const automation = process.env.PI_CLAUDE_SUPERVISOR_MODE === "auto" || process.env.PI_CLAUDE_SUPERVISOR_AUTOMATION === "1";
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
const stateDir = process.env.PI_CLAUDE_SUPERVISOR_STATE_DIR ?? join(homedir(), ".pi", "agent", "claude-supervisor");
|
|
26
|
+
const configuredTransport = process.env.PI_CLAUDE_SUPERVISOR_TRANSPORT;
|
|
27
|
+
const transport = configuredTransport ?? (automation ? "jsonl" : "process-pipe");
|
|
28
|
+
if (!(["process-pipe", "jsonl", "tmux"] as string[]).includes(transport)) {
|
|
29
|
+
throw new Error(`Unsupported PI_CLAUDE_SUPERVISOR_TRANSPORT: ${transport}; expected process-pipe, jsonl, or tmux`);
|
|
30
|
+
}
|
|
31
|
+
const adapter = transport === "tmux"
|
|
32
|
+
? new TmuxWorkerAdapter({ stateDir })
|
|
33
|
+
: new ProcessWorkerAdapter({
|
|
34
|
+
// Automatic decisions require Claude's structured event stream. The pipe
|
|
35
|
+
// transport remains available for manual/compatibility sessions.
|
|
36
|
+
mode: automation || transport === "jsonl" ? "claude-jsonl" : "process-pipe",
|
|
37
|
+
});
|
|
28
38
|
const humanWebhook = new HumanWebhookNotifier({
|
|
29
39
|
url: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_URL,
|
|
30
40
|
format: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_FORMAT === "wecom" ? "wecom" : "generic",
|
|
31
41
|
secret: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_SECRET,
|
|
32
42
|
});
|
|
33
|
-
const stateDir = process.env.PI_CLAUDE_SUPERVISOR_STATE_DIR ?? join(homedir(), ".pi", "agent", "claude-supervisor");
|
|
34
43
|
const events = new EventLog(join(stateDir, "events.jsonl"));
|
|
35
44
|
const decisionStore = new DecisionSessionStore(join(stateDir, "decision-sessions"));
|
|
36
45
|
const sessions = new Map<string, Supervisor>();
|
|
@@ -43,7 +52,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
43
52
|
let shutdownPromise: Promise<void> | undefined;
|
|
44
53
|
|
|
45
54
|
const notify = (ctx: ExtensionContext, message: string, type: "info" | "warning" = "info") => {
|
|
46
|
-
if (ctx.hasUI) ctx.ui.notify(message, type);
|
|
55
|
+
if (ctx.hasUI) ctx.ui.notify(redactText(message), type);
|
|
47
56
|
};
|
|
48
57
|
const activeSessions = () => [...sessions.entries()].filter(([, session]) =>
|
|
49
58
|
["starting", "running", "waiting", "paused"].includes(session.state));
|
|
@@ -63,6 +72,14 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
63
72
|
}
|
|
64
73
|
};
|
|
65
74
|
const stopSession = async (session: Supervisor, reason: string): Promise<void> => {
|
|
75
|
+
const handle = session.handle;
|
|
76
|
+
const persistent = adapter.capabilities().persistentSession && Boolean(handle);
|
|
77
|
+
const adoptedPersistent = persistent && handle?.ownership === "adopted";
|
|
78
|
+
const healthyPersistent = persistent && !["failed", "completed", "stopped"].includes(session.state);
|
|
79
|
+
if (adoptedPersistent || healthyPersistent) {
|
|
80
|
+
await session.release(reason);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
66
83
|
let lifecycleError: unknown;
|
|
67
84
|
try {
|
|
68
85
|
await session.stop(reason);
|
|
@@ -70,7 +87,6 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
70
87
|
lifecycleError = error;
|
|
71
88
|
}
|
|
72
89
|
|
|
73
|
-
const handle = session.handle;
|
|
74
90
|
if (!handle) {
|
|
75
91
|
if (lifecycleError) throw lifecycleError;
|
|
76
92
|
return;
|
|
@@ -81,7 +97,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
81
97
|
while (Date.now() <= deadline) {
|
|
82
98
|
try {
|
|
83
99
|
const status = await adapter.getStatus(handle);
|
|
84
|
-
if (!status.running && status.processGroupCleaned === true) {
|
|
100
|
+
if (!status.running && status.processGroupCleaned === true && !status.cleanupError) {
|
|
85
101
|
if (lifecycleError) throw lifecycleError;
|
|
86
102
|
return;
|
|
87
103
|
}
|
|
@@ -109,9 +125,11 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
109
125
|
const tokens = args.trim() ? args.trim().split(/\s+/u) : [];
|
|
110
126
|
const [operation = "status", ...rest] = tokens;
|
|
111
127
|
let message = "";
|
|
112
|
-
if (operation === "start") {
|
|
128
|
+
if (operation === "start" || operation === "adopt-tmux") {
|
|
129
|
+
const tmuxSession = operation === "adopt-tmux" ? rest.shift() : undefined;
|
|
113
130
|
const task = rest.join(" ").trim();
|
|
114
|
-
if (!task) throw new Error("Usage: /supervise start <task>");
|
|
131
|
+
if (!task) throw new Error(operation === "adopt-tmux" ? "Usage: /supervise adopt-tmux <tmux-session> <task>" : "Usage: /supervise start <task>");
|
|
132
|
+
if (operation === "adopt-tmux" && adapter.capabilities().transport !== "tmux") throw new Error("/supervise adopt-tmux requires PI_CLAUDE_SUPERVISOR_TRANSPORT=tmux");
|
|
115
133
|
const [command, ...workerArgs] = parseCommand(process.env.PI_CLAUDE_SUPERVISOR_WORKER ?? "claude");
|
|
116
134
|
if (!command) throw new Error("PI_CLAUDE_SUPERVISOR_WORKER must contain an executable");
|
|
117
135
|
const policy = evaluateCommand(command, workerArgs);
|
|
@@ -137,15 +155,15 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
137
155
|
pendingCwds.add(cwdKey);
|
|
138
156
|
const session = new Supervisor(adapter, events, {
|
|
139
157
|
onHumanRequired: async (notice) => {
|
|
140
|
-
if (ctx.hasUI)
|
|
158
|
+
if (ctx.hasUI) notify(ctx, `Claude Worker needs human intervention: ${notice.reason}`, "warning");
|
|
141
159
|
if (humanWebhook.enabled) {
|
|
142
160
|
try {
|
|
143
161
|
await humanWebhook.notify(notice);
|
|
144
162
|
} catch (error) {
|
|
145
|
-
console.error(`pi-claude-supervisor human webhook failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
163
|
+
console.error(`pi-claude-supervisor human webhook failed: ${redactText(error instanceof Error ? error.message : String(error))}`);
|
|
146
164
|
}
|
|
147
165
|
} else {
|
|
148
|
-
console.error(`pi-claude-supervisor human intervention required: ${notice.reason}`);
|
|
166
|
+
console.error(`pi-claude-supervisor human intervention required: ${redactText(notice.reason)}`);
|
|
149
167
|
}
|
|
150
168
|
},
|
|
151
169
|
});
|
|
@@ -160,6 +178,9 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
160
178
|
env: selectedWorkerEnvironment(),
|
|
161
179
|
approval,
|
|
162
180
|
automation,
|
|
181
|
+
tmuxSession,
|
|
182
|
+
tmuxSocket: process.env.PI_CLAUDE_SUPERVISOR_TMUX_SOCKET,
|
|
183
|
+
sendInitialInput: !tmuxSession,
|
|
163
184
|
decisionSessionDir: decisionStore.directory,
|
|
164
185
|
onDecisionSessionReady: (info) => decisionStore.save({
|
|
165
186
|
taskId: info.taskId,
|
|
@@ -200,7 +221,8 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
200
221
|
}
|
|
201
222
|
throw new Error("Pi session shut down during worker start");
|
|
202
223
|
}
|
|
203
|
-
|
|
224
|
+
const attach = handle.sessionName ? ` attach=${attachCommand(handle)}` : "";
|
|
225
|
+
message = `${tmuxSession ? "Tmux worker adopted" : "Worker started"}: task=${taskId} worker=${handle.id} (pid ${handle.pid ?? "unknown"}); transport=${adapter.capabilities().transport}${attach}`;
|
|
204
226
|
} catch (error) {
|
|
205
227
|
// Register failed starts before the promise settles, so shutdown
|
|
206
228
|
// cannot snapshot sessions before a returned handle is retained.
|
|
@@ -263,12 +285,12 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
263
285
|
pendingCwds.add(cwdKey);
|
|
264
286
|
const session = new Supervisor(adapter, events, {
|
|
265
287
|
onHumanRequired: async (notice) => {
|
|
266
|
-
if (ctx.hasUI)
|
|
288
|
+
if (ctx.hasUI) notify(ctx, `Claude Worker needs human intervention: ${notice.reason}`, "warning");
|
|
267
289
|
if (humanWebhook.enabled) {
|
|
268
290
|
try { await humanWebhook.notify(notice); }
|
|
269
|
-
catch (error) { console.error(`pi-claude-supervisor human webhook failed: ${error instanceof Error ? error.message : String(error)}`); }
|
|
291
|
+
catch (error) { console.error(`pi-claude-supervisor human webhook failed: ${redactText(error instanceof Error ? error.message : String(error))}`); }
|
|
270
292
|
} else {
|
|
271
|
-
console.error(`pi-claude-supervisor human intervention required: ${notice.reason}`);
|
|
293
|
+
console.error(`pi-claude-supervisor human intervention required: ${redactText(notice.reason)}`);
|
|
272
294
|
}
|
|
273
295
|
},
|
|
274
296
|
});
|
|
@@ -282,6 +304,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
282
304
|
// must explicitly send the next instruction.
|
|
283
305
|
task: record.task,
|
|
284
306
|
initialInput: "",
|
|
307
|
+
sendInitialInput: false,
|
|
285
308
|
cwd: record.cwd,
|
|
286
309
|
command: record.command,
|
|
287
310
|
args: record.args,
|
|
@@ -386,7 +409,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
386
409
|
} else if (operation === "resume-auto") {
|
|
387
410
|
await session.resumeAutomation(); message = `Automatic decisions resumed: ${sessionId}.`;
|
|
388
411
|
} 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");
|
|
412
|
+
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
413
|
}
|
|
391
414
|
}
|
|
392
415
|
notify(ctx, message);
|
|
@@ -401,15 +424,20 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
401
424
|
shuttingDown = true;
|
|
402
425
|
shutdownPromise = (async () => {
|
|
403
426
|
const pending = [...pendingStarts];
|
|
427
|
+
let startupFailures: PromiseRejectedResult[] = [];
|
|
404
428
|
await Promise.race([Promise.allSettled(pending), delay(5_000)]);
|
|
405
429
|
if (pendingStarts.size > 0) {
|
|
406
|
-
await Promise.allSettled([...pendingStartSessions].map((session) => session.abortStart("Pi session shutdown during startup")));
|
|
407
|
-
|
|
430
|
+
const abortResults = await Promise.allSettled([...pendingStartSessions].map((session) => session.abortStart("Pi session shutdown during startup")));
|
|
431
|
+
startupFailures = abortResults.filter((result): result is PromiseRejectedResult => result.status === "rejected");
|
|
432
|
+
// Startup adapters own their cleanup and expose bounded cancellation;
|
|
433
|
+
// do not exit while one of those cleanups is still in flight. A model
|
|
434
|
+
// provider can still fail to honor disposal, so retain a final bound.
|
|
435
|
+
await Promise.race([Promise.allSettled([...pendingStarts]), delay(30_000)]);
|
|
408
436
|
}
|
|
409
437
|
const results = await Promise.allSettled([...sessions.values()].map((session) => stopSession(session, "Pi session shutdown")));
|
|
410
|
-
const failures = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
|
|
438
|
+
const failures = [...startupFailures, ...results.filter((result): result is PromiseRejectedResult => result.status === "rejected")];
|
|
411
439
|
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)}`);
|
|
440
|
+
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
441
|
if (exitCode === undefined) process.exitCode = 1;
|
|
414
442
|
}
|
|
415
443
|
if (exitCode !== undefined) process.exitCode = exitCode;
|
|
@@ -493,8 +521,5 @@ function delay(ms: number): Promise<void> {
|
|
|
493
521
|
}
|
|
494
522
|
|
|
495
523
|
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]");
|
|
524
|
+
return String(redactSensitive(value));
|
|
500
525
|
}
|
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
|
+
}
|
package/src/supervisor.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { SupervisorStateMachine } from "./state.ts";
|
|
|
5
5
|
import { evaluatePermission } from "./policy.ts";
|
|
6
6
|
import { PiDecisionWorker, type DecisionAction } from "./decision-worker.ts";
|
|
7
7
|
import { verify, type VerificationCommand } from "./verifier.ts";
|
|
8
|
+
import { redactSensitive } from "./redaction.ts";
|
|
8
9
|
import type {
|
|
9
10
|
TaskContext,
|
|
10
11
|
VerificationResult,
|
|
@@ -48,7 +49,13 @@ export interface SupervisorStartOptions {
|
|
|
48
49
|
noOutputTimeoutMs?: number;
|
|
49
50
|
/** Human approval for a review-level worker command. */
|
|
50
51
|
approval?: { actor: "human"; reason: string };
|
|
51
|
-
/**
|
|
52
|
+
/** Adopt an existing tmux session instead of starting a new worker. */
|
|
53
|
+
tmuxSession?: string;
|
|
54
|
+
/** Optional socket path for an existing non-default tmux server. */
|
|
55
|
+
tmuxSocket?: string;
|
|
56
|
+
/** Do not replay the task when adopting an existing interactive session. */
|
|
57
|
+
sendInitialInput?: boolean;
|
|
58
|
+
/** Enable the event-driven Pi Decision Worker. Requires claude-jsonl or tmux. */
|
|
52
59
|
automation?: boolean;
|
|
53
60
|
/** Persistent Pi session location for the Decision Worker. */
|
|
54
61
|
decisionSessionFile?: string;
|
|
@@ -141,8 +148,8 @@ export class Supervisor {
|
|
|
141
148
|
...(options.approval ? { approval: options.approval } : {}),
|
|
142
149
|
},
|
|
143
150
|
});
|
|
144
|
-
if (this.#automation && this.#adapter.capabilities().transport
|
|
145
|
-
throw new Error("automatic supervision requires claude-jsonl transport");
|
|
151
|
+
if (this.#automation && !["jsonl", "tmux"].includes(this.#adapter.capabilities().transport)) {
|
|
152
|
+
throw new Error("automatic supervision requires claude-jsonl or tmux transport");
|
|
146
153
|
}
|
|
147
154
|
if (this.#automation) {
|
|
148
155
|
this.#decision = new PiDecisionWorker({
|
|
@@ -173,6 +180,9 @@ export class Supervisor {
|
|
|
173
180
|
args: options.args,
|
|
174
181
|
env: options.env,
|
|
175
182
|
approval: options.approval,
|
|
183
|
+
tmuxSession: options.tmuxSession,
|
|
184
|
+
tmuxSocket: options.tmuxSocket,
|
|
185
|
+
sendInitialInput: options.sendInitialInput,
|
|
176
186
|
eventListener: (event) => this.#receiveWorkerEvent(event),
|
|
177
187
|
};
|
|
178
188
|
this.#handle = await this.#adapter.start(input);
|
|
@@ -181,8 +191,9 @@ export class Supervisor {
|
|
|
181
191
|
this.#armWatchdog();
|
|
182
192
|
return this.#handle;
|
|
183
193
|
} catch (error) {
|
|
184
|
-
const
|
|
185
|
-
|
|
194
|
+
const startFailure = error as { workerHandle?: WorkerHandle; workerCleanupRequired?: boolean };
|
|
195
|
+
const startFailureHandle = startFailure.workerHandle;
|
|
196
|
+
if (!this.#handle && startFailureHandle && (startFailure.workerCleanupRequired || startFailureHandle.ownership || startFailureHandle.sessionName)) this.#handle = startFailureHandle;
|
|
186
197
|
const handle = this.#handle;
|
|
187
198
|
if (handle) {
|
|
188
199
|
try {
|
|
@@ -289,7 +300,7 @@ export class Supervisor {
|
|
|
289
300
|
}
|
|
290
301
|
try {
|
|
291
302
|
if (this.#onHumanRequired) await this.#onHumanRequired(notice);
|
|
292
|
-
else console.error(`pi-claude-supervisor human intervention required: ${reason}`);
|
|
303
|
+
else console.error(`pi-claude-supervisor human intervention required: ${safeMessage(reason)}`);
|
|
293
304
|
} catch (notifyError) {
|
|
294
305
|
console.error(`pi-claude-supervisor human intervention notification failed: ${safeMessage(notifyError)}`);
|
|
295
306
|
}
|
|
@@ -350,6 +361,11 @@ export class Supervisor {
|
|
|
350
361
|
return;
|
|
351
362
|
}
|
|
352
363
|
if (action.action === "verify") {
|
|
364
|
+
if (this.#machine.state === "waiting" && this.#adapter.capabilities().persistentSession) {
|
|
365
|
+
this.#machine.transition("verifying");
|
|
366
|
+
await this.#verifyInternal();
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
353
369
|
if (this.#machine.state === "waiting") {
|
|
354
370
|
await this.#adapter.stop(handle, "Decision Worker requested verification");
|
|
355
371
|
await this.#pollInternal(true);
|
|
@@ -393,7 +409,7 @@ export class Supervisor {
|
|
|
393
409
|
// Alert delivery is independent from event-log persistence: a broken audit
|
|
394
410
|
// path must not suppress the operator notification.
|
|
395
411
|
if (this.#onHumanRequired) await this.#onHumanRequired(notice);
|
|
396
|
-
else console.error(`pi-claude-supervisor human intervention required: ${reason}`);
|
|
412
|
+
else console.error(`pi-claude-supervisor human intervention required: ${safeMessage(reason)}`);
|
|
397
413
|
if (logError) throw logError;
|
|
398
414
|
}
|
|
399
415
|
|
|
@@ -440,7 +456,9 @@ export class Supervisor {
|
|
|
440
456
|
if (!["running", "waiting"].includes(this.#machine.state)) throw new Error(`cannot send from ${this.#machine.state}`);
|
|
441
457
|
const status = await this.#adapter.getStatus(handle);
|
|
442
458
|
if (status.activeRequests !== undefined && status.activeRequests > 0) {
|
|
443
|
-
throw new Error(
|
|
459
|
+
throw new Error(this.#adapter.capabilities().transport === "jsonl"
|
|
460
|
+
? "worker has an active JSONL request; poll until its result before sending the next turn"
|
|
461
|
+
: "worker has an active turn; wait until its interactive prompt or structured result is ready before sending another turn");
|
|
444
462
|
}
|
|
445
463
|
if (status.activeRequests === 0 && this.#machine.state === "running") {
|
|
446
464
|
this.#machine.transition("waiting");
|
|
@@ -480,9 +498,35 @@ export class Supervisor {
|
|
|
480
498
|
async abortStart(reason = "startup aborted"): Promise<void> {
|
|
481
499
|
// This path intentionally bypasses #exclusive(): start() may be blocked in
|
|
482
500
|
// a Decision Worker model call and shutdown must still dispose that session.
|
|
501
|
+
let cleanupError: unknown;
|
|
502
|
+
const abort = this.#adapter.abortStart?.(reason);
|
|
503
|
+
if (abort) {
|
|
504
|
+
try { await abort; }
|
|
505
|
+
catch (error) { cleanupError = error; }
|
|
506
|
+
}
|
|
483
507
|
await this.#decision?.close().catch(() => {});
|
|
484
508
|
this.#decision = undefined;
|
|
485
|
-
if (this.#handle)
|
|
509
|
+
if (this.#handle) {
|
|
510
|
+
try { await this.#adapter.stop(this.#handle, reason); }
|
|
511
|
+
catch (error) { cleanupError ??= error; }
|
|
512
|
+
}
|
|
513
|
+
if (cleanupError) throw cleanupError;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async release(reason = "Pi session disconnected"): Promise<void> {
|
|
517
|
+
const handle = this.#handle;
|
|
518
|
+
const preemptiveRelease = handle
|
|
519
|
+
? this.#adapter.release
|
|
520
|
+
? this.#adapter.release(handle, reason)
|
|
521
|
+
: this.#adapter.stop(handle, reason)
|
|
522
|
+
: this.#adapter.abortStart?.(reason) ?? Promise.resolve();
|
|
523
|
+
await this.#decision?.close().catch(() => {});
|
|
524
|
+
this.#decision = undefined;
|
|
525
|
+
await withTimeout(this.#exclusive(async () => {
|
|
526
|
+
this.#clearWatchdog();
|
|
527
|
+
await preemptiveRelease;
|
|
528
|
+
if (handle) await this.#appendEvent({ type: "worker_released", taskId: this.#task?.taskId, workerId: handle.id, data: { reason } });
|
|
529
|
+
}), 15_000, "persistent worker release");
|
|
486
530
|
}
|
|
487
531
|
|
|
488
532
|
async stop(reason = "human requested stop"): Promise<void> {
|
|
@@ -509,19 +553,44 @@ export class Supervisor {
|
|
|
509
553
|
await Promise.resolve(this.#onDecisionSessionClosed?.(this.#task?.taskId ?? "")).catch(() => {});
|
|
510
554
|
return;
|
|
511
555
|
}
|
|
556
|
+
if (this.#machine.state === "completed") {
|
|
557
|
+
if (this.#handle) {
|
|
558
|
+
await this.#adapter.stop(this.#handle, reason);
|
|
559
|
+
await this.#drainOutputAfterStop(this.#handle);
|
|
560
|
+
}
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
512
563
|
if (!["running", "waiting", "paused", "starting"].includes(this.#machine.state)) return;
|
|
513
564
|
this.#clearWatchdog();
|
|
514
565
|
const preemptiveStop = this.#preemptiveStop;
|
|
515
566
|
this.#preemptiveStop = undefined;
|
|
516
567
|
if (preemptiveStop) await preemptiveStop;
|
|
517
568
|
else await this.#adapter.stop(this.#handle, reason);
|
|
569
|
+
let outputError: unknown;
|
|
570
|
+
try {
|
|
571
|
+
await this.#drainOutputAfterStop(this.#handle);
|
|
572
|
+
} catch (error) {
|
|
573
|
+
outputError = error;
|
|
574
|
+
}
|
|
518
575
|
this.#machine.transition("stopped");
|
|
519
576
|
await this.#appendEvent({ type: "worker_stopped", taskId: this.#task?.taskId, workerId: this.#handle.id, data: { reason } });
|
|
577
|
+
if (outputError) throw outputError;
|
|
520
578
|
await this.#decision?.close().catch(() => {});
|
|
521
579
|
this.#decision = undefined;
|
|
522
580
|
await Promise.resolve(this.#onDecisionSessionClosed?.(this.#task?.taskId ?? "")).catch(() => {});
|
|
523
581
|
}
|
|
524
582
|
|
|
583
|
+
async #drainOutputAfterStop(handle: WorkerHandle): Promise<void> {
|
|
584
|
+
const output = await this.#adapter.readOutput(handle);
|
|
585
|
+
if (!output.length) return;
|
|
586
|
+
try {
|
|
587
|
+
await this.#appendEvent({ type: "worker_output", taskId: this.#task?.taskId, workerId: handle.id, data: { chunks: output } });
|
|
588
|
+
} catch (error) {
|
|
589
|
+
if (this.#adapter.restoreOutput) await this.#adapter.restoreOutput(handle, output).catch(() => {});
|
|
590
|
+
throw error;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
525
594
|
async verify(command?: VerificationCommand): Promise<VerificationResult> {
|
|
526
595
|
return this.#exclusive(() => this.#verifyInternal(command));
|
|
527
596
|
}
|
|
@@ -529,17 +598,70 @@ export class Supervisor {
|
|
|
529
598
|
async #verifyInternal(command?: VerificationCommand): Promise<VerificationResult> {
|
|
530
599
|
await this.#flushPendingEvents();
|
|
531
600
|
if (!this.#task) throw new Error("no active task");
|
|
601
|
+
if (this.#machine.state === "waiting" && this.#adapter.capabilities().persistentSession) this.#machine.transition("verifying");
|
|
532
602
|
if (this.#machine.state !== "verifying") throw new Error(`cannot verify from ${this.#machine.state}`);
|
|
533
|
-
|
|
603
|
+
let result: VerificationResult;
|
|
604
|
+
try {
|
|
605
|
+
result = await verify(this.#task.cwd, command);
|
|
606
|
+
} catch (error) {
|
|
607
|
+
await this.#failVerification(error);
|
|
608
|
+
throw error;
|
|
609
|
+
}
|
|
610
|
+
this.#clearWatchdog();
|
|
611
|
+
let cleanupError: unknown;
|
|
612
|
+
if (this.#handle) {
|
|
613
|
+
try {
|
|
614
|
+
await this.#adapter.stop(this.#handle, result.ok ? "verification passed" : "verification failed");
|
|
615
|
+
await this.#drainOutputAfterStop(this.#handle);
|
|
616
|
+
const cleanup = await this.#adapter.getStatus(this.#handle);
|
|
617
|
+
if (cleanup.cleanupError) throw new Error(`worker cleanup failed after verification: ${cleanup.cleanupError}`);
|
|
618
|
+
if (this.#handle.ownership === "owned" && (cleanup.running || cleanup.processGroupCleaned !== true)) {
|
|
619
|
+
throw new Error("owned worker cleanup was not confirmed after verification");
|
|
620
|
+
}
|
|
621
|
+
} catch (error) {
|
|
622
|
+
cleanupError = error;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
534
625
|
this.#lastVerification = result;
|
|
535
|
-
|
|
536
|
-
|
|
626
|
+
const verificationSucceeded = result.ok && !cleanupError;
|
|
627
|
+
this.#machine.transition(verificationSucceeded ? "completed" : "failed");
|
|
628
|
+
await this.#appendEvent({ type: verificationSucceeded ? "verification_passed" : "verification_failed", taskId: this.#task.taskId, workerId: this.#handle?.id, data: { ...result, ...(cleanupError ? { cleanupError: safeMessage(cleanupError) } : {}) } });
|
|
537
629
|
await this.#decision?.close().catch(() => {});
|
|
538
630
|
this.#decision = undefined;
|
|
539
631
|
await Promise.resolve(this.#onDecisionSessionClosed?.(this.#task.taskId)).catch(() => {});
|
|
632
|
+
if (cleanupError) throw cleanupError;
|
|
540
633
|
return result;
|
|
541
634
|
}
|
|
542
635
|
|
|
636
|
+
async #failVerification(error: unknown): Promise<void> {
|
|
637
|
+
this.#clearWatchdog();
|
|
638
|
+
let cleanupError: unknown;
|
|
639
|
+
if (this.#handle) {
|
|
640
|
+
try {
|
|
641
|
+
await this.#adapter.stop(this.#handle, "verification failed");
|
|
642
|
+
await this.#drainOutputAfterStop(this.#handle);
|
|
643
|
+
} catch (stopError) {
|
|
644
|
+
cleanupError = stopError;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
if (this.#machine.state === "verifying") this.#machine.transition("failed");
|
|
648
|
+
if (this.#task) {
|
|
649
|
+
try {
|
|
650
|
+
await this.#appendEvent({
|
|
651
|
+
type: "verification_failed",
|
|
652
|
+
taskId: this.#task.taskId,
|
|
653
|
+
workerId: this.#handle?.id,
|
|
654
|
+
data: { error: safeMessage(error), ...(cleanupError ? { cleanupError: safeMessage(cleanupError) } : {}) },
|
|
655
|
+
});
|
|
656
|
+
} catch {
|
|
657
|
+
// Preserve the verifier error; the event remains a pending lifecycle record.
|
|
658
|
+
}
|
|
659
|
+
await this.#decision?.close().catch(() => {});
|
|
660
|
+
this.#decision = undefined;
|
|
661
|
+
await Promise.resolve(this.#onDecisionSessionClosed?.(this.#task.taskId)).catch(() => {});
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
543
665
|
#armWatchdog(): void {
|
|
544
666
|
if (this.#deadlineMs <= 0 && this.#noOutputTimeoutMs <= 0) return;
|
|
545
667
|
this.#watchdog = setInterval(() => { void this.#checkWatchdog().catch(() => { /* lifecycle state is retained for the next explicit operation */ }); }, 1_000);
|
|
@@ -618,6 +740,19 @@ export class Supervisor {
|
|
|
618
740
|
}
|
|
619
741
|
}
|
|
620
742
|
|
|
743
|
+
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
|
744
|
+
let timer: NodeJS.Timeout | undefined;
|
|
745
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
746
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
747
|
+
timer.unref();
|
|
748
|
+
});
|
|
749
|
+
try {
|
|
750
|
+
return await Promise.race([promise, timeout]);
|
|
751
|
+
} finally {
|
|
752
|
+
if (timer) clearTimeout(timer);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
621
756
|
function workerEventKey(event: WorkerEvent): string {
|
|
622
757
|
if (event.type === "permission_request") return `${event.handle.id}:permission:${event.request.requestId}`;
|
|
623
758
|
if (event.type === "turn_completed") return `${event.handle.id}:result:${event.sequence}`;
|
|
@@ -627,5 +762,5 @@ function workerEventKey(event: WorkerEvent): string {
|
|
|
627
762
|
}
|
|
628
763
|
|
|
629
764
|
function safeMessage(error: unknown): string {
|
|
630
|
-
return error instanceof Error ? error.message : String(error);
|
|
765
|
+
return String(redactSensitive(error instanceof Error ? error.message : String(error)));
|
|
631
766
|
}
|
package/src/types.ts
CHANGED
|
@@ -36,6 +36,12 @@ export interface WorkerStartInput {
|
|
|
36
36
|
env?: NodeJS.ProcessEnv;
|
|
37
37
|
approval?: { actor: "human"; reason: string };
|
|
38
38
|
eventListener?: WorkerEventListener;
|
|
39
|
+
/** Attach to an existing tmux session instead of starting a new worker. */
|
|
40
|
+
tmuxSession?: string;
|
|
41
|
+
/** Optional tmux socket path; omitted means the user's default server. */
|
|
42
|
+
tmuxSocket?: string;
|
|
43
|
+
/** Adopted sessions must not replay the task as a new user message. */
|
|
44
|
+
sendInitialInput?: boolean;
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
export interface WorkerHandle {
|
|
@@ -44,6 +50,9 @@ export interface WorkerHandle {
|
|
|
44
50
|
startedAt: string;
|
|
45
51
|
cwd: string;
|
|
46
52
|
sessionId?: string;
|
|
53
|
+
sessionName?: string;
|
|
54
|
+
tmuxSocket?: string;
|
|
55
|
+
ownership?: "owned" | "adopted";
|
|
47
56
|
}
|
|
48
57
|
|
|
49
58
|
export interface WorkerStatus {
|
|
@@ -75,6 +84,8 @@ export interface PermissionDecision {
|
|
|
75
84
|
export interface WorkerAdapter {
|
|
76
85
|
capabilities(): WorkerCapabilities;
|
|
77
86
|
start(input: WorkerStartInput): Promise<WorkerHandle>;
|
|
87
|
+
/** Cancel adapter-owned startup work before a WorkerHandle is returned. */
|
|
88
|
+
abortStart?(reason: string): Promise<void>;
|
|
78
89
|
getStatus(handle: WorkerHandle): Promise<WorkerStatus>;
|
|
79
90
|
readOutput(handle: WorkerHandle): Promise<WorkerOutputChunk[]>;
|
|
80
91
|
/** Restore chunks when diagnostic event persistence fails before acknowledgement. */
|
|
@@ -87,6 +98,8 @@ export interface WorkerAdapter {
|
|
|
87
98
|
pause(handle: WorkerHandle): Promise<void>;
|
|
88
99
|
resume(handle: WorkerHandle): Promise<void>;
|
|
89
100
|
stop(handle: WorkerHandle, reason: string): Promise<void>;
|
|
101
|
+
/** Disconnect the supervisor without stopping a persistent worker, if supported. */
|
|
102
|
+
release?(handle: WorkerHandle, reason: string): Promise<void>;
|
|
90
103
|
killProcessGroup(handle: WorkerHandle, reason: string): Promise<void>;
|
|
91
104
|
resumeSession(sessionId: string): Promise<WorkerHandle>;
|
|
92
105
|
}
|
|
@@ -98,11 +111,13 @@ export interface WorkerOutputChunk {
|
|
|
98
111
|
}
|
|
99
112
|
|
|
100
113
|
export interface WorkerCapabilities {
|
|
101
|
-
transport: "process-pipe" | "pty" | "jsonl";
|
|
114
|
+
transport: "process-pipe" | "pty" | "jsonl" | "tmux";
|
|
102
115
|
interactiveInput: boolean;
|
|
103
116
|
pause: boolean;
|
|
104
117
|
resumeSession: boolean;
|
|
105
118
|
processGroupControl: boolean;
|
|
119
|
+
/** The worker can remain alive while Pi disconnects from it. */
|
|
120
|
+
persistentSession?: boolean;
|
|
106
121
|
}
|
|
107
122
|
|
|
108
123
|
export interface TaskContext {
|
|
@@ -192,7 +192,11 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
192
192
|
} catch (error) {
|
|
193
193
|
try { await this.#ensureGroupCleanup(record); } catch (cleanupError) { record.cleanupError = cleanupError instanceof Error ? cleanupError : new Error(String(cleanupError)); }
|
|
194
194
|
const startupError = error instanceof Error ? error : new Error(String(error));
|
|
195
|
-
|
|
195
|
+
if (record.cleanupError) {
|
|
196
|
+
startupError.message = `${startupError.message}; startup cleanup failed: ${record.cleanupError.message}`;
|
|
197
|
+
Object.defineProperty(startupError, "workerHandle", { value: handle, enumerable: false });
|
|
198
|
+
Object.defineProperty(startupError, "workerCleanupRequired", { value: true, enumerable: false });
|
|
199
|
+
}
|
|
196
200
|
throw startupError;
|
|
197
201
|
}
|
|
198
202
|
if (input.task) {
|
|
@@ -200,7 +204,16 @@ export class ProcessWorkerAdapter implements WorkerAdapter {
|
|
|
200
204
|
try {
|
|
201
205
|
await this.#writeInput(record, this.#encodeMessage(input.task));
|
|
202
206
|
} catch (error) {
|
|
203
|
-
|
|
207
|
+
let cleanupError: unknown;
|
|
208
|
+
try { await this.stop(handle, "initial worker input failed"); }
|
|
209
|
+
catch (stopError) { cleanupError = stopError; }
|
|
210
|
+
if (cleanupError) {
|
|
211
|
+
const startupError = error instanceof Error ? error : new Error(String(error));
|
|
212
|
+
startupError.message = `${startupError.message}; startup cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`;
|
|
213
|
+
Object.defineProperty(startupError, "workerHandle", { value: handle, enumerable: false });
|
|
214
|
+
Object.defineProperty(startupError, "workerCleanupRequired", { value: true, enumerable: false });
|
|
215
|
+
throw startupError;
|
|
216
|
+
}
|
|
204
217
|
throw error;
|
|
205
218
|
}
|
|
206
219
|
}
|