pi-claude-supervisor 0.5.2 → 0.5.4
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 +58 -40
- package/README.md +84 -43
- package/docs/architecture.md +108 -65
- package/docs/automation-hardening-plan.md +24 -22
- package/docs/autonomy-target.md +130 -0
- package/docs/engineering-plan.md +136 -138
- package/docs/implementation-review.md +37 -14
- package/docs/independent-review.md +23 -18
- package/docs/releasing.md +7 -2
- package/docs/testing.md +53 -27
- package/package.json +1 -1
- package/src/acceptance.ts +16 -0
- package/src/config.ts +31 -0
- package/src/decision-session-store.ts +27 -2
- package/src/decision-worker.ts +46 -18
- package/src/index.ts +46 -54
- package/src/notifications.ts +29 -11
- package/src/policy.ts +294 -22
- package/src/reviewer.ts +90 -10
- package/src/state.ts +7 -6
- package/src/supervisor.ts +332 -120
- package/src/types.ts +26 -1
- package/src/verifier.ts +106 -7
- package/src/worker/environment.ts +217 -0
- package/src/worker/process-adapter.ts +379 -77
- package/src/worker/process-tree.ts +218 -0
- package/src/worker/tmux-adapter.ts +669 -25
package/src/decision-worker.ts
CHANGED
|
@@ -12,7 +12,8 @@ const MAX_DECISION_FIELD_BYTES = 8 * 1024;
|
|
|
12
12
|
export type DecisionAction =
|
|
13
13
|
| { action: "continue" | "redirect" | "answer"; message: string; reason: string; confidence?: number }
|
|
14
14
|
| { action: "allow_permission" | "deny_permission"; requestId: string; toolUseId: string; reason: string; confidence?: number }
|
|
15
|
-
| { action: "verify" | "
|
|
15
|
+
| { action: "verify" | "stop" | "park" | "ask_human" | "noop"; reason: string; question?: string; confidence?: number }
|
|
16
|
+
| { action: "retry"; reason: string; message?: string; confidence?: number };
|
|
16
17
|
|
|
17
18
|
export interface DecisionContext {
|
|
18
19
|
taskId: string;
|
|
@@ -130,12 +131,7 @@ export class PiDecisionWorker implements DecisionWorkerLike {
|
|
|
130
131
|
const first = this.#seenEvents.values().next().value;
|
|
131
132
|
if (first) this.#seenEvents.delete(first);
|
|
132
133
|
}
|
|
133
|
-
this.#tail = this.#tail.then(async () => {
|
|
134
|
-
if (!this.#session || this.#closed) return;
|
|
135
|
-
const text = await askDecision(this.#session, event, this.#context, this.#timeoutMs);
|
|
136
|
-
const action = parseDecision(text, event);
|
|
137
|
-
await this.#options.onAction(action, event);
|
|
138
|
-
}).catch(async (error) => {
|
|
134
|
+
this.#tail = this.#tail.then(() => this.#processEvent(event)).catch(async (error) => {
|
|
139
135
|
try {
|
|
140
136
|
if (this.#options.onFailure) await this.#options.onFailure(event, error);
|
|
141
137
|
} catch {
|
|
@@ -144,6 +140,29 @@ export class PiDecisionWorker implements DecisionWorkerLike {
|
|
|
144
140
|
});
|
|
145
141
|
}
|
|
146
142
|
|
|
143
|
+
async #processEvent(event: WorkerEvent): Promise<void> {
|
|
144
|
+
if (!this.#session || this.#closed) return;
|
|
145
|
+
const maxRetries = this.#context.spec?.autonomy.maxDecisionRetries ?? 2;
|
|
146
|
+
let attempt = 0;
|
|
147
|
+
while (true) {
|
|
148
|
+
let text: string;
|
|
149
|
+
try {
|
|
150
|
+
text = await askDecision(this.#session, event, this.#context, this.#timeoutMs);
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (attempt >= maxRetries || this.#closed) throw error;
|
|
153
|
+
attempt += 1;
|
|
154
|
+
await new Promise<void>((resolve) => setTimeout(resolve, Math.min(1_000, 100 * attempt)));
|
|
155
|
+
if (!this.#session || this.#closed) return;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const action = parseDecision(text, event);
|
|
159
|
+
// An action handler may stop or close the session. Do not replay an
|
|
160
|
+
// already-decoded action if the handler itself fails.
|
|
161
|
+
await this.#options.onAction(action, event);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
147
166
|
get sessionFile(): string | undefined {
|
|
148
167
|
return this.#sessionFile;
|
|
149
168
|
}
|
|
@@ -181,14 +200,20 @@ Current repair round: ${context.repairRound ?? 0}
|
|
|
181
200
|
Task specification: ${boundedJson(context.spec ?? { goal: context.task })}
|
|
182
201
|
|
|
183
202
|
Return exactly one JSON object and no markdown:
|
|
184
|
-
{"action":"continue|redirect|answer|allow_permission|deny_permission|verify|retry|stop|
|
|
203
|
+
{"action":"continue|redirect|answer|allow_permission|deny_permission|verify|retry|stop|park|noop",...}
|
|
185
204
|
For continue/redirect/answer include message and reason. For permission actions include
|
|
186
|
-
requestId and toolUseId.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
205
|
+
requestId and toolUseId. Retry may include a corrective message. Never choose allow_permission
|
|
206
|
+
for a command that crosses the remote push or main/integration merge boundary; the deterministic
|
|
207
|
+
policy will deny it.
|
|
208
|
+
For AskUserQuestion, choose deny_permission when the question can be converted into ordinary
|
|
209
|
+
Claude text, then use answer on the resulting turn. For product ambiguity or an architecture
|
|
210
|
+
choice, inspect the repository and task evidence, select the best task-compatible option, state
|
|
211
|
+
the assumption in reason, and instruct Claude Code with answer or redirect. Do not ask a human
|
|
212
|
+
for ordinary uncertainty. Use verify when a turn result indicates the task is complete, even if
|
|
213
|
+
Claude says it will stop; choose stop only for an explicit stop or technical containment reason.
|
|
214
|
+
Use park only when the task cannot safely produce a candidate because required evidence,
|
|
215
|
+
authority, or runtime capability is unavailable. A parked candidate is asynchronous and must not
|
|
216
|
+
wait for a human to be online.`;
|
|
192
217
|
}
|
|
193
218
|
|
|
194
219
|
async function askDecision(session: AgentSession, event: WorkerEvent, context: DecisionContext, timeoutMs: number): Promise<string> {
|
|
@@ -249,12 +274,12 @@ function textFromMessage(content: unknown): string {
|
|
|
249
274
|
|
|
250
275
|
function parseDecision(text: string, event: WorkerEvent): DecisionAction {
|
|
251
276
|
const candidate = text.trim();
|
|
252
|
-
if (!candidate) return { action: "
|
|
277
|
+
if (!candidate) return { action: "park", reason: "Decision Worker returned no JSON action" };
|
|
253
278
|
try {
|
|
254
279
|
const value = JSON.parse(candidate) as Record<string, unknown>;
|
|
255
280
|
const action = value.action;
|
|
256
281
|
if (typeof action !== "string") throw new Error("missing action");
|
|
257
|
-
const allowed = new Set(["continue", "redirect", "answer", "allow_permission", "deny_permission", "verify", "retry", "stop", "ask_human", "noop"]);
|
|
282
|
+
const allowed = new Set(["continue", "redirect", "answer", "allow_permission", "deny_permission", "verify", "retry", "stop", "park", "ask_human", "noop"]);
|
|
258
283
|
if (!allowed.has(action)) throw new Error(`unsupported action: ${action}`);
|
|
259
284
|
const reason = typeof value.reason === "string" && value.reason.trim() ? boundedDecisionText(value.reason, "reason") : "no reason provided";
|
|
260
285
|
const confidence = value.confidence === undefined ? undefined : typeof value.confidence === "number" && Number.isFinite(value.confidence) && value.confidence >= 0 && value.confidence <= 1
|
|
@@ -271,9 +296,12 @@ function parseDecision(text: string, event: WorkerEvent): DecisionAction {
|
|
|
271
296
|
if (!requestId || !toolUseId) throw new Error("permission requestId/toolUseId required");
|
|
272
297
|
return { action: action as "allow_permission" | "deny_permission", requestId, toolUseId, reason, confidence };
|
|
273
298
|
}
|
|
274
|
-
|
|
299
|
+
if (action === "retry") {
|
|
300
|
+
return { action, reason, message: typeof value.message === "string" ? boundedDecisionText(value.message, "message") : undefined, confidence };
|
|
301
|
+
}
|
|
302
|
+
return { action: action as "verify" | "stop" | "park" | "ask_human" | "noop", reason, question: typeof value.question === "string" ? boundedDecisionText(value.question, "question") : undefined, confidence };
|
|
275
303
|
} catch (error) {
|
|
276
|
-
return { action: "
|
|
304
|
+
return { action: "park", reason: `invalid Decision Worker action: ${error instanceof Error ? error.message : String(error)}` };
|
|
277
305
|
}
|
|
278
306
|
}
|
|
279
307
|
|
package/src/index.ts
CHANGED
|
@@ -7,11 +7,12 @@ import { readFile, realpath } 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
|
+
import { automaticWorkerEnvironment } from "./worker/environment.ts";
|
|
10
11
|
import { TmuxWorkerAdapter, attachCommand } from "./worker/tmux-adapter.ts";
|
|
11
12
|
import { Supervisor, type DecisionSessionClosedInfo, type SupervisorProgress } from "./supervisor.ts";
|
|
12
13
|
import { evaluateCommand } from "./policy.ts";
|
|
13
14
|
import { HumanWebhookNotifier } from "./notifications.ts";
|
|
14
|
-
import { loadSupervisorEnvironment } from "./config.ts";
|
|
15
|
+
import { autonomyDefaults, loadSupervisorEnvironment } from "./config.ts";
|
|
15
16
|
import { DecisionSessionStore, type DecisionSessionRecord } from "./decision-session-store.ts";
|
|
16
17
|
import { CwdLeaseStore, type CwdLeaseHandle, pathsOverlap, workerIdentity } from "./cwd-lease.ts";
|
|
17
18
|
import { normalizeTaskSpec } from "./acceptance.ts";
|
|
@@ -38,19 +39,19 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
38
39
|
if (!["off", "auto", "required"].includes(cgroupMode)) {
|
|
39
40
|
throw new Error(`Unsupported PI_CLAUDE_SUPERVISOR_CGROUP_MODE: ${cgroupMode}; expected off, auto, or required`);
|
|
40
41
|
}
|
|
41
|
-
if (transport === "tmux" && cgroupMode === "required") {
|
|
42
|
-
throw new Error("PI_CLAUDE_SUPERVISOR_CGROUP_MODE=required is unsupported with tmux; use
|
|
42
|
+
if (transport === "tmux" && cgroupMode === "required" && !automation) {
|
|
43
|
+
throw new Error("PI_CLAUDE_SUPERVISOR_CGROUP_MODE=required is unsupported with manual tmux; use automatic mode or cgroup mode auto/off");
|
|
43
44
|
}
|
|
44
|
-
if (automation &&
|
|
45
|
-
throw new Error("automatic supervision requires PI_CLAUDE_SUPERVISOR_TRANSPORT=jsonl or tmux;
|
|
45
|
+
if (automation && !["jsonl", "tmux"].includes(transport)) {
|
|
46
|
+
throw new Error("automatic supervision requires PI_CLAUDE_SUPERVISOR_TRANSPORT=jsonl or tmux; process-pipe is manual-only");
|
|
46
47
|
}
|
|
47
48
|
const adapter = transport === "tmux"
|
|
48
|
-
? new TmuxWorkerAdapter({ stateDir })
|
|
49
|
+
? new TmuxWorkerAdapter({ stateDir, cgroupMode: automation ? "required" : cgroupMode as "off" | "auto" | "required" })
|
|
49
50
|
: new ProcessWorkerAdapter({
|
|
50
51
|
// Automatic decisions require Claude's structured event stream. The pipe
|
|
51
52
|
// transport remains available for manual/compatibility sessions.
|
|
52
53
|
mode: automation || transport === "jsonl" ? "claude-jsonl" : "process-pipe",
|
|
53
|
-
cgroupMode: cgroupMode as "off" | "auto" | "required",
|
|
54
|
+
cgroupMode: automation ? "required" : cgroupMode as "off" | "auto" | "required",
|
|
54
55
|
});
|
|
55
56
|
const humanWebhook = new HumanWebhookNotifier({
|
|
56
57
|
url: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_URL,
|
|
@@ -115,7 +116,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
115
116
|
};
|
|
116
117
|
const releaseSettledReservations = async (): Promise<void> => {
|
|
117
118
|
for (const [taskId, session] of sessions) {
|
|
118
|
-
const terminal = ["completed", "stopped", "failed"].includes(session.state);
|
|
119
|
+
const terminal = ["completed", "blocked", "stopped", "failed"].includes(session.state);
|
|
119
120
|
const detached = session.released;
|
|
120
121
|
if (!terminal && !detached) continue;
|
|
121
122
|
if (!session.handle) {
|
|
@@ -165,7 +166,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
165
166
|
await session.release(reason);
|
|
166
167
|
return;
|
|
167
168
|
}
|
|
168
|
-
if (!handle && ["failed", "completed", "stopped"].includes(session.state)) return;
|
|
169
|
+
if (!handle && ["failed", "completed", "blocked", "stopped"].includes(session.state)) return;
|
|
169
170
|
let lifecycleError: unknown;
|
|
170
171
|
try {
|
|
171
172
|
await session.stop(reason, { preserveDecisionSession });
|
|
@@ -228,24 +229,20 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
228
229
|
}
|
|
229
230
|
const tmuxSession = operation === "adopt-tmux" ? rest.shift() : undefined;
|
|
230
231
|
const task = rest.join(" ").trim();
|
|
231
|
-
const
|
|
232
|
-
const
|
|
232
|
+
const fileSpec = specPath ? await readTaskSpecFile(specPath, ctx.cwd) : undefined;
|
|
233
|
+
const spec = fileSpec ?? { autonomy: autonomyDefaults() };
|
|
234
|
+
const goal = fileSpec?.goal ?? task;
|
|
235
|
+
// Adopted sessions are explicit manual compatibility controls. They
|
|
236
|
+
// never enter the automatic Reviewer/decision loop, even when the
|
|
237
|
+
// extension is globally configured for unattended starts.
|
|
238
|
+
const taskAutomation = operation !== "adopt-tmux" && automation && spec.autonomy.unattended;
|
|
233
239
|
if (!goal) throw new Error(operation === "adopt-tmux" ? "Usage: /supervise adopt-tmux [--spec <file>] <tmux-session> <task>" : "Usage: /supervise start [--spec <file>] <task>");
|
|
234
240
|
if (operation === "adopt-tmux" && adapter.capabilities().transport !== "tmux") throw new Error("/supervise adopt-tmux requires PI_CLAUDE_SUPERVISOR_TRANSPORT=tmux");
|
|
235
241
|
const [command, ...workerArgs] = parseCommand(process.env.PI_CLAUDE_SUPERVISOR_WORKER ?? "claude");
|
|
236
242
|
if (!command) throw new Error("PI_CLAUDE_SUPERVISOR_WORKER must contain an executable");
|
|
237
243
|
const policy = evaluateCommand(command, workerArgs);
|
|
238
|
-
|
|
244
|
+
const approval: { actor: "human"; reason: string } | undefined = undefined;
|
|
239
245
|
if (policy.decision === "deny") throw new Error(`Worker command denied: ${policy.reason}`);
|
|
240
|
-
if (policy.decision === "review") {
|
|
241
|
-
if (!ctx.hasUI) throw new Error(`Worker command requires interactive approval: ${policy.reason}`);
|
|
242
|
-
const approved = await ctx.ui.confirm(
|
|
243
|
-
"Approve Claude worker command?",
|
|
244
|
-
`${redactText([command, ...workerArgs].join(" "))}\n\nReason: ${redactText(policy.reason)}`,
|
|
245
|
-
);
|
|
246
|
-
if (!approved) throw new Error("Worker command not approved");
|
|
247
|
-
approval = { actor: "human", reason: policy.reason };
|
|
248
|
-
}
|
|
249
246
|
if (shuttingDown) throw new Error("Pi session is shutting down");
|
|
250
247
|
const cwdKey = await canonicalCwd(ctx.cwd);
|
|
251
248
|
await releaseSettledReservations();
|
|
@@ -265,17 +262,12 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
265
262
|
}
|
|
266
263
|
pendingCwds.add(cwdKey);
|
|
267
264
|
const session = new Supervisor(adapter, events, {
|
|
268
|
-
reviewer,
|
|
269
|
-
|
|
270
|
-
if (ctx.hasUI) notify(ctx, `
|
|
265
|
+
reviewer: taskAutomation ? reviewer : undefined,
|
|
266
|
+
onCandidate: async (notice) => {
|
|
267
|
+
if (ctx.hasUI) notify(ctx, `Candidate ${notice.status}: ${notice.reason}`, notice.status === "ready" ? "info" : "warning");
|
|
271
268
|
if (humanWebhook.enabled) {
|
|
272
|
-
try {
|
|
273
|
-
|
|
274
|
-
} catch (error) {
|
|
275
|
-
console.error(`pi-claude-supervisor human webhook failed: ${redactText(error instanceof Error ? error.message : String(error))}`);
|
|
276
|
-
}
|
|
277
|
-
} else {
|
|
278
|
-
console.error(`pi-claude-supervisor human intervention required: ${redactText(notice.reason)}`);
|
|
269
|
+
try { await humanWebhook.notifyCandidate(notice); }
|
|
270
|
+
catch (error) { console.error(`pi-claude-supervisor candidate webhook failed: ${redactText(error instanceof Error ? error.message : String(error))}`); }
|
|
279
271
|
}
|
|
280
272
|
},
|
|
281
273
|
});
|
|
@@ -291,9 +283,9 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
291
283
|
cwd: cwdKey,
|
|
292
284
|
command,
|
|
293
285
|
args: workerArgs,
|
|
294
|
-
env: selectedWorkerEnvironment(),
|
|
286
|
+
env: selectedWorkerEnvironment(taskAutomation),
|
|
295
287
|
approval,
|
|
296
|
-
automation,
|
|
288
|
+
automation: taskAutomation,
|
|
297
289
|
tmuxSession,
|
|
298
290
|
tmuxSocket: process.env.PI_CLAUDE_SUPERVISOR_TMUX_SOCKET,
|
|
299
291
|
tmuxExpectedIdentity: tmuxSession && lease.record.worker ? {
|
|
@@ -321,6 +313,9 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
321
313
|
deadlineMs: info.deadlineMs,
|
|
322
314
|
noOutputTimeoutMs: info.noOutputTimeoutMs,
|
|
323
315
|
startedAt: info.startedAt,
|
|
316
|
+
...(info.baseCommit ? { baseCommit: info.baseCommit } : {}),
|
|
317
|
+
...(info.baseBranch ? { baseBranch: info.baseBranch } : {}),
|
|
318
|
+
...(info.resolvedExecutable ? { resolvedExecutable: info.resolvedExecutable } : {}),
|
|
324
319
|
turn: info.turn,
|
|
325
320
|
repairRound: info.repairRound,
|
|
326
321
|
...(info.lastFindingSignature ? { lastFindingSignature: info.lastFindingSignature } : {}),
|
|
@@ -455,16 +450,9 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
455
450
|
}
|
|
456
451
|
const policy = evaluateCommand(record.command, record.args);
|
|
457
452
|
if (policy.decision === "deny") throw new Error(`Worker command denied: ${policy.reason}`);
|
|
458
|
-
|
|
459
|
-
if (
|
|
460
|
-
|
|
461
|
-
const approved = await ctx.ui.confirm(
|
|
462
|
-
"Approve recovered Claude worker command?",
|
|
463
|
-
`${redactText([record.command, ...record.args].join(" "))}\n\nReason: ${redactText(policy.reason)}`,
|
|
464
|
-
);
|
|
465
|
-
if (!approved) throw new Error("Worker command not approved");
|
|
466
|
-
approval = { actor: "human", reason: policy.reason };
|
|
467
|
-
}
|
|
453
|
+
const automaticRecovery = record.spec?.autonomy.unattended !== false;
|
|
454
|
+
if (automaticRecovery && !record.resolvedExecutable) throw new Error("automatic recovery requires a persisted resolved Claude executable identity");
|
|
455
|
+
const approval = record.approval;
|
|
468
456
|
const lease = await cwdLeaseStore.acquire(cwdKey, record.taskId, adapter.capabilities().transport, takeover
|
|
469
457
|
? {
|
|
470
458
|
takeover: {
|
|
@@ -490,14 +478,12 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
490
478
|
}
|
|
491
479
|
pendingCwds.add(cwdKey);
|
|
492
480
|
const session = new Supervisor(adapter, events, {
|
|
493
|
-
reviewer,
|
|
494
|
-
|
|
495
|
-
if (ctx.hasUI) notify(ctx, `
|
|
481
|
+
reviewer: automaticRecovery ? reviewer : undefined,
|
|
482
|
+
onCandidate: async (notice) => {
|
|
483
|
+
if (ctx.hasUI) notify(ctx, `Candidate ${notice.status}: ${notice.reason}`, notice.status === "ready" ? "info" : "warning");
|
|
496
484
|
if (humanWebhook.enabled) {
|
|
497
|
-
try { await humanWebhook.
|
|
498
|
-
catch (error) { console.error(`pi-claude-supervisor
|
|
499
|
-
} else {
|
|
500
|
-
console.error(`pi-claude-supervisor human intervention required: ${redactText(notice.reason)}`);
|
|
485
|
+
try { await humanWebhook.notifyCandidate(notice); }
|
|
486
|
+
catch (error) { console.error(`pi-claude-supervisor candidate webhook failed: ${redactText(error instanceof Error ? error.message : String(error))}`); }
|
|
501
487
|
}
|
|
502
488
|
},
|
|
503
489
|
});
|
|
@@ -518,13 +504,16 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
518
504
|
cwd: cwdKey,
|
|
519
505
|
command: record.command,
|
|
520
506
|
args: record.args,
|
|
521
|
-
env: selectedWorkerEnvironment(),
|
|
507
|
+
env: selectedWorkerEnvironment(automaticRecovery),
|
|
522
508
|
approval,
|
|
523
|
-
automation:
|
|
509
|
+
automation: automaticRecovery,
|
|
510
|
+
...(automaticRecovery && record.resolvedExecutable ? { expectedClaudeExecutable: record.resolvedExecutable } : {}),
|
|
524
511
|
maxTurns: record.maxTurns,
|
|
525
512
|
deadlineMs: record.deadlineMs,
|
|
526
513
|
noOutputTimeoutMs: record.noOutputTimeoutMs,
|
|
527
514
|
startedAt: record.startedAt,
|
|
515
|
+
baseCommit: record.baseCommit,
|
|
516
|
+
baseBranch: record.baseBranch,
|
|
528
517
|
initialTurn: record.turn,
|
|
529
518
|
initialRepairRound: record.repairRound,
|
|
530
519
|
initialFindingSignature: record.lastFindingSignature,
|
|
@@ -546,6 +535,9 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
|
|
|
546
535
|
deadlineMs: info.deadlineMs,
|
|
547
536
|
noOutputTimeoutMs: info.noOutputTimeoutMs,
|
|
548
537
|
startedAt: info.startedAt,
|
|
538
|
+
...(info.baseCommit ? { baseCommit: info.baseCommit } : {}),
|
|
539
|
+
...(info.baseBranch ? { baseBranch: info.baseBranch } : {}),
|
|
540
|
+
...(info.resolvedExecutable ? { resolvedExecutable: info.resolvedExecutable } : {}),
|
|
549
541
|
turn: info.turn,
|
|
550
542
|
repairRound: info.repairRound,
|
|
551
543
|
...(info.lastFindingSignature ? { lastFindingSignature: info.lastFindingSignature } : {}),
|
|
@@ -775,7 +767,7 @@ function formatSessions(sessions: Map<string, Supervisor>, recoverable: Decision
|
|
|
775
767
|
return [...active, ...pending].join("\n") || "No task sessions.";
|
|
776
768
|
}
|
|
777
769
|
|
|
778
|
-
function selectedWorkerEnvironment(): NodeJS.ProcessEnv {
|
|
770
|
+
function selectedWorkerEnvironment(automatic = false): NodeJS.ProcessEnv {
|
|
779
771
|
const result: NodeJS.ProcessEnv = {};
|
|
780
772
|
const names = (process.env.PI_CLAUDE_SUPERVISOR_WORKER_ENV ?? "")
|
|
781
773
|
.split(",")
|
|
@@ -784,7 +776,7 @@ function selectedWorkerEnvironment(): NodeJS.ProcessEnv {
|
|
|
784
776
|
for (const name of names) {
|
|
785
777
|
if (process.env[name] !== undefined) result[name] = process.env[name];
|
|
786
778
|
}
|
|
787
|
-
return result;
|
|
779
|
+
return automatic ? automaticWorkerEnvironment(result) : result;
|
|
788
780
|
}
|
|
789
781
|
|
|
790
782
|
async function readTaskSpecFile(path: string, cwd: string): Promise<TaskSpec> {
|
package/src/notifications.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHmac, randomUUID } from "node:crypto";
|
|
2
|
-
import type { HumanInterventionNotice } from "./supervisor.ts";
|
|
2
|
+
import type { CandidateNotice, HumanInterventionNotice } from "./supervisor.ts";
|
|
3
3
|
import { redactSensitive } from "./redaction.ts";
|
|
4
4
|
|
|
5
5
|
export interface HumanWebhookOptions {
|
|
@@ -9,7 +9,7 @@ export interface HumanWebhookOptions {
|
|
|
9
9
|
timeoutMs?: number;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
/**
|
|
12
|
+
/** Optional outbound candidate delivery. It never grants permission or controls the Worker. */
|
|
13
13
|
export class HumanWebhookNotifier {
|
|
14
14
|
readonly #url?: string;
|
|
15
15
|
readonly #format: "generic" | "wecom";
|
|
@@ -28,42 +28,60 @@ export class HumanWebhookNotifier {
|
|
|
28
28
|
async notify(notice: HumanInterventionNotice): Promise<void> {
|
|
29
29
|
if (!this.#url) return;
|
|
30
30
|
const body = this.#format === "wecom" ? JSON.stringify(toWeCom(notice)) : JSON.stringify(toGeneric(notice));
|
|
31
|
+
await this.#send(body);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async notifyCandidate(notice: CandidateNotice): Promise<void> {
|
|
35
|
+
if (!this.#url) return;
|
|
36
|
+
const body = this.#format === "wecom" ? JSON.stringify(toWeCom(notice)) : JSON.stringify(toGeneric(notice));
|
|
37
|
+
await this.#send(body);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async #send(body: string): Promise<void> {
|
|
31
41
|
const headers: Record<string, string> = { "content-type": "application/json", "user-agent": "pi-claude-supervisor/0.1" };
|
|
32
42
|
if (this.#secret) headers["x-pi-supervisor-signature"] = `sha256=${createHmac("sha256", this.#secret).update(body).digest("hex")}`;
|
|
33
43
|
const controller = new AbortController();
|
|
34
44
|
const timer = setTimeout(() => controller.abort(), this.#timeoutMs);
|
|
35
45
|
try {
|
|
36
|
-
const response = await fetch(this.#url
|
|
37
|
-
if (!response.ok) throw new Error(`
|
|
46
|
+
const response = await fetch(this.#url!, { method: "POST", headers, body, signal: controller.signal });
|
|
47
|
+
if (!response.ok) throw new Error(`candidate webhook returned HTTP ${response.status}`);
|
|
38
48
|
} finally {
|
|
39
49
|
clearTimeout(timer);
|
|
40
50
|
}
|
|
41
51
|
}
|
|
42
52
|
}
|
|
43
53
|
|
|
44
|
-
function toGeneric(notice: HumanInterventionNotice): Record<string, unknown> {
|
|
54
|
+
function toGeneric(notice: HumanInterventionNotice | CandidateNotice): Record<string, unknown> {
|
|
55
|
+
const candidate = "status" in notice;
|
|
45
56
|
return {
|
|
46
|
-
schema: "pi-claude-supervisor/human-intervention/v1",
|
|
57
|
+
schema: candidate ? "pi-claude-supervisor/candidate/v1" : "pi-claude-supervisor/human-intervention/v1",
|
|
47
58
|
eventId: randomUUID(),
|
|
48
|
-
event: "human_intervention_required",
|
|
59
|
+
event: candidate ? "candidate_status" : "human_intervention_required",
|
|
49
60
|
occurredAt: new Date().toISOString(),
|
|
50
61
|
task: { id: sanitize(notice.taskId), goal: sanitize(notice.task), cwd: sanitize(notice.cwd) },
|
|
51
62
|
worker: { id: sanitize(notice.workerId) },
|
|
52
63
|
reason: sanitize(notice.reason),
|
|
53
64
|
question: sanitize(notice.question),
|
|
54
65
|
permission: notice.permission ? sanitize(notice.permission) : undefined,
|
|
55
|
-
actions: ["approve_or_deny_permission", "send_instruction", "stop_worker", "takeover"],
|
|
56
|
-
note:
|
|
66
|
+
...(candidate ? { status: notice.status, deliverable: notice.deliverable } : { actions: ["approve_or_deny_permission", "send_instruction", "stop_worker", "takeover"] }),
|
|
67
|
+
note: candidate
|
|
68
|
+
? "This is an optional candidate notification. It does not grant remote push or main/integration merge permission."
|
|
69
|
+
: "This is an outbound notification. Use the Pi session or a separately authenticated callback service to approve actions.",
|
|
57
70
|
};
|
|
58
71
|
}
|
|
59
72
|
|
|
60
|
-
function toWeCom(notice: HumanInterventionNotice): Record<string, unknown> {
|
|
73
|
+
function toWeCom(notice: HumanInterventionNotice | CandidateNotice): Record<string, unknown> {
|
|
74
|
+
const candidate = "status" in notice;
|
|
61
75
|
const permission = notice.permission ? `\n工具: ${safeText(notice.permission.toolName)}\n请求 ID: ${safeText(notice.permission.requestId)}` : "";
|
|
62
76
|
const question = notice.question ? `\n问题: ${safeText(notice.question)}` : "";
|
|
77
|
+
const title = candidate ? "Claude Supervisor 候选状态" : "Claude Supervisor 需要人工介入";
|
|
78
|
+
const suffix = candidate
|
|
79
|
+
? `\n> 状态: ${safeText(notice.status)}\n> 可交付: ${notice.deliverable ? "yes" : "no"}\n\n该通知不授予远程 push 或 main/integration merge 权限。`
|
|
80
|
+
: "\n\n请在 Pi 中执行对应的 approve/deny、send、stop 或 takeover 操作。";
|
|
63
81
|
return {
|
|
64
82
|
msgtype: "markdown",
|
|
65
83
|
markdown: {
|
|
66
|
-
content: `###
|
|
84
|
+
content: `### ${title}\n> 任务: ${safeText(notice.task)}\n> Task ID: ${safeText(notice.taskId)}\n> 原因: ${safeText(notice.reason)}${escapeMarkdown(question)}${escapeMarkdown(permission)}${suffix}`,
|
|
67
85
|
},
|
|
68
86
|
};
|
|
69
87
|
}
|