pi-claude-supervisor 0.5.0 → 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.
package/src/types.ts CHANGED
@@ -104,6 +104,8 @@ export interface PermissionDecision {
104
104
 
105
105
  export interface WorkerAdapter {
106
106
  capabilities(): WorkerCapabilities;
107
+ /** Validate host/transport prerequisites before starting a Worker. */
108
+ preflight?(input: Pick<WorkerStartInput, "cwd" | "command" | "args" | "env" | "approval">): Promise<void>;
107
109
  start(input: WorkerStartInput): Promise<WorkerHandle>;
108
110
  /** Cancel adapter-owned startup work before a WorkerHandle is returned. */
109
111
  abortStart?(reason: string, startupToken?: string): Promise<void>;
@@ -137,8 +139,10 @@ export interface WorkerCapabilities {
137
139
  pause: boolean;
138
140
  resumeSession: boolean;
139
141
  processGroupControl: boolean;
140
- /** The worker can remain alive while Pi disconnects from it. */
142
+ /** The worker can remain alive while Pi disconnects from it and be explicitly re-adopted. */
141
143
  persistentSession?: boolean;
144
+ /** The current Supervisor can send another bounded turn after a result. */
145
+ repairableSession?: boolean;
142
146
  }
143
147
 
144
148
  export interface AcceptanceCheck {
@@ -150,7 +154,7 @@ export interface AcceptanceCheck {
150
154
  timeoutMs: number;
151
155
  }
152
156
 
153
- export type AcceptanceCheckStatus = "passed" | "failed" | "timed_out" | "blocked";
157
+ export type AcceptanceCheckStatus = "passed" | "failed" | "timed_out" | "blocked" | "cancelled";
154
158
 
155
159
  export interface AcceptanceCheckResult {
156
160
  check: AcceptanceCheck;
package/src/verifier.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { lstat, open, realpath } from "node:fs/promises";
4
+ import { isAbsolute, relative, resolve, sep } from "node:path";
2
5
  import { promisify } from "node:util";
3
6
  import type { AcceptanceCheck, AcceptanceCheckResult, AcceptanceReport, VerificationResult } from "./types.ts";
4
7
  import { assertSafeWorkerCommand } from "./policy.ts";
@@ -6,15 +9,37 @@ import { workerEnvironment } from "./worker/environment.ts";
6
9
 
7
10
  const execFileAsync = promisify(execFile);
8
11
  const MAX_OUTPUT_BYTES = 256 * 1024;
12
+ const MAX_EXEC_BUFFER_BYTES = 8 * 1024 * 1024;
13
+ const MAX_UNTRACKED_FILE_BYTES = 64 * 1024;
14
+ const MAX_UNTRACKED_FILES = 128;
15
+
16
+ function throwIfAborted(signal?: AbortSignal): void {
17
+ if (!signal?.aborted) return;
18
+ const error = new Error("verification aborted");
19
+ error.name = "AbortError";
20
+ throw error;
21
+ }
9
22
 
10
23
  export interface VerificationCommand {
11
24
  command: string;
12
25
  args?: string[];
13
26
  }
14
27
 
28
+ export interface VerificationOptions {
29
+ signal?: AbortSignal;
30
+ onCheck?: (info: { check: AcceptanceCheck; result: AcceptanceCheckResult; index: number; total: number }) => void | Promise<void>;
31
+ }
32
+
15
33
  export interface RepositoryEvidence {
16
34
  status: string;
35
+ /** HEAD-relative diff for tracked staged and unstaged changes. */
17
36
  diff: string;
37
+ /** Bounded content for untracked regular files. */
38
+ untracked?: string;
39
+ /** False when a command/file could not be safely or completely collected. */
40
+ complete?: boolean;
41
+ /** True when any evidence field was bounded or omitted. */
42
+ truncated?: boolean;
18
43
  collectedAt: string;
19
44
  }
20
45
 
@@ -22,6 +47,7 @@ export async function verify(
22
47
  cwd: string,
23
48
  command: VerificationCommand = { command: "git", args: ["diff", "--check"] },
24
49
  timeoutMs = 120_000,
50
+ options: VerificationOptions = {},
25
51
  ): Promise<VerificationResult> {
26
52
  assertSafeWorkerCommand(command.command, command.args);
27
53
  const check: AcceptanceCheck = {
@@ -32,9 +58,9 @@ export async function verify(
32
58
  required: true,
33
59
  timeoutMs,
34
60
  };
35
- const result = await runCheck(cwd, check);
61
+ const result = await runCheck(cwd, check, options.signal);
36
62
  return {
37
- ok: result.ok,
63
+ ok: result.ok && !options.signal?.aborted,
38
64
  command: [check.command, ...check.args].join(" "),
39
65
  exitCode: result.exitCode,
40
66
  output: result.output,
@@ -42,39 +68,58 @@ export async function verify(
42
68
  };
43
69
  }
44
70
 
45
- export async function verifyAll(cwd: string, checks: readonly AcceptanceCheck[]): Promise<AcceptanceReport> {
71
+ export async function verifyAll(cwd: string, checks: readonly AcceptanceCheck[], options: VerificationOptions = {}): Promise<AcceptanceReport> {
46
72
  if (checks.length === 0) throw new Error("at least one acceptance check is required");
47
73
  const results: AcceptanceCheckResult[] = [];
48
- for (const check of checks) results.push(await runCheck(cwd, check));
49
- const ok = results.every((result) => !result.check.required || result.ok);
74
+ for (const [index, check] of checks.entries()) {
75
+ const result = await runCheck(cwd, check, options.signal);
76
+ results.push(result);
77
+ await options.onCheck?.({ check, result, index, total: checks.length });
78
+ if (options.signal?.aborted) break;
79
+ }
80
+ const cancelled = options.signal?.aborted === true;
81
+ const ok = !cancelled && results.every((result) => !result.check.required || result.ok);
50
82
  const firstFailure = results.find((result) => result.check.required && !result.ok);
51
83
  return {
52
84
  ok,
53
85
  command: "acceptance checks",
54
- exitCode: firstFailure?.exitCode ?? 0,
55
- output: boundOutput(results.map(formatCheckResult).join("\n\n")),
86
+ exitCode: firstFailure?.exitCode ?? (cancelled ? 1 : 0),
87
+ output: boundOutput(`${results.map(formatCheckResult).join("\n\n")}${cancelled ? "\n\n[verification cancelled]" : ""}`),
56
88
  checkedAt: new Date().toISOString(),
57
89
  checks: results,
58
90
  };
59
91
  }
60
92
 
61
93
  /** Collect bounded repository evidence for the independent read-only Reviewer. */
62
- export async function collectRepositoryEvidence(cwd: string): Promise<RepositoryEvidence> {
63
- const [status, diff] = await Promise.all([
64
- readGitEvidence(cwd, ["status", "--short", "--untracked-files=all"]),
65
- readGitEvidence(cwd, ["diff", "--no-ext-diff", "--unified=3"]),
94
+ export async function collectRepositoryEvidence(cwd: string, options: Pick<VerificationOptions, "signal"> = {}): Promise<RepositoryEvidence> {
95
+ throwIfAborted(options.signal);
96
+ const [statusResult, diffResult, untrackedResult] = await Promise.all([
97
+ readGitEvidence(cwd, ["status", "--short", "--untracked-files=all"], options.signal),
98
+ // HEAD-relative diff includes both staged and unstaged changes for tracked files.
99
+ readGitEvidence(cwd, ["diff", "--no-ext-diff", "--unified=3", "HEAD", "--"], options.signal),
100
+ collectUntrackedEvidence(cwd, options.signal),
66
101
  ]);
67
- return { status, diff, collectedAt: new Date().toISOString() };
102
+ throwIfAborted(options.signal);
103
+ return {
104
+ status: statusResult.text,
105
+ diff: diffResult.text,
106
+ untracked: untrackedResult.text,
107
+ complete: statusResult.complete && diffResult.complete && untrackedResult.complete,
108
+ truncated: statusResult.truncated || diffResult.truncated || untrackedResult.truncated,
109
+ collectedAt: new Date().toISOString(),
110
+ };
68
111
  }
69
112
 
70
- async function runCheck(cwd: string, check: AcceptanceCheck): Promise<AcceptanceCheckResult> {
113
+ async function runCheck(cwd: string, check: AcceptanceCheck, signal?: AbortSignal): Promise<AcceptanceCheckResult> {
114
+ throwIfAborted(signal);
71
115
  assertSafeWorkerCommand(check.command, check.args);
72
116
  const startedAt = new Date().toISOString();
73
117
  try {
74
118
  const result = await execFileAsync(check.command, check.args, {
75
119
  cwd,
76
120
  timeout: check.timeoutMs,
77
- maxBuffer: MAX_OUTPUT_BYTES,
121
+ maxBuffer: MAX_EXEC_BUFFER_BYTES,
122
+ signal,
78
123
  env: workerEnvironment(process.env, { GIT_TERMINAL_PROMPT: "0" }),
79
124
  });
80
125
  const finishedAt = new Date().toISOString();
@@ -88,13 +133,14 @@ async function runCheck(cwd: string, check: AcceptanceCheck): Promise<Acceptance
88
133
  finishedAt,
89
134
  };
90
135
  } catch (error) {
91
- const failure = error as { code?: number | string; signal?: string; stdout?: string; stderr?: string; message?: string; killed?: boolean };
136
+ const failure = error as { code?: number | string; signal?: string; stdout?: string; stderr?: string; message?: string; killed?: boolean; name?: string };
92
137
  const finishedAt = new Date().toISOString();
93
- const timedOut = failure.killed === true || failure.signal === "SIGTERM" || failure.code === "ETIMEDOUT";
138
+ const cancelled = signal?.aborted === true || failure.name === "AbortError" || failure.code === "ABORT_ERR";
139
+ const timedOut = !cancelled && (failure.killed === true || failure.signal === "SIGTERM" || failure.code === "ETIMEDOUT");
94
140
  const exitCode = typeof failure.code === "number" ? failure.code : 1;
95
141
  return {
96
142
  check,
97
- status: timedOut ? "timed_out" : "failed",
143
+ status: cancelled ? "cancelled" : timedOut ? "timed_out" : "failed",
98
144
  ok: false,
99
145
  exitCode,
100
146
  output: boundOutput(`${failure.stdout ?? ""}${failure.stderr ?? ""}${failure.message ?? ""}`),
@@ -104,18 +150,119 @@ async function runCheck(cwd: string, check: AcceptanceCheck): Promise<Acceptance
104
150
  }
105
151
  }
106
152
 
107
- async function readGitEvidence(cwd: string, args: string[]): Promise<string> {
153
+ interface EvidencePart {
154
+ text: string;
155
+ complete: boolean;
156
+ truncated: boolean;
157
+ }
158
+
159
+ async function readGitEvidence(cwd: string, args: string[], signal?: AbortSignal): Promise<EvidencePart> {
160
+ throwIfAborted(signal);
108
161
  try {
109
162
  const result = await execFileAsync("git", args, {
110
163
  cwd,
111
164
  timeout: 30_000,
112
165
  maxBuffer: MAX_OUTPUT_BYTES,
166
+ signal,
113
167
  env: workerEnvironment(process.env, { GIT_TERMINAL_PROMPT: "0" }),
114
168
  });
115
- return boundOutput(`${result.stdout}${result.stderr}`) || "(none)";
169
+ const bounded = boundEvidence(`${result.stdout}${result.stderr}`);
170
+ return { ...bounded, text: bounded.text || "(none)" };
116
171
  } catch (error) {
117
172
  const failure = error as { stdout?: string; stderr?: string; message?: string };
118
- return boundOutput(`${failure.stdout ?? ""}${failure.stderr ?? ""}${failure.message ?? "git evidence unavailable"}`);
173
+ const bounded = boundEvidence(`${failure.stdout ?? ""}${failure.stderr ?? ""}${failure.message ?? "git evidence unavailable"}`);
174
+ return { ...bounded, complete: false };
175
+ }
176
+ }
177
+
178
+ async function collectUntrackedEvidence(cwd: string, signal?: AbortSignal): Promise<EvidencePart> {
179
+ throwIfAborted(signal);
180
+ let output: string;
181
+ let root: string;
182
+ try {
183
+ root = await realpath(cwd);
184
+ } catch (error) {
185
+ return { text: `[UNTRACKED EVIDENCE UNAVAILABLE] ${error instanceof Error ? error.message : String(error)}`, complete: false, truncated: false };
186
+ }
187
+ try {
188
+ const result = await execFileAsync("git", ["ls-files", "--others", "--exclude-standard", "-z"], {
189
+ cwd,
190
+ timeout: 30_000,
191
+ maxBuffer: MAX_OUTPUT_BYTES,
192
+ signal,
193
+ env: workerEnvironment(process.env, { GIT_TERMINAL_PROMPT: "0" }),
194
+ });
195
+ output = result.stdout;
196
+ throwIfAborted(signal);
197
+ } catch (error) {
198
+ const failure = error as { stdout?: string; stderr?: string; message?: string };
199
+ return { text: `[UNTRACKED EVIDENCE UNAVAILABLE] ${failure.stderr ?? failure.message ?? "git ls-files failed"}`, complete: false, truncated: false };
200
+ }
201
+ const paths = output.split("\0").filter(Boolean);
202
+ if (paths.length === 0) return { text: "(none)", complete: true, truncated: false };
203
+ let complete = paths.length <= MAX_UNTRACKED_FILES;
204
+ let truncated = false;
205
+ const sections: string[] = [];
206
+ for (const path of paths.slice(0, MAX_UNTRACKED_FILES)) {
207
+ const fullPath = resolve(root, path);
208
+ const relativePath = relative(root, fullPath);
209
+ if (isAbsolute(relativePath) || relativePath === ".." || relativePath.startsWith(`..${"/"}`) || relativePath.startsWith(`..${"\\"}`)) {
210
+ complete = false;
211
+ sections.push(`--- ${JSON.stringify(path)} [unsafe path omitted]`);
212
+ continue;
213
+ }
214
+ let handle: Awaited<ReturnType<typeof open>> | undefined;
215
+ try {
216
+ await assertNoSymlinkComponents(root, relativePath);
217
+ const info = await lstat(fullPath);
218
+ if (info.isSymbolicLink() || !info.isFile()) {
219
+ complete = false;
220
+ sections.push(`--- ${JSON.stringify(path)} [non-regular file omitted]`);
221
+ continue;
222
+ }
223
+ const noFollow = typeof fsConstants.O_NOFOLLOW === "number" ? fsConstants.O_NOFOLLOW : 0;
224
+ handle = await open(fullPath, fsConstants.O_RDONLY | noFollow);
225
+ const opened = await handle.stat();
226
+ if (!opened.isFile()) {
227
+ complete = false;
228
+ sections.push(`--- ${JSON.stringify(path)} [non-regular file omitted]`);
229
+ continue;
230
+ }
231
+ const buffer = Buffer.alloc(MAX_UNTRACKED_FILE_BYTES + 1);
232
+ const read = await handle.read(buffer, 0, buffer.length, 0);
233
+ const bytes = buffer.subarray(0, read.bytesRead);
234
+ if (read.bytesRead > MAX_UNTRACKED_FILE_BYTES) {
235
+ complete = false;
236
+ truncated = true;
237
+ }
238
+ if (bytes.includes(0)) {
239
+ complete = false;
240
+ sections.push(`--- ${JSON.stringify(path)} [binary file omitted]`);
241
+ } else {
242
+ sections.push(`--- ${JSON.stringify(path)}${read.bytesRead > MAX_UNTRACKED_FILE_BYTES ? " [TRUNCATED]" : ""}\n${bytes.toString("utf8")}`);
243
+ }
244
+ } catch (error) {
245
+ complete = false;
246
+ sections.push(`--- ${JSON.stringify(path)} [read failed: ${error instanceof Error ? error.message : String(error)}]`);
247
+ } finally {
248
+ await handle?.close().catch(() => {});
249
+ }
250
+ }
251
+ if (paths.length > MAX_UNTRACKED_FILES) {
252
+ truncated = true;
253
+ sections.push(`[TRUNCATED: ${paths.length - MAX_UNTRACKED_FILES} untracked paths omitted]`);
254
+ }
255
+ const bounded = boundEvidence(sections.join("\n"));
256
+ return { ...bounded, complete: complete && bounded.complete, truncated: truncated || bounded.truncated };
257
+ }
258
+
259
+ async function assertNoSymlinkComponents(root: string, relativePath: string): Promise<void> {
260
+ const parts = relativePath.split(sep).filter(Boolean);
261
+ let current = root;
262
+ for (const part of parts) {
263
+ current = resolve(current, part);
264
+ const info = await lstat(current);
265
+ if (info.isSymbolicLink()) throw new Error("symlink path component is not allowed in repository evidence");
119
266
  }
120
267
  }
121
268
 
@@ -123,7 +270,18 @@ function formatCheckResult(result: AcceptanceCheckResult): string {
123
270
  return `[${result.status}] ${result.check.id}: ${result.check.command} ${result.check.args.join(" ")}\n${result.output || "(no output)"}`;
124
271
  }
125
272
 
273
+ function boundEvidence(value: string): EvidencePart {
274
+ const encoded = Buffer.from(value, "utf8");
275
+ if (encoded.byteLength <= MAX_OUTPUT_BYTES) return { text: value, complete: true, truncated: false };
276
+ const marker = Buffer.from("\n[TRUNCATED]", "utf8");
277
+ const suffix = encoded.subarray(-Math.max(0, MAX_OUTPUT_BYTES - marker.byteLength));
278
+ return { text: `${suffix.toString("utf8")}${marker.toString("utf8")}`, complete: false, truncated: true };
279
+ }
280
+
126
281
  function boundOutput(value: string): string {
127
- if (Buffer.byteLength(value, "utf8") <= MAX_OUTPUT_BYTES) return value;
128
- return `${Buffer.from(value, "utf8").subarray(-MAX_OUTPUT_BYTES).toString("utf8")}\n[TRUNCATED]`;
282
+ const encoded = Buffer.from(value, "utf8");
283
+ if (encoded.byteLength <= MAX_OUTPUT_BYTES) return value;
284
+ const marker = Buffer.from("\n[TRUNCATED]", "utf8");
285
+ const suffix = encoded.subarray(-Math.max(0, MAX_OUTPUT_BYTES - marker.byteLength));
286
+ return `${suffix.toString("utf8")}${marker.toString("utf8")}`;
129
287
  }