pi-claude-supervisor 0.5.1 → 0.5.2

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.
@@ -6,6 +6,9 @@ import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager,
6
6
  import type { TaskSpec, WorkerEvent } from "./types.ts";
7
7
  import { redactSensitive } from "./redaction.ts";
8
8
 
9
+ const MAX_DECISION_RESPONSE_BYTES = 32 * 1024;
10
+ const MAX_DECISION_FIELD_BYTES = 8 * 1024;
11
+
9
12
  export type DecisionAction =
10
13
  | { action: "continue" | "redirect" | "answer"; message: string; reason: string; confidence?: number }
11
14
  | { action: "allow_permission" | "deny_permission"; requestId: string; toolUseId: string; reason: string; confidence?: number }
@@ -189,10 +192,37 @@ secrets, deployment, or any uncertainty. Never invent missing information.`;
189
192
  }
190
193
 
191
194
  async function askDecision(session: AgentSession, event: WorkerEvent, context: DecisionContext, timeoutMs: number): Promise<string> {
192
- let text = "";
195
+ let current = "";
196
+ let finalMessage = "";
197
+ let capturingAssistant = false;
198
+ let currentTooLarge = false;
199
+ let finalTooLarge = false;
193
200
  const unsubscribe = session.subscribe((value) => {
194
- const record = value as unknown as { type?: string; assistantMessageEvent?: { type?: string; delta?: string } };
195
- if (record.type === "message_update" && record.assistantMessageEvent?.type === "text_delta") text += record.assistantMessageEvent.delta ?? "";
201
+ const record = value as unknown as { type?: string; message?: { role?: string; content?: unknown }; assistantMessageEvent?: { type?: string; delta?: string } };
202
+ const role = record.message?.role;
203
+ if (record.type === "message_start" && role === "assistant") {
204
+ current = "";
205
+ currentTooLarge = false;
206
+ capturingAssistant = true;
207
+ return;
208
+ }
209
+ if (record.type === "message_update" && record.assistantMessageEvent?.type === "text_delta" && (capturingAssistant || role === "assistant" || role === undefined)) {
210
+ capturingAssistant = true;
211
+ const delta = record.assistantMessageEvent.delta ?? "";
212
+ if (Buffer.byteLength(current, "utf8") + Buffer.byteLength(delta, "utf8") > MAX_DECISION_RESPONSE_BYTES) {
213
+ currentTooLarge = true;
214
+ return;
215
+ }
216
+ current += delta;
217
+ return;
218
+ }
219
+ if (record.type === "message_end" && role === "assistant") {
220
+ finalMessage = current || textFromMessage(record.message?.content);
221
+ finalTooLarge = currentTooLarge;
222
+ current = "";
223
+ currentTooLarge = false;
224
+ capturingAssistant = false;
225
+ }
196
226
  });
197
227
  try {
198
228
  try {
@@ -204,11 +234,21 @@ async function askDecision(session: AgentSession, event: WorkerEvent, context: D
204
234
  } finally {
205
235
  unsubscribe();
206
236
  }
207
- return text;
237
+ if (finalTooLarge) return "";
238
+ return finalMessage || current;
239
+ }
240
+
241
+ function textFromMessage(content: unknown): string {
242
+ if (!Array.isArray(content)) return "";
243
+ return content
244
+ .filter((block): block is { type?: unknown; text?: unknown } => Boolean(block && typeof block === "object"))
245
+ .filter((block) => block.type === "text" && typeof block.text === "string")
246
+ .map((block) => block.text as string)
247
+ .join("");
208
248
  }
209
249
 
210
250
  function parseDecision(text: string, event: WorkerEvent): DecisionAction {
211
- const candidate = text.match(/\{[\s\S]*\}/u)?.[0];
251
+ const candidate = text.trim();
212
252
  if (!candidate) return { action: "ask_human", reason: "Decision Worker returned no JSON action" };
213
253
  try {
214
254
  const value = JSON.parse(candidate) as Record<string, unknown>;
@@ -216,11 +256,13 @@ function parseDecision(text: string, event: WorkerEvent): DecisionAction {
216
256
  if (typeof action !== "string") throw new Error("missing action");
217
257
  const allowed = new Set(["continue", "redirect", "answer", "allow_permission", "deny_permission", "verify", "retry", "stop", "ask_human", "noop"]);
218
258
  if (!allowed.has(action)) throw new Error(`unsupported action: ${action}`);
219
- const reason = typeof value.reason === "string" && value.reason.trim() ? value.reason : "no reason provided";
220
- const confidence = typeof value.confidence === "number" ? value.confidence : undefined;
259
+ const reason = typeof value.reason === "string" && value.reason.trim() ? boundedDecisionText(value.reason, "reason") : "no reason provided";
260
+ const confidence = value.confidence === undefined ? undefined : typeof value.confidence === "number" && Number.isFinite(value.confidence) && value.confidence >= 0 && value.confidence <= 1
261
+ ? value.confidence
262
+ : (() => { throw new Error("confidence must be a finite number between 0 and 1"); })();
221
263
  if (["continue", "redirect", "answer"].includes(action)) {
222
264
  if (typeof value.message !== "string" || !value.message.trim()) throw new Error("message required");
223
- return { action: action as "continue" | "redirect" | "answer", message: value.message, reason, confidence };
265
+ return { action: action as "continue" | "redirect" | "answer", message: boundedDecisionText(value.message, "message"), reason, confidence };
224
266
  }
225
267
  if (["allow_permission", "deny_permission"].includes(action)) {
226
268
  const permission = event.type === "permission_request" ? event.request : undefined;
@@ -229,12 +271,17 @@ function parseDecision(text: string, event: WorkerEvent): DecisionAction {
229
271
  if (!requestId || !toolUseId) throw new Error("permission requestId/toolUseId required");
230
272
  return { action: action as "allow_permission" | "deny_permission", requestId, toolUseId, reason, confidence };
231
273
  }
232
- return { action: action as "verify" | "retry" | "stop" | "ask_human" | "noop", reason, question: typeof value.question === "string" ? value.question : undefined, confidence };
274
+ return { action: action as "verify" | "retry" | "stop" | "ask_human" | "noop", reason, question: typeof value.question === "string" ? boundedDecisionText(value.question, "question") : undefined, confidence };
233
275
  } catch (error) {
234
276
  return { action: "ask_human", reason: `invalid Decision Worker action: ${error instanceof Error ? error.message : String(error)}` };
235
277
  }
236
278
  }
237
279
 
280
+ function boundedDecisionText(value: string, field: string): string {
281
+ if (Buffer.byteLength(value, "utf8") > MAX_DECISION_FIELD_BYTES) throw new Error(`${field} exceeds ${MAX_DECISION_FIELD_BYTES} bytes`);
282
+ return value;
283
+ }
284
+
238
285
  function eventKey(event: WorkerEvent): string {
239
286
  if (event.type === "permission_request") return `${event.handle.id}:permission:${event.request.requestId}`;
240
287
  if (event.type === "turn_completed") return `${event.handle.id}:result:${event.sequence}`;
package/src/index.ts CHANGED
@@ -2,12 +2,13 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
+ import { accessSync, chmodSync, constants as fsConstants, lstatSync, mkdirSync } from "node:fs";
5
6
  import { readFile, realpath } from "node:fs/promises";
6
7
  import { EventLog } from "./events.ts";
7
8
  import { redactSensitive } from "./redaction.ts";
8
9
  import { ProcessWorkerAdapter } from "./worker/process-adapter.ts";
9
10
  import { TmuxWorkerAdapter, attachCommand } from "./worker/tmux-adapter.ts";
10
- import { Supervisor, type DecisionSessionClosedInfo } from "./supervisor.ts";
11
+ import { Supervisor, type DecisionSessionClosedInfo, type SupervisorProgress } from "./supervisor.ts";
11
12
  import { evaluateCommand } from "./policy.ts";
12
13
  import { HumanWebhookNotifier } from "./notifications.ts";
13
14
  import { loadSupervisorEnvironment } from "./config.ts";
@@ -40,6 +41,9 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
40
41
  if (transport === "tmux" && cgroupMode === "required") {
41
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
43
  }
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");
46
+ }
43
47
  const adapter = transport === "tmux"
44
48
  ? new TmuxWorkerAdapter({ stateDir })
45
49
  : new ProcessWorkerAdapter({
@@ -53,9 +57,12 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
53
57
  format: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_FORMAT === "wecom" ? "wecom" : "generic",
54
58
  secret: process.env.PI_CLAUDE_SUPERVISOR_HUMAN_WEBHOOK_SECRET,
55
59
  });
60
+ const leaseDir = process.env.PI_CLAUDE_SUPERVISOR_CWD_LEASE_DIR ?? join(homedir(), ".pi", "agent", "claude-supervisor", "cwd-leases");
61
+ assertRuntimeDirectory(stateDir, "state");
62
+ assertRuntimeDirectory(leaseDir, "lease");
56
63
  const events = new EventLog(join(stateDir, "events.jsonl"));
57
64
  const decisionStore = new DecisionSessionStore(join(stateDir, "decision-sessions"));
58
- const cwdLeaseStore = new CwdLeaseStore(process.env.PI_CLAUDE_SUPERVISOR_CWD_LEASE_DIR ?? join(homedir(), ".pi", "agent", "claude-supervisor", "cwd-leases"));
65
+ const cwdLeaseStore = new CwdLeaseStore(leaseDir);
59
66
  const reviewer = automation ? new PiReadOnlyReviewer() : undefined;
60
67
  const sessions = new Map<string, Supervisor>();
61
68
  const cwdLeases = new Map<string, CwdLeaseHandle>();
@@ -83,6 +90,9 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
83
90
  const notify = (ctx: ExtensionContext, message: string, type: "info" | "warning" = "info") => {
84
91
  if (ctx.hasUI) ctx.ui.notify(redactText(message), type);
85
92
  };
93
+ const progress = (ctx: ExtensionContext) => (info: SupervisorProgress) => {
94
+ notify(ctx, `Worker progress: task=${info.taskId} phase=${info.phase} ${info.message}`, info.phase === "human" || info.phase === "failed" ? "warning" : "info");
95
+ };
86
96
  const activeSessions = () => [...sessions.entries()].filter(([, session]) =>
87
97
  ["starting", "running", "waiting", "paused"].includes(session.state));
88
98
  const releaseLease = async (taskId: string): Promise<boolean> => {
@@ -275,6 +285,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
275
285
  try {
276
286
  const handle = await session.start({
277
287
  taskId,
288
+ onProgress: progress(ctx),
278
289
  task: goal,
279
290
  spec,
280
291
  cwd: cwdKey,
@@ -496,6 +507,7 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
496
507
  try {
497
508
  const handle = await session.start({
498
509
  taskId: record.taskId,
510
+ onProgress: progress(ctx),
499
511
  // Claude session resume is not supported by this adapter. Start
500
512
  // idle so recovery never replays the original task; the operator
501
513
  // must explicitly send the next instruction.
@@ -719,11 +731,9 @@ export default function piClaudeSupervisor(pi: ExtensionAPI): void {
719
731
  })();
720
732
  return shutdownPromise;
721
733
  };
722
- const onSignal = (signal: NodeJS.Signals) => {
723
- void shutdown().finally(() => process.exit(signal === "SIGTERM" ? 143 : 130));
724
- };
725
- process.once("SIGTERM", onSignal);
726
- process.once("SIGINT", onSignal);
734
+ // Pi owns process signal handling and invokes session_shutdown. Installing a
735
+ // second extension-level process.exit() handler races Pi's terminal restore and
736
+ // other extension shutdown hooks.
727
737
  pi.on("session_shutdown", async () => {
728
738
  await shutdown();
729
739
  });
@@ -796,6 +806,18 @@ function delay(ms: number): Promise<void> {
796
806
  return new Promise((resolve) => setTimeout(resolve, ms));
797
807
  }
798
808
 
809
+ function assertRuntimeDirectory(path: string, label: string): void {
810
+ try {
811
+ mkdirSync(path, { recursive: true, mode: 0o700 });
812
+ const info = lstatSync(path);
813
+ if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`${label} directory is not a real directory`);
814
+ chmodSync(path, 0o700);
815
+ accessSync(path, fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK);
816
+ } catch (error) {
817
+ throw new Error(`${label} directory preflight failed for ${path}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
818
+ }
819
+ }
820
+
799
821
  function redactText(value: string): string {
800
822
  return String(redactSensitive(value));
801
823
  }
