@a2f0/agent-tool 0.1.44 → 0.1.46

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2f0/agent-tool",
3
- "version": "0.1.44",
3
+ "version": "0.1.46",
4
4
  "packageManager": "bun@1.4.4",
5
5
  "type": "module",
6
6
  "description": "Portable cross-agent review, PR workflows, and shared skills",
@@ -1,8 +1,9 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, writeFileSync } from "node:fs";
4
+ import { constants } from "node:os";
4
5
  import path from "node:path";
5
- import { runCommand, type RunReceipt } from "./run";
6
+ import { FORWARDED_SIGNALS, runCommand, type RunReceipt } from "./run";
6
7
  import { assert, git, head, oid, option, output } from "../workflows/core";
7
8
  import { gitEnvironment } from "../git/environment";
8
9
 
@@ -60,6 +61,7 @@ export type GroupEvent =
60
61
  | { type: "stepStarted"; index: number }
61
62
  | { type: "stepFinished"; index: number; receipt: RunReceipt }
62
63
  | { type: "failed"; error: string }
64
+ | { type: "interrupted"; signal: NodeJS.Signals }
63
65
  | { type: "finished"; passed: boolean; endedAt: string; durationMs: number }
64
66
  | { type: "settled"; after: SourceState }
65
67
  | { type: "settleFailed"; error: string };
@@ -69,7 +71,11 @@ export type GroupEvent =
69
71
  * ignored: a step starts only from pending, finishes only while running, and
70
72
  * nothing but the final source read changes a finished group. A failure while
71
73
  * a step runs (its runner could not start) finishes that step as failed with
72
- * no receipt, so a finished report never shows a step still running.
74
+ * no receipt, so a finished report never shows a step still running. An
75
+ * interruption counts only while the group runs: it records its signal as the
76
+ * error (unless one is already recorded), lets the running step finish with its
77
+ * receipt, and keeps the group from passing. A finished group ignores it, so
78
+ * its report and exit status stand.
73
79
  */
