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.
@@ -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" | "retry" | "stop" | "ask_human" | "noop"; reason: string; question?: string; confidence?: number };
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|ask_human|noop",...}
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. For ask_human optionally include question. Never choose allow_permission unless the request is low-risk, directly required by the task, and the policy evidence supports it.
187
- For AskUserQuestion, prefer deny_permission when the question can be converted into ordinary Claude text;
188
- then use answer on the resulting turn only when the task and repository make the answer unambiguous.
189
- Use verify when a turn result indicates the task is complete, even if Claude says it will stop; choose stop only for an explicit human stop, unrecoverable failure, or a safety reason.
190
- Use ask_human for product ambiguity, architecture tradeoffs with material risk, unknown tools,
191
- secrets, deployment, or any uncertainty. Never invent missing information.`;
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: "ask_human", reason: "Decision Worker returned no JSON 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
- return { action: action as "verify" | "retry" | "stop" | "ask_human" | "noop", reason, question: typeof value.question === "string" ? boundedDecisionText(value.question, "question") : undefined, confidence };
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: "ask_human", reason: `invalid Decision Worker action: ${error instanceof Error ? error.message : String(error)}` };
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 process-pipe/jsonl or set cgroup mode to auto/off");
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 && transport === "process-pipe") {
45
- throw new Error("automatic supervision requires PI_CLAUDE_SUPERVISOR_TRANSPORT=jsonl or tmux; explicit process-pipe is manual-only");
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 spec = specPath ? await readTaskSpecFile(specPath, ctx.cwd) : undefined;
232
- const goal = spec?.goal ?? task;
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
- let approval: { actor: "human"; reason: string } | undefined;
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
- onHumanRequired: async (notice) => {
270
- if (ctx.hasUI) notify(ctx, `Claude Worker needs human intervention: ${notice.reason}`, "warning");
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
- await humanWebhook.notify(notice);
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
- let approval = record.approval;
459
- if (policy.decision === "review" && !approval) {
460
- if (!ctx.hasUI) throw new Error(`Worker command requires interactive approval: ${policy.reason}`);
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
- onHumanRequired: async (notice) => {
495
- if (ctx.hasUI) notify(ctx, `Claude Worker needs human intervention: ${notice.reason}`, "warning");
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.notify(notice); }
498
- catch (error) { console.error(`pi-claude-supervisor human webhook failed: ${redactText(error instanceof Error ? error.message : String(error))}`); }
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: true,
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> {
@@ -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
- /** Outbound-only human escalation. Approval still happens through Pi/manual control. */
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, { method: "POST", headers, body, signal: controller.signal });
37
- if (!response.ok) throw new Error(`human webhook returned HTTP ${response.status}`);
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: "This is an outbound notification. Use the Pi session or a separately authenticated callback service to approve actions.",
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: `### 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 操作。`,
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
  }