package/src/redaction.ts CHANGED
@@ -10,8 +10,9 @@ export function redactSensitive(value: unknown, key?: string): unknown {
10
10
  .replace(/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/gu, "[REDACTED]")
11
11
  .replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[REDACTED]")
12
12
  .replace(/\b(Bearer\s+)[^\s]+/giu, "$1[REDACTED]")
13
+ .replace(/\b((?:authorization)\s*:\s*(?:[A-Za-z]+\s+)?)[^\s,;)}\]]+/giu, "$1[REDACTED]")
14
+ .replace(/\b((?:x-api-key|api[-_]?key|token|secret|password|credential)\s*[:=]\s*)[^\s,;)}\]]+/giu, "$1[REDACTED]")
13
15
  .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
16
  .replace(/(--?(?:token|api[-_]?key|secret|password|authorization)(?:=|\s+))[^\s]+/giu, "$1[REDACTED]");
16
17
  }
17
18
  if (Array.isArray(value)) return value.map((item) => redactSensitive(item, key));
package/src/reviewer.ts CHANGED
@@ -15,6 +15,8 @@ export interface ReviewInput {
15
15
  workerOutput?: string;
16
16
  workerResult?: Record<string, unknown>;
17
17
  round: number;
18
+ /** Abort a review when the operator stops or shuts down the Supervisor. */
19
+ signal?: AbortSignal;
18
20
  }