74
80
  export function groupTransition(record: GroupRecord, event: GroupEvent): GroupRecord {
75
81
  const step = (index: number, next: GroupStep) => ({ ...record, steps: record.steps.map((item, at) => at === index ? next : item) });
@@ -85,8 +91,10 @@ export function groupTransition(record: GroupRecord, event: GroupEvent): GroupRe
85
91
  case "failed":
86
92
  if (record.status !== "running") return record;
87
93
  return { ...record, steps: record.steps.map(item => item.status === "running" ? { label: item.label, status: "failed", receipt: null } : item), error: event.error };
94
+ case "interrupted":
95
+ return record.status === "running" && record.error === null ? { ...record, error: `Interrupted by ${event.signal}.` } : record;
88
96
  case "finished":
89
- return record.status === "running" ? { ...record, status: event.passed ? "passed" : "failed", endedAt: event.endedAt, durationMs: event.durationMs } : record;
97
+ return record.status === "running" ? { ...record, status: event.passed && record.error === null ? "passed" : "failed", endedAt: event.endedAt, durationMs: event.durationMs } : record;
90
98
  case "settled":
91
99
  return record.status !== "running" && record.after === null ? { ...record, after: event.after } : record;
92
100
  case "settleFailed":
@@ -108,21 +116,53 @@ export async function runGroup(args: string[]): Promise<number> {
108
116
  const started = performance.now();
109
117
  let record: GroupRecord = { schemaVersion: 1, id, label: plan.label, startedAt: new Date().toISOString(), endedAt: null, durationMs: 0, status: "running", before, after: null, steps: plan.steps.map(step => ({ label: step.label, status: "pending", receipt: null })), error: null };
110
118
  const send = (event: GroupEvent) => { record = groupTransition(record, event); };
111
- save(report, record);
119
+ // A signal must not end the process before the report is finished: the step's runner forwards it
120
+ // to the step and returns, the group stops, and the signal is raised again once the report is saved.
121
+ // Only a running group is interrupted; a signal handled after it finished changes nothing.
122
+ const interrupt: { signal: NodeJS.Signals | null } = { signal: null };
123
+ const handlers = FORWARDED_SIGNALS.map(value => [value, () => {
124
+ if (record.status !== "running") return;
125
+ interrupt.signal ??= value;
126
+ send({ type: "interrupted", signal: value });
127
+ }] as const);
128
+ for (const [value, handler] of handlers) process.on(value, handler);
129
+ // Handlers run only between tasks, and a signal still queued when they are removed is lost.
130
+ // Fingerprinting and saving are synchronous, so yield to let a pending signal apply first.
131
+ const handleSignals = () => new Promise(resolve => setImmediate(resolve));
112
132
  let code = 0;
113
133
  try {
114
- for (const [index, step] of plan.steps.entries()) {
115
- send({ type: "stepStarted", index }); save(report, record);
116
- code = await runCommand(["--label", step.label, "--", ...step.command], receipt => { send({ type: "stepFinished", index, receipt }); save(report, record); }, { quiet: true, reraiseChildSignal: false });
117
- const after = sourceState();
118
- assert(JSON.stringify(after) === JSON.stringify(before), "Source state changed during validation; results do not establish the current checkout.");
119
- if (code !== 0) break;
120
- }
121
- } catch (error) { code = 1; send({ type: "failed", error: (error as Error).message }); }
122
- send({ type: "finished", passed: code === 0, endedAt: new Date().toISOString(), durationMs: Math.round(performance.now() - started) });
123
- try { send({ type: "settled", after: sourceState() }); } catch (error) { code = 1; send({ type: "settleFailed", error: (error as Error).message }); }
124
- save(report, record);
125
- output({ ...record, report }); return code;
134
+ save(report, record);
135
+ try {
136
+ for (const [index, step] of plan.steps.entries()) {
137
+ await handleSignals();
138
+ if (interrupt.signal) break;
139
+ send({ type: "stepStarted", index }); save(report, record);
140
+ code = await runCommand(["--label", step.label, "--", ...step.command], receipt => { send({ type: "stepFinished", index, receipt }); save(report, record); }, { quiet: true, reraiseChildSignal: false, reraiseInterrupt: false });
141
+ const after = sourceState();
142
+ assert(JSON.stringify(after) === JSON.stringify(before), "Source state changed during validation; results do not establish the current checkout.");
143
+ if (code !== 0) break;
144
+ }
145
+ } catch (error) { code = 1; send({ type: "failed", error: (error as Error).message }); }
146
+ const endedAt = new Date().toISOString(), durationMs = Math.round(performance.now() - started);
147
+ let settle: GroupEvent;
148
+ try { settle = { type: "settled", after: sourceState() }; } catch (error) { code = 1; settle = { type: "settleFailed", error: (error as Error).message }; }
149
+ await handleSignals();
150
+ send({ type: "finished", passed: code === 0, endedAt, durationMs }); send(settle);
151
+ save(report, record);
152
+ output({ ...record, report });
153
+ // Signals that arrived while publishing reach the handlers, which ignore them now.
154
+ await handleSignals();
155
+ } finally {
156
+ for (const [value, handler] of handlers) process.removeListener(value, handler);
157
+ }
158
+ const { signal } = interrupt;
159
+ if (signal) {
160
+ // Preserve signal termination for calling shells and supervisors, as `run` does.
161
+ process.kill(process.pid, signal);
162
+ await new Promise(resolve => setTimeout(resolve, 100));
163
+ return 128 + (constants.signals[signal] ?? 0);
164
+ }
165
+ return code;
126
166
  }
127
167
 
128
168
  export function listRuns(args: string[]): number {
@@ -4,12 +4,16 @@ import { constants } from "node:os";
4
4
  import { DEFAULT_LIMITS, outputLimit, plainText, printLogTail } from "./output";
5
5
  import { createLog } from "./logFile";
6
6
 
7
- const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
7
+ export const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
8
8
 
9
9
  export interface RunReceipt { status: string; label?: string; exitCode: number; signal: string | null; durationMs: number; outputBytes: number; log: string; leftoverProcessGroup?: number }
10
10
 
11
- /** Run directly into a private file: child output never accumulates in memory. */
12
- export async function runCommand(args: string[], record?: (receipt: RunReceipt) => void, options: { quiet?: boolean; reraiseChildSignal?: boolean } = {}): Promise<number> {
11
+ /**
12
+ * Run directly into a private file: child output never accumulates in memory.
13
+ * A caller that must record the interruption before exiting passes
14
+ * `reraiseInterrupt: false` and re-raises the signal itself.
15
+ */
16
+ export async function runCommand(args: string[], record?: (receipt: RunReceipt) => void, options: { quiet?: boolean; reraiseChildSignal?: boolean; reraiseInterrupt?: boolean } = {}): Promise<number> {
13
17
  const limits = { ...DEFAULT_LIMITS };
14
18
  let label: string | undefined;
15
19
  while (args[0] !== "--" && args.length) {
@@ -99,7 +103,7 @@ export async function runCommand(args: string[], record?: (receipt: RunReceipt)
99
103
  }
100
104
  // Preserve signal termination for calling shells/supervisors, after the receipt.
101
105
  if (signal) {
102
- if (interrupted || options.reraiseChildSignal !== false) {
106
+ if (interrupted ? options.reraiseInterrupt !== false : options.reraiseChildSignal !== false) {
103
107
  process.kill(process.pid, signal);
104
108
  // Let the runtime deliver its signal before main sets process.exitCode
105
109
  // can turn signal termination into an ordinary numeric exit under load.
package/src/harnesses.ts CHANGED
@@ -12,7 +12,7 @@ export interface Harness {
12
12
  skillDirectory: string;
13
13
  defaultEffort: ReviewEffort;
14
14
  accessNote: string;
15
- review: (prompt: string, effort: ReviewEffort, snapshotRoot: string) => number;
15
+ review: (prompt: string, effort: ReviewEffort, snapshotRoot: string) => Promise<number>;
16
16
  }
17
17
 
18
18
  /** Harness-specific discovery and invocation stay behind this small boundary. */
@@ -29,7 +29,7 @@ export function harnessNamed(name: string): Harness {
29
29
  }
30
30
 
31
31
  /** An explicit local base enables review without GitHub or network access. */
32
- export function review(rootDir: string, name: string, effortArg?: string, base?: string): number {
32
+ export function review(rootDir: string, name: string, effortArg?: string, base?: string): Promise<number> {
33
33
  const harness = harnessNamed(name);
34
34
  const effort = resolveReviewEffort(effortArg, harness.defaultEffort);
35
35
  const context: PrContext = base === undefined ? resolveReviewContext() : {
@@ -39,8 +39,8 @@ export function review(rootDir: string, name: string, effortArg?: string, base?:
39
39
  title: "",
40
40
  baseRef: run("git", ["rev-parse", "--verify", "--end-of-options", `${base}^{commit}`]),
41
41
  };
42
- return withPinnedReviewInput(rootDir, context, input => {
43
- const exitCode = harness.review(buildReviewPrompt({
42
+ return withPinnedReviewInput(rootDir, context, async input => {
43
+ const exitCode = await harness.review(buildReviewPrompt({
44
44
  context,
45
45
  diff: input.diff,
46
46
  reviewInstructions: input.reviewInstructions,
@@ -4,12 +4,12 @@ import { DEFAULT_LIMITS, printLogTail } from "../commands/output";
4
4
 
5
5
  /** Reviewer stderr is a transcript, not the review. Keep it off the model's
6
6
  * context while retaining complete local evidence and bounded failure details. */
7
- export function withReviewDiagnostics<T extends { status: number | null; error?: Error }>(
8
- reviewer: string, run: (fd: number) => T,
9
- ): T {
7
+ export async function withReviewDiagnostics<T extends { status: number | null; error?: Error }>(
8
+ reviewer: string, run: (fd: number) => T | Promise<T>,
9
+ ): Promise<T> {
10
10
  const { directory, log, fd } = createLog("agent-tool-review-log-");
11
11
  try {
12
- const result = run(fd);
12
+ const result = await run(fd);
13
13
  const outputBytes = fstatSync(fd).size;
14
14
  if (outputBytes > 0) {
15
15
  writeSync(2, `${JSON.stringify({ reviewer, outputBytes, log })}\n`);
@@ -3,6 +3,7 @@ import { execFileSync, spawnSync } from "node:child_process";
3
3
 
4
4
  import { type PrContext } from "../git/prContext";
5
5
  import { readReviewInstructions } from "./reviewPrompt";
6
+ import { withReviewInterrupts } from "./reviewerProcess";
6
7
  import { withReviewSnapshot } from "./reviewSnapshot";
7
8
 
8
9
  interface PinnedReviewInput {
@@ -61,12 +62,24 @@ function ensureChanges(
61
62
  }
62
63
  }
63
64
 
64
- /** Resolve refs once, then derive every review artifact from those commit IDs. */
65
+ /**
66
+ * Resolve refs once, then derive every review artifact from those commit IDs.
67
+ * An interruption stops the reviewer and removes the snapshot before the
68
+ * signal ends the process.
69
+ */
65
70
  export function withPinnedReviewInput<T>(
66
71
  rootDir: string,
67
72
  context: PrContext,
68
- callback: (input: PinnedReviewInput) => T,
69
- ): T {
73
+ callback: (input: PinnedReviewInput) => T | Promise<T>,
74
+ ): Promise<T> {
75
+ return withReviewInterrupts(() => pinnedReviewInput(rootDir, context, callback));
76
+ }
77
+
78
+ async function pinnedReviewInput<T>(
79
+ rootDir: string,
80
+ context: PrContext,
81
+ callback: (input: PinnedReviewInput) => T | Promise<T>,
82
+ ): Promise<T> {
70
83
  const baseCommit = resolveCommit(rootDir, context.baseRef);
71
84
  const headCommit = resolveCommit(rootDir, "HEAD");
72
85
  ensureChanges(rootDir, baseCommit, headCommit);
@@ -48,11 +48,11 @@ function removeSnapshot(tempRoot: string, repositoryRoot: string): void {
48
48
  * Run a callback against immutable raw blobs from an exact commit.
49
49
  * Working-tree edits, untracked files, and `.git` metadata never enter it.
50
50
  */
51
- export function withReviewSnapshot<T>(
51
+ export async function withReviewSnapshot<T>(
52
52
  rootDir: string,
53
53
  commit: string,
54
- callback: (repositoryRoot: string) => T,
55
- ): T {
54
+ callback: (repositoryRoot: string) => T | Promise<T>,
55
+ ): Promise<T> {
56
56
  const tempRoot = mkdtempSync(path.join(tmpdir(), "agent-tool-review-"));
57
57
  const repositoryRoot = path.join(tempRoot, "repository");
58
58
  mkdirSync(repositoryRoot);
@@ -60,7 +60,7 @@ export function withReviewSnapshot<T>(
60
60
  try {
61
61
  materializeRawGitTree(rootDir, commit, repositoryRoot);
62
62
  makeTreeReadOnly(repositoryRoot);
63
- return callback(repositoryRoot);
63
+ return await callback(repositoryRoot);
64
64
  } finally {
65
65
  removeSnapshot(tempRoot, repositoryRoot);
66
66
  }
@@ -0,0 +1,162 @@
1
+ import { type ChildProcess, spawn } from "node:child_process";
2
+
3
+ import { FORWARDED_SIGNALS } from "../commands/run";
4
+ import type { ReviewerEnv } from "./runReview";
5
+
6
+ /** How long a reviewer asked to stop may take before it is killed. */
7
+ export const STOP_GRACE_MS = 5000;
8
+ /** How long to wait for a reviewer's stdout to close after it exits, in case a descendant holds it. */
9
+ const CLOSE_GRACE_MS = 2000;
10
+
11
+ /** One reviewer run, shaped like `spawnSync`'s result so `spawnExitCode` reads either. */
12
+ export interface ReviewerResult {
13
+ readonly status: number | null;
14
+ readonly signal: NodeJS.Signals | null;
15
+ readonly stdout: string;
16
+ readonly error?: Error;
17
+ }
18
+
19
+ /** A started reviewer, as the interruption handler sees it. */
20
+ interface Reviewer { stop(signal: NodeJS.Signals): void }
21
+
22
+ interface ReviewScope {
23
+ signal: NodeJS.Signals | null;
24
+ readonly reviewers: Set<Reviewer>;
25
+ }
26
+
27
+ let scope: ReviewScope | null = null;
28
+
29
+ /** Signal handlers run only between tasks; let one that is already pending run first. */
30
+ const handlePendingSignals = () => new Promise(resolve => setImmediate(resolve));
31
+ const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
32
+
33
+ // Each reviewer leads its own process group, so stopping it reaches every descendant that stays
34
+ // in the group (a CLI wrapper's native binary, a shell's children), as `agent-tool run` does.
35
+ const grouped = process.platform !== "win32";
36
+
37
+ function signalGroup(child: ChildProcess, signal: NodeJS.Signals | 0): boolean {
38
+ if (!child.pid) return false;
39
+ try {
40
+ if (grouped) process.kill(-child.pid, signal);
41
+ else if (signal) child.kill(signal);
42
+ else return child.exitCode === null && child.signalCode === null;
43
+ return true;
44
+ } catch (error) {
45
+ // EPERM: the group still exists, now holding a process this user cannot signal.
46
+ return (error as NodeJS.ErrnoException).code === "EPERM";
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Runs review work so that SIGINT, SIGTERM, and SIGHUP stop it instead of
52
+ * ending agent-tool at once: the running reviewer is stopped (and killed after
53
+ * a grace period), no further attempt starts, the work's own cleanup runs (the
54
+ * snapshot and temporary directories), and only then is the signal raised
55
+ * again, so callers still see termination by that signal.
56
+ */
57
+ export async function withReviewInterrupts<T>(work: () => Promise<T>): Promise<T> {
58
+ if (scope) return work();
59
+ const current: ReviewScope = { signal: null, reviewers: new Set() };
60
+ const handlers = FORWARDED_SIGNALS.map(value => [value, () => {
61
+ current.signal ??= value;
62
+ for (const reviewer of current.reviewers) reviewer.stop(value);
63
+ }] as const);
64
+ scope = current;
65
+ for (const [value, handler] of handlers) process.on(value, handler);
66
+ try {
67
+ return await work();
68
+ } finally {
69
+ // Cleanup is synchronous; a signal that arrived during it is still pending.
70
+ await handlePendingSignals();
71
+ for (const [value, handler] of handlers) process.removeListener(value, handler);
72
+ scope = null;
73
+ if (current.signal) {
74
+ process.kill(process.pid, current.signal);
75
+ // Let the runtime deliver the signal before the caller sets an exit code.
76
+ await new Promise(resolve => setTimeout(resolve, 100));
77
+ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Run a reviewer CLI without blocking the event loop, so an interruption can
83
+ * stop it. The prompt goes to stdin, stdout is captured up to `maxBuffer`, and
84
+ * stderr goes to the `stderr` descriptor. Past `timeoutMs` or `maxBuffer` the
85
+ * reviewer is stopped, and the result carries an `ETIMEDOUT` or `ENOBUFS`
86
+ * error as `spawnSync` reports them. A stopped reviewer's process group gets
87
+ * the signal, then SIGKILL after `STOP_GRACE_MS`; the run settles only once
88
+ * that group is gone, and a group the reviewer left behind when it exited is
89
+ * stopped the same way. Inside an interrupted `withReviewInterrupts`, no
90
+ * reviewer starts.
91
+ */
92
+ export async function spawnReviewer(
93
+ command: string,
94
+ args: string[],
95
+ options: { input: string; cwd: string; env: ReviewerEnv; timeoutMs: number; maxBuffer: number; stderr: number },
96
+ ): Promise<ReviewerResult> {
97
+ const owner = scope;
98
+ if (owner) await handlePendingSignals();
99
+ if (owner?.signal) {
100
+ return { status: null, signal: null, stdout: "", error: new Error(`review interrupted by ${owner.signal}`) };
101
+ }
102
+ let child: ChildProcess;
103
+ try {
104
+ child = spawn(command, args, { cwd: options.cwd, env: options.env, stdio: ["pipe", "pipe", options.stderr], detached: grouped });
105
+ } catch (spawnError) {
106
+ return { status: null, signal: null, stdout: "", error: spawnError as Error };
107
+ }
108
+ const chunks: Buffer[] = [];
109
+ let size = 0;
110
+ let error: Error | undefined;
111
+ let killAt: number | null = null;
112
+ const reviewer: Reviewer = {
113
+ stop(signal) {
114
+ signalGroup(child, signal);
115
+ killAt ??= Date.now() + STOP_GRACE_MS;
116
+ },
117
+ };
118
+ const fail = (code: string, message: string) => {
119
+ error ??= Object.assign(new Error(message), { code });
120
+ reviewer.stop("SIGTERM");
121
+ };
122
+ owner?.reviewers.add(reviewer);
123
+ const timer = setTimeout(() => fail("ETIMEDOUT", `${command} timed out after ${options.timeoutMs} ms`), options.timeoutMs);
124
+ // While stopping, escalate to SIGKILL at the deadline even if the reviewer has not exited yet.
125
+ const escalation = setInterval(() => { if (killAt !== null && Date.now() >= killAt) signalGroup(child, "SIGKILL"); }, 50);
126
+ try {
127
+ child.stdout!.on("data", (chunk: Buffer) => {
128
+ size += chunk.length;
129
+ if (size > options.maxBuffer) fail("ENOBUFS", `${command} wrote more than ${options.maxBuffer} bytes to stdout`);
130
+ else chunks.push(chunk);
131
+ });
132
+ const closed = new Promise<void>(resolve => child.stdout!.once("close", () => resolve()));
133
+ // A reviewer may exit without reading the whole prompt.
134
+ child.stdin!.on("error", () => {});
135
+ child.stdin!.end(options.input);
136
+ const exit = await new Promise<{ status: number | null; signal: NodeJS.Signals | null }>(resolve => {
137
+ child.once("error", spawnError => {
138
+ error ??= spawnError;
139
+ if (child.pid === undefined) resolve({ status: null, signal: null });
140
+ });
141
+ child.once("exit", (status, signal) => resolve({ status, signal }));
142
+ });
143
+ if (child.pid !== undefined) {
144
+ // The review is over once its process group is: stop whatever the reviewer left in it.
145
+ if (signalGroup(child, 0)) reviewer.stop("SIGTERM");
146
+ while (signalGroup(child, 0)) {
147
+ if (killAt !== null && Date.now() >= killAt + STOP_GRACE_MS) break;
148
+ await sleep(25);
149
+ }
150
+ // A descendant outside the group may still hold stdout; do not wait on it forever.
151
+ let grace: ReturnType<typeof setTimeout> | undefined;
152
+ await Promise.race([closed, new Promise(resolve => { grace = setTimeout(resolve, CLOSE_GRACE_MS); })]);
153
+ clearTimeout(grace);
154
+ child.stdout?.destroy();
155
+ }
156
+ return { status: exit.status, signal: exit.signal, stdout: Buffer.concat(chunks).toString("utf8"), ...(error ? { error } : {}) };
157
+ } finally {
158
+ clearTimeout(timer);
159
+ clearInterval(escalation);
160
+ owner?.reviewers.delete(reviewer);
161
+ }
162
+ }
@@ -37,13 +37,13 @@ export const MAX_REVIEW_ATTEMPTS = 2;
37
37
  * line with them while the in-memory check below still saw a complete review
38
38
  * and exited 0.
39
39
  */
40
- export function relayReviewWithRetry(
40
+ export async function relayReviewWithRetry(
41
41
  command: string,
42
- runAttempt: () => ReviewAttempt,
43
- ): number {
42
+ runAttempt: () => ReviewAttempt | Promise<ReviewAttempt>,
43
+ ): Promise<number> {
44
44
  let last: ReviewAttempt = { exitCode: 1, review: "" };
45
45
  for (let attempt = 1; attempt <= MAX_REVIEW_ATTEMPTS; attempt += 1) {
46
- last = runAttempt();
46
+ last = await runAttempt();
47
47
  if (last.exitCode !== 0) {
48
48
  writeSync(1, last.review);
49
49
  return last.exitCode;
@@ -1,6 +1,5 @@
1
1
  import { reviewExecutable } from "./executable";
2
2
  import { loadConfig } from "../config";
3
- import { spawnSync } from "node:child_process";
4
3
  import path from "node:path";
5
4
 
6
5
  import {
@@ -17,6 +16,7 @@ import {
17
16
  import { buildReviewPrompt, CLAUDE_ACCESS_NOTE } from "./reviewPrompt";
18
17
  import { type ReviewerEnv, relayReviewWithRetry } from "./runReview";
19
18
  import { withReviewDiagnostics } from "./diagnostics";
19
+ import { spawnReviewer } from "./reviewerProcess";
20
20
 
21
21
  /**
22
22
  * Tools the reviewer gets. Read-only, and deliberately not empty: the diff alone
@@ -77,30 +77,29 @@ export function buildClaudeReviewArgs(
77
77
  * that failure is stochastic — so a caller's fallback chain treats a degenerate
78
78
  * review the same as a crashed one.
79
79
  */
80
- export function spawnClaudeReview(
80
+ export async function spawnClaudeReview(
81
81
  prompt: string,
82
82
  effort: ReviewEffort,
83
83
  snapshotRoot: string,
84
84
  env: ReviewerEnv = process.env,
85
- ): number {
85
+ ): Promise<number> {
86
86
  const executable = reviewExecutable("claude", env);
87
87
  if (executable === undefined) {
88
88
  process.stderr.write("Claude executable unavailable outside the repository.\n");
89
89
  return 1;
90
90
  }
91
- return relayReviewWithRetry("claude", () => {
91
+ return await relayReviewWithRetry("claude", async () => {
92
92
  // Captured rather than inherited: a review has to be read to be judged, and
93
93
  // `claude` exits 0 whether it reviewed the diff or merely said it would.
94
94
  // `--print` emits the review in one final block, so nothing streams anyway.
95
- const result = withReviewDiagnostics("claude", fd => spawnSync(
95
+ const result = await withReviewDiagnostics("claude", fd => spawnReviewer(
96
96
  executable,
97
97
  buildClaudeReviewArgs(effort, snapshotRoot, loadConfig().review.claudeModel),
98
98
  {
99
- stdio: ["pipe", "pipe", fd],
99
+ stderr: fd,
100
100
  input: prompt,
101
- encoding: "utf8",
102
101
  maxBuffer: MAX_BUFFER_BYTES,
103
- timeout: loadConfig().review.timeoutMs,
102
+ timeoutMs: loadConfig().review.timeoutMs,
104
103
  cwd: snapshotRoot,
105
104
  // Passed explicitly so `claude` resolves against this PATH rather than
106
105
  // the one the runtime snapshotted at startup.
@@ -124,7 +123,7 @@ export function spawnClaudeReview(
124
123
  export function solicitClaudeCodeReview(
125
124
  rootDir: string,
126
125
  effortArg?: string,
127
- ): number {
126
+ ): Promise<number> {
128
127
  const effort = resolveReviewEffort(effortArg, DEFAULT_CLAUDE_EFFORT);
129
128
  const context = resolveReviewContext();
130
129
  return withPinnedReviewInput(rootDir, context, (input) => {
@@ -1,6 +1,5 @@
1
1
  import { reviewExecutable } from "./executable";
2
2
  import { loadConfig } from "../config";
3
- import { spawnSync } from "node:child_process";
4
3
  import {
5
4
  mkdtempSync,
6
5
  readFileSync,
@@ -23,6 +22,7 @@ import {
23
22
  import { buildReviewPrompt, CODEX_ACCESS_NOTE } from "./reviewPrompt";
24
23
  import { type ReviewerEnv, relayReviewWithRetry } from "./runReview";
25
24
  import { withReviewDiagnostics } from "./diagnostics";
25
+ import { spawnReviewer } from "./reviewerProcess";
26
26
 
27
27
  /**
28
28
  * Build the `codex exec` argv. `exec`, not `review`: `codex review` writes its
@@ -123,12 +123,12 @@ function readLastMessage(lastMessageFile: string): string {
123
123
  * Degenerate exit-0 output is retried once via
124
124
  * the shared gate, same as the Claude path.
125
125
  */
126
- export function spawnCodexReview(
126
+ export async function spawnCodexReview(
127
127
  prompt: string,
128
128
  effort: ReviewEffort,
129
129
  snapshotRoot: string,
130
130
  env: ReviewerEnv = process.env,
131
- ): number {
131
+ ): Promise<number> {
132
132
  const codexExecutable = reviewExecutable("codex", env);
133
133
  if (codexExecutable === undefined) {
134
134
  process.stderr.write("Codex executable unavailable outside the repository.\n");
@@ -138,12 +138,12 @@ export function spawnCodexReview(
138
138
  const codexRuntimeRoot = path.dirname(codexExecutable);
139
139
  let attempt = 0;
140
140
  try {
141
- return relayReviewWithRetry("codex", () => {
141
+ return await relayReviewWithRetry("codex", async () => {
142
142
  // A fresh file per attempt, so a retry that crashes before writing can
143
143
  // never be read as the previous attempt's stale message.
144
144
  attempt += 1;
145
145
  const lastMessageFile = path.join(outDir, `review-${attempt}.md`);
146
- const result = withReviewDiagnostics("codex", fd => spawnSync(
146
+ const result = await withReviewDiagnostics("codex", fd => spawnReviewer(
147
147
  codexExecutable,
148
148
  buildCodexReviewArgs(
149
149
  effort,
@@ -153,11 +153,10 @@ export function spawnCodexReview(
153
153
  loadConfig().review.codexModel,
154
154
  ),
155
155
  {
156
- stdio: ["pipe", "pipe", fd],
156
+ stderr: fd,
157
157
  input: prompt,
158
- encoding: "utf8",
159
158
  maxBuffer: MAX_BUFFER_BYTES,
160
- timeout: loadConfig().review.timeoutMs,
159
+ timeoutMs: loadConfig().review.timeoutMs,
161
160
  // Codex loads project AGENTS.md from its cwd. Keep cwd neutral and
162
161
  // grant read-only access to the reviewed repository explicitly.
163
162
  cwd: outDir,
@@ -181,7 +180,7 @@ export function spawnCodexReview(
181
180
  export function solicitCodexReview(
182
181
  rootDir: string,
183
182
  effortArg?: string,
184
- ): number {
183
+ ): Promise<number> {
185
184
  const effort = resolveReviewEffort(effortArg, DEFAULT_CODEX_EFFORT);
186
185
  const context = resolveReviewContext();
187
186
  return withPinnedReviewInput(rootDir, context, (input) => {
@@ -1,6 +1,5 @@
1
1
  import { reviewExecutable } from "./executable";
2
2
  import { loadConfig, DEFAULT_CONFIG } from "../config";
3
- import { spawnSync } from "node:child_process";
4
3
  import { mkdtempSync, realpathSync, rmSync } from "node:fs";
5
4
  import { tmpdir } from "node:os";
6
5
  import path from "node:path";
@@ -19,6 +18,7 @@ import {
19
18
  import { buildReviewPrompt, OPENCODE_ACCESS_NOTE } from "./reviewPrompt";
20
19
  import { type ReviewerEnv, relayReviewWithRetry } from "./runReview";
21
20
  import { withReviewDiagnostics } from "./diagnostics";
21
+ import { spawnReviewer } from "./reviewerProcess";
22
22
 
23
23
  /**
24
24
  * The model every opencode review runs under. Pinned so the review never
@@ -150,12 +150,12 @@ export function buildOpencodeReviewArgs(variant: string, model = OPENCODE_MODEL)
150
150
  * needs still resolves. Global config still loads, but the inline config's
151
151
  * agent-scoped deny rules take precedence over whatever it sets.
152
152
  */
153
- export function spawnOpencodeReview(
153
+ export async function spawnOpencodeReview(
154
154
  prompt: string,
155
155
  effort: ReviewEffort,
156
156
  snapshotRoot: string,
157
157
  env: ReviewerEnv = process.env,
158
- ): number {
158
+ ): Promise<number> {
159
159
  const executable = reviewExecutable("opencode", env);
160
160
  if (executable === undefined) {
161
161
  process.stderr.write("OpenCode executable unavailable outside the repository.\n");
@@ -164,16 +164,15 @@ export function spawnOpencodeReview(
164
164
  const model = loadConfig().review.opencodeModel;
165
165
  const outDir = mkdtempSync(path.join(tmpdir(), "agent-tool-opencode-"));
166
166
  try {
167
- return relayReviewWithRetry("opencode", () => {
168
- const result = withReviewDiagnostics("opencode", fd => spawnSync(
167
+ return await relayReviewWithRetry("opencode", async () => {
168
+ const result = await withReviewDiagnostics("opencode", fd => spawnReviewer(
169
169
  executable,
170
170
  buildOpencodeReviewArgs(resolveOpencodeVariant(effort, loadConfig().review.opencodeVariants), model),
171
171
  {
172
- stdio: ["pipe", "pipe", fd],
172
+ stderr: fd,
173
173
  input: prompt,
174
- encoding: "utf8",
175
174
  maxBuffer: MAX_BUFFER_BYTES,
176
- timeout: loadConfig().review.timeoutMs,
175
+ timeoutMs: loadConfig().review.timeoutMs,
177
176
  // Neutral cwd: opencode would otherwise load the snapshot's
178
177
  // branch-controlled config and instructions into the reviewer.
179
178
  cwd: outDir,
@@ -206,7 +205,7 @@ export function spawnOpencodeReview(
206
205
  export function solicitOpencodeReview(
207
206
  rootDir: string,
208
207
  effortArg?: string,
209
- ): number {
208
+ ): Promise<number> {
210
209
  const effort = resolveReviewEffort(effortArg, DEFAULT_OPENCODE_EFFORT);
211
210
  const context = resolveReviewContext();
212
211
  return withPinnedReviewInput(rootDir, context, (input) => {
@@ -17,9 +17,9 @@ export interface AgentToolActions {
17
17
  readonly solicitClaudeCodeReview: (
18
18
  rootDir: string,
19
19
  effort?: string,
20
- ) => number;
21
- readonly solicitCodexReview: (rootDir: string, effort?: string) => number;
22
- readonly solicitOpencodeReview: (rootDir: string, effort?: string) => number;
20
+ ) => number | Promise<number>;
21
+ readonly solicitCodexReview: (rootDir: string, effort?: string) => number | Promise<number>;
22
+ readonly solicitOpencodeReview: (rootDir: string, effort?: string) => number | Promise<number>;
23
23
  readonly squashMerge: (
24
24
  rootDir: string,
25
25
  subject?: string,
@@ -56,7 +56,7 @@ export function runAgentToolAction(
56
56
  rootDir: string,
57
57
  args: readonly string[],
58
58
  actions: AgentToolActions = defaultActions,
59
- ): number {
59
+ ): number | Promise<number> {
60
60
  const [action, ...positionals] = args;
61
61
  const [first, second, third] = positionals;
62
62
  switch (action) {