19
21
 
20
22
  export interface TaskReviewer {
@@ -38,6 +40,9 @@ export class PiReadOnlyReviewer implements TaskReviewer {
38
40
  }
39
41
 
40
42
  async review(input: ReviewInput): Promise<ReviewReport> {
43
+ if (input.evidence.complete === false || input.evidence.truncated === true) {
44
+ return invalidReview("repository evidence is incomplete or truncated", input.round, new Date().toISOString());
45
+ }
41
46
  const resourceLoader = new DefaultResourceLoader({
42
47
  cwd: input.cwd,
43
48
  agentDir: getAgentDir(),
@@ -55,20 +60,40 @@ export class PiReadOnlyReviewer implements TaskReviewer {
55
60
  thinkingLevel: "low",
56
61
  tools: ["read", "grep", "find", "ls"],
57
62
  });
58
- let text = "";
59
- let responseTooLarge = false;
63
+ let current = "";
64
+ let finalMessage = "";
65
+ let capturingAssistant = false;
66
+ let currentTooLarge = false;
67
+ let finalTooLarge = false;
60
68
  const unsubscribe = session.subscribe((value) => {
61
- const record = value as unknown as { type?: string; assistantMessageEvent?: { type?: string; delta?: string } };
62
- if (record.type !== "message_update" || record.assistantMessageEvent?.type !== "text_delta" || responseTooLarge) return;
63
- const delta = record.assistantMessageEvent.delta ?? "";
64
- if (Buffer.byteLength(text, "utf8") + Buffer.byteLength(delta, "utf8") > MAX_REVIEW_RESPONSE_BYTES) {
65
- responseTooLarge = true;
69
+ const record = value as unknown as { type?: string; message?: { role?: string; content?: unknown }; assistantMessageEvent?: { type?: string; delta?: string } };
70
+ const role = record.message?.role;
71
+ if (record.type === "message_start" && role === "assistant") {
72
+ current = "";
73
+ currentTooLarge = false;
74
+ capturingAssistant = true;
75
+ return;
76
+ }
77
+ if (record.type === "message_update" && record.assistantMessageEvent?.type === "text_delta" && (capturingAssistant || role === "assistant" || role === undefined)) {
78
+ capturingAssistant = true;
79
+ const delta = record.assistantMessageEvent.delta ?? "";
80
+ if (Buffer.byteLength(current, "utf8") + Buffer.byteLength(delta, "utf8") > MAX_REVIEW_RESPONSE_BYTES) {
81
+ currentTooLarge = true;
82
+ return;
83
+ }
84
+ current += delta;
66
85
  return;
67
86
  }
68
- text += delta;
87
+ if (record.type === "message_end" && role === "assistant") {
88
+ finalMessage = current || textFromMessage(record.message?.content);
89
+ finalTooLarge = currentTooLarge;
90
+ current = "";
91
+ currentTooLarge = false;
92
+ capturingAssistant = false;
93
+ }
69
94
  });
70
95
  try {
71
- await withTimeout(session.prompt(reviewPrompt(input)), this.#timeoutMs, "independent Reviewer");
96
+ await withTimeout(session.prompt(reviewPrompt(input)), this.#timeoutMs, "independent Reviewer", input.signal);
72
97
  } catch (error) {
73
98
  await session.abort().catch(() => {});
74
99
  throw error;
@@ -76,9 +101,9 @@ export class PiReadOnlyReviewer implements TaskReviewer {
76
101
  unsubscribe();
77
102
  session.dispose();
78
103
  }
79
- return responseTooLarge
104
+ return finalTooLarge
80
105
  ? invalidReview(`Reviewer response exceeded ${MAX_REVIEW_RESPONSE_BYTES} bytes`, input.round, new Date().toISOString())
81
- : parseReview(text, input.round);
106
+ : parseReview(finalMessage || current, input.round);
82
107
  }
83
108
  }
84
109
 
@@ -103,10 +128,17 @@ ACCEPTANCE REPORT:
103
128
  ${boundedJson(input.acceptance)}
104
129
 
105
130
  REPOSITORY STATUS:
106
- ${boundText(input.evidence.status, 8_000)}
131
+ ${boundText(redactText(input.evidence.status), 8_000)}
132
+
133
+ REPOSITORY DIFF (HEAD-RELATIVE, UNTRUSTED):
134
+ ${boundText(redactText(input.evidence.diff), 16_000)}
107
135
 
108
- REPOSITORY DIFF (UNTRUSTED):
109
- ${boundText(input.evidence.diff, 16_000)}
136
+ UNTRACKED FILE EVIDENCE (UNTRUSTED):
137
+ ${boundText(redactText(input.evidence.untracked ?? "(none)"), 16_000)}
138
+
139
+ EVIDENCE COMPLETE:
140
+ ${String(input.evidence.complete !== false && input.evidence.truncated !== true)}
141
+ If repository evidence is incomplete or truncated, do not return pass; return human and explain which evidence is unavailable.
110
142
 
111
143
  WORKER OUTPUT (UNTRUSTED):
112
144
  ${boundTailText(redactSensitive(input.workerOutput ?? "(none)"), 8_000)}
@@ -118,6 +150,15 @@ REVIEW ROUND:
118
150
  ${input.round}`;
119
151
  }
120
152
 
153
+ function textFromMessage(content: unknown): string {
154
+ if (!Array.isArray(content)) return "";
155
+ return content
156
+ .filter((block): block is { type?: unknown; text?: unknown } => Boolean(block && typeof block === "object"))
157
+ .filter((block) => block.type === "text" && typeof block.text === "string")
158
+ .map((block) => block.text as string)
159
+ .join("");
160
+ }
161
+
121
162
  export function parseReview(text: string, round: number): ReviewReport {
122
163
  const checkedAt = new Date().toISOString();
123
164
  if (Buffer.byteLength(text, "utf8") > MAX_REVIEW_RESPONSE_BYTES) return invalidReview(`Reviewer response exceeded ${MAX_REVIEW_RESPONSE_BYTES} bytes`, round, checkedAt);
@@ -179,16 +220,28 @@ function redactText(value: string): string {
179
220
  return String(redactSensitive(value));
180
221
  }
181
222
 
182
- async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
223
+ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string, signal?: AbortSignal): Promise<T> {
183
224
  let timer: NodeJS.Timeout | undefined;
225
+ let onAbort: (() => void) | undefined;
184
226
  const timeout = new Promise<never>((_, reject) => {
185
227
  timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
186
228
  timer.unref();
187
229
  });
230
+ const aborted = new Promise<never>((_, reject) => {
231
+ if (!signal) return;
232
+ onAbort = () => {
233
+ const error = new Error(`${label} aborted`);
234
+ error.name = "AbortError";
235
+ reject(error);
236
+ };
237
+ if (signal.aborted) onAbort();
238
+ else signal.addEventListener("abort", onAbort, { once: true });
239
+ });
188
240
  try {
189
- return await Promise.race([promise, timeout]);
241
+ return await Promise.race([promise, timeout, aborted]);
190
242
  } finally {
191
243
  if (timer) clearTimeout(timer);
244
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
192
245
  }
193
246
  }
194
247