@kylecheng3146/agent-ops 0.3.7 → 0.4.1

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.
Files changed (46) hide show
  1. package/README.md +35 -0
  2. package/dist/packages/cli/src/args.js +93 -9
  3. package/dist/packages/cli/src/bin.js +151 -104
  4. package/dist/packages/cli/src/cli.js +54 -5
  5. package/dist/packages/cli/src/commands/batch.js +106 -0
  6. package/dist/packages/cli/src/commands/hook.js +4 -1
  7. package/dist/packages/cli/src/commands/init.js +1 -0
  8. package/dist/packages/cli/src/commands/parallel.js +16 -3
  9. package/dist/packages/cli/src/commands/review.js +22 -0
  10. package/dist/packages/cli/src/commands/uninstall.js +3 -2
  11. package/dist/packages/cli/src/commands/update.js +1 -0
  12. package/dist/packages/cli/src/context.js +28 -3
  13. package/dist/packages/cli/src/hook-process.js +14 -3
  14. package/dist/packages/cli/src/public-plan.js +32 -2
  15. package/dist/runtime/src/adapters/claude/config.js +1 -1
  16. package/dist/runtime/src/adapters/claude/input.js +17 -0
  17. package/dist/runtime/src/adapters/claude/output.js +9 -0
  18. package/dist/runtime/src/hooks/codex-loop.js +67 -7
  19. package/dist/runtime/src/hooks/completion-gate.js +27 -2
  20. package/dist/runtime/src/hooks/dispatch.js +5 -0
  21. package/dist/runtime/src/install/apply.js +2 -0
  22. package/dist/runtime/src/install/doctor.js +8 -1
  23. package/dist/runtime/src/install/harness.js +17 -6
  24. package/dist/runtime/src/install/plan.js +9 -0
  25. package/dist/runtime/src/install/preauth.js +276 -0
  26. package/dist/runtime/src/install/probes.js +32 -0
  27. package/dist/runtime/src/install/uninstall.js +29 -11
  28. package/dist/runtime/src/install/update.js +1 -0
  29. package/dist/runtime/src/parallel/enter.js +43 -0
  30. package/dist/runtime/src/parallel/finish.js +2 -1
  31. package/dist/runtime/src/parallel/guard.js +20 -1
  32. package/dist/runtime/src/parallel/manage.js +38 -0
  33. package/dist/runtime/src/parallel/service.js +3 -1
  34. package/dist/runtime/src/review/batch-guard.js +11 -0
  35. package/dist/runtime/src/review/batch-select.js +37 -0
  36. package/dist/runtime/src/review/batch.js +202 -0
  37. package/dist/runtime/src/review/execute.js +10 -7
  38. package/dist/runtime/src/review/host-sandbox.js +9 -0
  39. package/dist/runtime/src/review/invocation.js +15 -2
  40. package/dist/runtime/src/review/probe.js +51 -15
  41. package/dist/runtime/src/review/render.js +4 -0
  42. package/docs/en/guides/configuration.md +51 -1
  43. package/docs/en/spec/review.md +70 -0
  44. package/docs/zh-TW/guides/configuration.md +41 -0
  45. package/docs/zh-TW/spec/review.md +71 -1
  46. package/package.json +1 -1
@@ -6,6 +6,7 @@ import { formatInstallManifest, parseInstallManifest } from "../fs/manifest.js";
6
6
  import { AgentOpsError, resolveContainedPath } from "../fs/paths.js";
7
7
  import { FileTransaction } from "../fs/transaction.js";
8
8
  import { planHookRemoval } from "./hooks.js";
9
+ import { applyPreauth, planPreauth } from "./preauth.js";
9
10
  import { assertExpectedManagedBlock, assertSupportedManifestOwnership } from "./ownership.js";
10
11
  import { isOpencodeManagedPlugin } from "../adapters/opencode/config.js";
11
12
  import { isHarnessId } from "./harness.js";
@@ -207,7 +208,7 @@ async function planArtifactRemoval(root, artifact) {
207
208
  expectedHash: current.hash
208
209
  };
209
210
  }
210
- async function createFullUninstallPlan(root) {
211
+ async function createFullUninstallPlan(root, options = {}) {
211
212
  const currentManifest = await readCurrentFile(root, MANIFEST_PATH);
212
213
  if (currentManifest === null) {
213
214
  return {
@@ -262,11 +263,19 @@ async function createFullUninstallPlan(root) {
262
263
  path: MANIFEST_PATH,
263
264
  expectedHash: currentManifest.hash
264
265
  });
266
+ const preauthorization = await planPreauth({
267
+ root,
268
+ scope: manifest.scope,
269
+ harness: manifest.harness,
270
+ desired: false,
271
+ ...(options.codexHome === undefined ? {} : { codexHome: options.codexHome })
272
+ });
265
273
  return {
266
274
  installed: true,
267
275
  manifest,
268
276
  manifestHash: currentManifest.hash,
269
- operations
277
+ operations,
278
+ ...(preauthorization.length === 0 ? {} : { preauthorization })
270
279
  };
271
280
  }
272
281
  async function planLoopMarkerUpdate(root, marker, expected, remaining) {
@@ -300,7 +309,7 @@ async function planLoopMarkerUpdate(root, marker, expected, remaining) {
300
309
  }
301
310
  };
302
311
  }
303
- async function planSelectiveUninstall(root, currentManifest, manifest, expectedMarkers, selectedHarnesses) {
312
+ async function planSelectiveUninstall(root, currentManifest, manifest, expectedMarkers, selectedHarnesses, planOptions = {}) {
304
313
  if (selectedHarnesses.length === 0 ||
305
314
  selectedHarnesses.some((id) => !isHarnessId(id)) ||
306
315
  new Set(selectedHarnesses).size !== selectedHarnesses.length ||
@@ -310,7 +319,7 @@ async function planSelectiveUninstall(root, currentManifest, manifest, expectedM
310
319
  const selected = selectedSet(selectedHarnesses);
311
320
  const remaining = manifest.harness.filter((id) => !selected.has(id));
312
321
  if (remaining.length === 0) {
313
- return await createFullUninstallPlan(root);
322
+ return await createFullUninstallPlan(root, planOptions);
314
323
  }
315
324
  const operations = [];
316
325
  const removedArtifacts = manifest.artifacts.filter((artifact) => shouldRemove(artifactOwners(manifest, artifact), selected));
@@ -398,26 +407,34 @@ async function planSelectiveUninstall(root, currentManifest, manifest, expectedM
398
407
  content: formatInstallManifest(resultingManifest),
399
408
  expectedHash: currentManifest.hash
400
409
  });
410
+ const preauthorization = await planPreauth({
411
+ root,
412
+ scope: manifest.scope,
413
+ harness: selectedHarnesses,
414
+ desired: false,
415
+ ...(planOptions.codexHome === undefined ? {} : { codexHome: planOptions.codexHome })
416
+ });
401
417
  return {
402
418
  installed: true,
403
419
  manifest,
404
420
  manifestHash: currentManifest.hash,
405
421
  operations,
406
422
  selectedHarnesses,
407
- resultingManifest
423
+ resultingManifest,
424
+ ...(preauthorization.length === 0 ? {} : { preauthorization })
408
425
  };
409
426
  }
410
- export async function createUninstallPlan(root, selectedHarnesses) {
427
+ export async function createUninstallPlan(root, selectedHarnesses, options = {}) {
411
428
  if (selectedHarnesses === undefined) {
412
- return await createFullUninstallPlan(root);
429
+ return await createFullUninstallPlan(root, options);
413
430
  }
414
431
  const currentManifest = await readCurrentFile(root, MANIFEST_PATH);
415
432
  if (currentManifest === null) {
416
- return await createFullUninstallPlan(root);
433
+ return await createFullUninstallPlan(root, options);
417
434
  }
418
435
  const manifest = parseInstallManifest(currentManifest.content);
419
436
  const expectedMarkers = assertSupportedManifestOwnership(manifest, root);
420
- return await planSelectiveUninstall(root, currentManifest, manifest, expectedMarkers, selectedHarnesses);
437
+ return await planSelectiveUninstall(root, currentManifest, manifest, expectedMarkers, selectedHarnesses, options);
421
438
  }
422
439
  function allowedPaths(plan) {
423
440
  if (plan.manifest === null) {
@@ -559,9 +576,9 @@ async function validateUninstalled(root, manifest, resultingManifest) {
559
576
  throw new AgentOpsError("UNINSTALL_VALIDATION_FAILED", "Installation manifest still exists.");
560
577
  }
561
578
  }
562
- export async function applyUninstallPlan(root, plan) {
579
+ export async function applyUninstallPlan(root, plan, options = {}) {
563
580
  assertUninstallPlan(plan);
564
- const currentPlan = await createUninstallPlan(root, plan.selectedHarnesses);
581
+ const currentPlan = await createUninstallPlan(root, plan.selectedHarnesses, options);
565
582
  if (JSON.stringify(currentPlan) !== JSON.stringify(plan)) {
566
583
  throw new AgentOpsError("INVALID_UNINSTALL_PLAN", "Uninstall plan no longer matches the current managed installation.");
567
584
  }
@@ -571,4 +588,5 @@ export async function applyUninstallPlan(root, plan) {
571
588
  }
572
589
  const transaction = new FileTransaction(root);
573
590
  await transaction.apply({ operations: plan.operations }, async () => await validateUninstalled(root, manifest, plan.resultingManifest));
591
+ await applyPreauth(plan.preauthorization ?? []);
574
592
  }
@@ -124,6 +124,7 @@ export async function createUpdatePlan(options) {
124
124
  value: configPreview.migrated,
125
125
  sourceHash: configPreview.sourceHash
126
126
  },
127
+ ...(options.codexHome === undefined ? {} : { codexHome: options.codexHome }),
127
128
  ...(options.worktree === undefined
128
129
  ? {}
129
130
  : { worktree: options.worktree })
@@ -0,0 +1,43 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { join, relative, sep } from "node:path";
3
+ import { canonicalPath } from "./guard.js";
4
+ import { readWorktreeRecord, WORKTREE_DIRECTORY } from "./service.js";
5
+ /**
6
+ * Whether Claude's EnterWorktree may switch this session into `path`. Only the
7
+ * worktree agent-ops recorded for the session is answered for the host, so an
8
+ * allow never lets the session move its permission root to a directory it did
9
+ * not get from `worktree add`.
10
+ */
11
+ export async function evaluateWorktreeEnter(mainRoot, path, sessionId) {
12
+ if (sessionId === undefined) {
13
+ return { action: "continue", status: "UNKNOWN", code: "WORKTREE_ENTER_UNJUDGED" };
14
+ }
15
+ const directory = join(mainRoot, WORKTREE_DIRECTORY);
16
+ const target = await canonicalPath(path);
17
+ const name = relative(directory, target).split(sep)[0] ?? "";
18
+ const inside = !target.startsWith(`${directory}${sep}`) ? false : target === join(directory, name);
19
+ if (inside && (await readWorktreeRecord(target))?.sessionId === sessionId) {
20
+ return {
21
+ action: "continue",
22
+ status: "PASS",
23
+ code: "WORKTREE_ENTER_ALLOWED",
24
+ remedy: `${target} is the worktree agent-ops recorded for this session.`,
25
+ decision: "allow"
26
+ };
27
+ }
28
+ const owned = [];
29
+ for (const entry of await readdir(directory).catch(() => [])) {
30
+ if ((await readWorktreeRecord(join(directory, entry)))?.sessionId === sessionId) {
31
+ owned.push(join(directory, entry));
32
+ }
33
+ }
34
+ return {
35
+ action: "block",
36
+ status: "FAIL",
37
+ code: "WORKTREE_ENTER_DENIED",
38
+ remedy: `EnterWorktree may enter only a worktree this session owns. ` +
39
+ (owned.length > 0
40
+ ? `Yours: ${owned.join(", ")}.`
41
+ : `None yet: run agent-ops worktree add <name> --session ${sessionId} from ${mainRoot}.`)
42
+ };
43
+ }
@@ -251,7 +251,8 @@ export async function finishWorktree(deps, options) {
251
251
  await deps.tasks(record.path, base).complete(task.task.id, {});
252
252
  }
253
253
  catch (error) {
254
- throw finishError("WORKTREE_TASK_INCOMPLETE", `Task ${task.task.id} (${task.task.title}) could not be completed against ${base}: ${error instanceof Error ? error.message : String(error)}`);
254
+ throw finishError("WORKTREE_TASK_INCOMPLETE", `Task ${task.task.id} (${task.task.title}) could not be completed against ${base}: ${error instanceof Error ? error.message : String(error)} ` +
255
+ "A task another session left here is not this session's to archive: its owner finishes or archives it, and finish never does.");
255
256
  }
256
257
  }
257
258
  const worktreeGate = await deps.gate(record.path, worktreeConfig);
@@ -1,6 +1,6 @@
1
1
  import { realpath } from "node:fs/promises";
2
2
  import { basename, dirname, join, relative, sep } from "node:path";
3
- import { insideWorktreeDirectory } from "./service.js";
3
+ import { insideWorktreeDirectory, readWorktreeRecord, WORKTREE_DIRECTORY } from "./service.js";
4
4
  /** The main checkout of the repository `runner` runs in, or null. */
5
5
  export async function resolveMainRoot(runner) {
6
6
  const result = await runner.run(["rev-parse", "--path-format=absolute", "--git-common-dir"]);
@@ -47,6 +47,25 @@ ensureWorktree) {
47
47
  for (const path of paths) {
48
48
  const target = await canonicalPath(path);
49
49
  const inMain = target === mainRoot || target.startsWith(`${mainRoot}${sep}`);
50
+ if (inMain && insideWorktreeDirectory(mainRoot, target) && sessionId !== undefined) {
51
+ // A worktree belongs to the session that made it: writing into another
52
+ // session's mixes two tasks' changes into one review and one merge.
53
+ const name = relative(join(mainRoot, WORKTREE_DIRECTORY), target).split(sep)[0] ?? "";
54
+ const owner = (await readWorktreeRecord(join(mainRoot, WORKTREE_DIRECTORY, name)))?.sessionId;
55
+ if (owner !== undefined && owner !== sessionId) {
56
+ let own = "";
57
+ if (ensureWorktree !== undefined) {
58
+ own = await ensureWorktree(sessionId).then((worktree) => ` Your own worktree is ${worktree}: edit there instead.`, () => "");
59
+ }
60
+ return {
61
+ action: "block",
62
+ status: "FAIL",
63
+ code: "WORKTREE_OWNED_BY_OTHER_SESSION",
64
+ remedy: `${join(mainRoot, WORKTREE_DIRECTORY, name)} belongs to another session (${owner}); ` +
65
+ `this session is ${sessionId}.${own || " Run agent-ops worktree add <name> --session " + sessionId + " from " + mainRoot + " and edit in the printed path."}`
66
+ };
67
+ }
68
+ }
50
69
  if (inMain && !insideWorktreeDirectory(mainRoot, target)) {
51
70
  let failure = "";
52
71
  if (sessionId !== undefined && ensureWorktree !== undefined) {
@@ -154,6 +154,44 @@ export async function resumeWorktree(deps, options) {
154
154
  await bindSession(deps, record, mainConfig, RESUMED_BASELINE);
155
155
  return record;
156
156
  }
157
+ /**
158
+ * Commits everything in the calling session's own worktree. Git runs from
159
+ * here rather than the shell, where a host cannot prove a command stays inside
160
+ * the worktree; that is also why it refuses anything that is not the caller's.
161
+ *
162
+ * ponytail: stages all changes, so an untracked file Git does not ignore goes
163
+ * in too; narrow it with a path list if that ever commits something unwanted.
164
+ */
165
+ export async function commitWorktree(deps, options) {
166
+ const { mainRoot, currentRoot } = await resolveCheckouts(deps, options.cwd);
167
+ if (currentRoot === mainRoot) {
168
+ throw manageError("WORKTREE_COMMIT_MAIN", `worktree commit works only inside an agent-ops worktree; this is the main checkout (${mainRoot}).`);
169
+ }
170
+ const record = await readWorktreeRecord(currentRoot);
171
+ if (record === null) {
172
+ throw manageError("WORKTREE_COMMIT_UNRECORDED", `${currentRoot} is not an agent-ops worktree; see agent-ops worktree list.`);
173
+ }
174
+ if (options.sessionId === undefined) {
175
+ throw manageError("WORKTREE_COMMIT_SESSION_REQUIRED", "Cannot tell which session is committing; pass --session <id> or set AGENT_OPS_SESSION_ID.");
176
+ }
177
+ if (record.sessionId !== options.sessionId) {
178
+ throw manageError("WORKTREE_COMMIT_FOREIGN", `${record.path} belongs to session ${record.sessionId}, not ${options.sessionId}.`);
179
+ }
180
+ const step = async (args, code, message) => {
181
+ const result = await deps.git(record.path, args);
182
+ if (result.exitCode !== 0) {
183
+ const detail = result.stderr.trim().split("\n")[0];
184
+ throw manageError(code, detail === undefined || detail === "" ? message : `${message} ${detail}`);
185
+ }
186
+ return result.stdout.trim();
187
+ };
188
+ await step(["add", "-A"], "WORKTREE_COMMIT_FAILED", "Git could not stage the worktree.");
189
+ if ((await deps.git(record.path, ["diff", "--cached", "--quiet"])).exitCode === 0) {
190
+ throw manageError("WORKTREE_COMMIT_NOTHING", `${record.path} has no changes to commit.`);
191
+ }
192
+ await step(["commit", "-m", options.message], "WORKTREE_COMMIT_FAILED", "Git refused the commit.");
193
+ return { record, commit: await step(["rev-parse", "HEAD"], "WORKTREE_COMMIT_FAILED", "Git could not read the new commit.") };
194
+ }
157
195
  /**
158
196
  * Deletes a worktree and its branch. Work that exists nowhere else — an
159
197
  * uncommitted change or a commit the target lacks — needs `force`.
@@ -291,7 +291,9 @@ export async function addWorktree(deps, options) {
291
291
  const mainTrust = await deps.trust.status(mainRoot, mainConfig);
292
292
  const setupSteps = mainConfig.worktree?.setup ?? [];
293
293
  if (setupSteps.length > 0 && mainTrust !== "TRUSTED") {
294
- throw worktreeError("WORKTREE_SETUP_UNTRUSTED", "worktree.setup runs repository commands; run agent-ops trust grant in the main checkout first.");
294
+ throw worktreeError("WORKTREE_SETUP_UNTRUSTED", "worktree.setup runs repository commands and the main checkout is not trusted: trust is bound to the config hash, " +
295
+ "so a hand edit of .agent-ops/config.json (adding worktree.setup, say) leaves it untrusted. " +
296
+ "Trust is the user's to grant, once: run agent-ops update in the main checkout, which lists the commands it will trust and asks.");
295
297
  }
296
298
  await ensureExcluded(deps, mainRoot, commonDir);
297
299
  await git(deps, mainRoot, ["worktree", "add", "-b", branch, path, base], "WORKTREE_ADD_FAILED", "Git could not create the worktree.");
@@ -0,0 +1,11 @@
1
+ import { collectChangeSurface, resolveGitCommit } from "../verify/change-surface.js";
2
+ /**
3
+ * Remembers HEAD now and answers whether the batch may go on: HEAD is still
4
+ * that commit and the working tree has no change. The first answer doubles as
5
+ * the clean-tree check, since a batch reviews committed ranges only.
6
+ */
7
+ export async function createSourceGuard(runner) {
8
+ const head = await resolveGitCommit(runner, "HEAD");
9
+ return async () => await resolveGitCommit(runner, "HEAD") === head &&
10
+ (await collectChangeSurface(runner)).paths.length === 0;
11
+ }
@@ -0,0 +1,37 @@
1
+ import { AgentOpsError } from "../fs/paths.js";
2
+ /**
3
+ * The tasks one `batch --parent` covers: the parent's active subtasks in
4
+ * creation order, then the parent itself. Archived and completed tasks, and
5
+ * tasks under another parent, are left out.
6
+ */
7
+ export function selectBatchTasks(records, parentId) {
8
+ const parent = records.find((record) => record.task.id === parentId);
9
+ if (parent === undefined) {
10
+ throw new AgentOpsError("BATCH_PARENT_NOT_FOUND", `Parent task not found: ${parentId}`);
11
+ }
12
+ if (parent.status !== "active") {
13
+ throw new AgentOpsError("BATCH_PARENT_NOT_ACTIVE", `Parent task is not active: ${parentId}`);
14
+ }
15
+ const subtasks = records
16
+ .filter((record) => record.task.parentTaskId === parentId && record.status === "active")
17
+ .sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.task.id.localeCompare(right.task.id));
18
+ return [...subtasks, parent];
19
+ }
20
+ /**
21
+ * An explicit flag wins, then the commit the task's last PASS review measured
22
+ * against (so a rerun reproduces the same fingerprint), then the worktree base.
23
+ * A parent's base is a human choice that cannot be derived from the repository.
24
+ */
25
+ export function resolveBatchBase(record, isParent, input) {
26
+ const flag = isParent ? input.parentBase : input.base;
27
+ if (flag !== undefined) {
28
+ return { base: flag, source: "flag" };
29
+ }
30
+ if (record.reviewBase !== undefined) {
31
+ return { base: record.reviewBase, source: "recorded" };
32
+ }
33
+ if (input.worktreeBase !== undefined) {
34
+ return { base: input.worktreeBase, source: "worktree" };
35
+ }
36
+ throw new AgentOpsError("BATCH_BASE_UNKNOWN", `No base for task ${record.task.id}: pass ${isParent ? "--parent-base" : "--base"} or run from an agent-ops worktree.`);
37
+ }
@@ -0,0 +1,202 @@
1
+ import { AgentOpsError } from "../fs/paths.js";
2
+ /**
3
+ * NOT_RUN reasons that mean the reviewer was briefly unavailable. Anything
4
+ * else (a sandboxed host, stale verification, a changed policy) fails the same
5
+ * way on a second attempt, so it is reported instead of retried.
6
+ */
7
+ export const TRANSIENT_REVIEW_REASONS = new Set([
8
+ "probe-failed",
9
+ "stalled",
10
+ "timeout",
11
+ "quota-exhausted",
12
+ "network-unreachable"
13
+ ]);
14
+ /** Reviews allowed at once when the caller names no width. */
15
+ export const DEFAULT_BATCH_WIDTH = 2;
16
+ /**
17
+ * Verify runs one task at a time (a full test run per task; several at once
18
+ * starve each other of CPU and fake a failure). Each task's review starts as
19
+ * soon as its own verify has passed, so the next verify overlaps it. Reviews
20
+ * run up to `width` at a time; a transient NOT_RUN drops the width to 1 for
21
+ * good and that task gets one more try alone after everything else finished.
22
+ */
23
+ export async function runBatch(ids, deps, options) {
24
+ if (!Number.isInteger(options.width) || options.width < 1) {
25
+ throw new AgentOpsError("BATCH_INVALID_WIDTH", "Batch width must be a positive integer.");
26
+ }
27
+ const progress = options.onProgress ?? (() => { });
28
+ const controller = new AbortController();
29
+ const outer = options.signal;
30
+ if (outer?.aborted === true) {
31
+ controller.abort();
32
+ }
33
+ else {
34
+ outer?.addEventListener("abort", () => controller.abort(), { once: true });
35
+ }
36
+ const slots = new Map(ids.map((id) => [
37
+ id,
38
+ { id, verify: "not-run", review: null, retried: false }
39
+ ]));
40
+ const slot = (id) => slots.get(id);
41
+ const stop = (why) => {
42
+ if (!controller.signal.aborted) {
43
+ progress(why);
44
+ controller.abort();
45
+ }
46
+ };
47
+ const guard = async () => {
48
+ try {
49
+ return await deps.guard();
50
+ }
51
+ catch {
52
+ return false;
53
+ }
54
+ };
55
+ let width = options.width;
56
+ let running = 0;
57
+ let verifying = true;
58
+ const ready = [];
59
+ const retry = [];
60
+ let finish;
61
+ const done = new Promise((resolve) => {
62
+ finish = resolve;
63
+ });
64
+ const review = async (id, final) => {
65
+ if (!await guard()) {
66
+ stop("source changed; aborting the batch");
67
+ return;
68
+ }
69
+ const entry = slot(id);
70
+ if (final) {
71
+ entry.retried = true;
72
+ }
73
+ let result;
74
+ try {
75
+ result = await deps.review(id, controller.signal);
76
+ }
77
+ catch (error) {
78
+ progress(`review ${id} failed to run: ${error instanceof Error ? error.message : String(error)}`);
79
+ result = { status: "NOT_RUN", reason: "review-error" };
80
+ }
81
+ if (controller.signal.aborted) {
82
+ return;
83
+ }
84
+ entry.review = result;
85
+ if (!final &&
86
+ result.status === "NOT_RUN" &&
87
+ result.reason !== undefined &&
88
+ TRANSIENT_REVIEW_REASONS.has(result.reason)) {
89
+ if (width > 1) {
90
+ width = 1;
91
+ progress(`degrade: review width -> 1 (${id}: ${result.reason})`);
92
+ }
93
+ retry.push(id);
94
+ }
95
+ };
96
+ const pump = () => {
97
+ while (!controller.signal.aborted && running < width && ready.length > 0) {
98
+ const id = ready.shift();
99
+ running += 1;
100
+ void review(id, false).finally(() => {
101
+ running -= 1;
102
+ pump();
103
+ });
104
+ }
105
+ if (!verifying && running === 0 && (ready.length === 0 || controller.signal.aborted)) {
106
+ finish();
107
+ }
108
+ };
109
+ const fresh = await Promise.all(ids.map((id) => deps.isVerified(id).catch(() => false)));
110
+ const stale = [];
111
+ ids.forEach((id, index) => {
112
+ if (fresh[index] === true) {
113
+ slot(id).verify = "skipped";
114
+ ready.push(id);
115
+ }
116
+ else {
117
+ stale.push(id);
118
+ }
119
+ });
120
+ const verifyAll = async () => {
121
+ for (const id of stale) {
122
+ if (controller.signal.aborted) {
123
+ break;
124
+ }
125
+ if (!await guard()) {
126
+ stop("source changed; aborting the batch");
127
+ break;
128
+ }
129
+ let status;
130
+ try {
131
+ status = await deps.verify(id, controller.signal);
132
+ }
133
+ catch (error) {
134
+ progress(`verify ${id} failed to run: ${error instanceof Error ? error.message : String(error)}`);
135
+ status = "FAIL";
136
+ }
137
+ if (controller.signal.aborted) {
138
+ break;
139
+ }
140
+ slot(id).verify = status;
141
+ if (status === "PASS") {
142
+ ready.push(id);
143
+ pump();
144
+ }
145
+ }
146
+ verifying = false;
147
+ pump();
148
+ };
149
+ void verifyAll();
150
+ pump();
151
+ await done;
152
+ if (!controller.signal.aborted) {
153
+ for (const id of retry) {
154
+ if (controller.signal.aborted) {
155
+ break;
156
+ }
157
+ await review(id, true);
158
+ }
159
+ }
160
+ const interrupted = controller.signal.aborted;
161
+ return ids.map((id) => {
162
+ const entry = slot(id);
163
+ const pendingRetry = retry.includes(id) && !entry.retried;
164
+ const unfinished = entry.review === null
165
+ ? entry.verify !== "FAIL"
166
+ : pendingRetry;
167
+ return {
168
+ id,
169
+ verify: entry.verify,
170
+ review: entry.review,
171
+ retried: entry.retried,
172
+ aborted: interrupted && unfinished
173
+ };
174
+ });
175
+ }
176
+ /**
177
+ * One probe per target for a whole batch. Concurrent callers share the probe
178
+ * in flight and a success is kept, but a failure is forgotten so the retry of
179
+ * a task re-probes instead of inheriting the failure.
180
+ */
181
+ export function memoizePreflight(probe) {
182
+ const cache = new Map();
183
+ return (target, budget) => {
184
+ const cached = cache.get(target);
185
+ if (cached !== undefined) {
186
+ return cached;
187
+ }
188
+ const pending = probe(target, budget);
189
+ cache.set(target, pending);
190
+ const forget = () => {
191
+ if (cache.get(target) === pending) {
192
+ cache.delete(target);
193
+ }
194
+ };
195
+ pending.then((answer) => {
196
+ if ((typeof answer === "string" ? answer : answer.result) !== "ok") {
197
+ forget();
198
+ }
199
+ }, forget);
200
+ return pending;
201
+ };
202
+ }
@@ -8,7 +8,7 @@ import { answeredNothing, conversationIdOf, deniedToolsOf, extractFinalMessage,
8
8
  import { buildTargetInvocation } from "./invocation.js";
9
9
  import { reviewReportResults, reviewReportStatus, validateReviewReport } from "./report.js";
10
10
  import { detectHostTarget, planReviewTargets } from "./roles.js";
11
- import { detectHostRestriction, isNetworkFailure } from "./host-sandbox.js";
11
+ import { detectHostRestriction, isLoginFailure, isNetworkFailure } from "./host-sandbox.js";
12
12
  import { buildAdversarialPrompt, buildReviewPrompt } from "./runner.js";
13
13
  import { MAX_ADVERSARIAL_PROMPT_BYTES } from "./runner.js";
14
14
  import { extractUsage, sumUsage } from "./usage.js";
@@ -149,6 +149,8 @@ function preflightUnavailableReason(result) {
149
149
  return "capability-unavailable";
150
150
  case "capability-unavailable":
151
151
  return "capability-unavailable";
152
+ case "probe-failed":
153
+ return "probe-failed";
152
154
  }
153
155
  }
154
156
  /**
@@ -157,7 +159,7 @@ function preflightUnavailableReason(result) {
157
159
  * suppressed under `--json`: a machine consumer would otherwise be left with
158
160
  * the bare authentication guess this exists to qualify. Never evidence.
159
161
  */
160
- function firstComplaint(...streams) {
162
+ export function firstComplaint(...streams) {
161
163
  for (const stream of streams) {
162
164
  const line = redactSecrets(stream)
163
165
  .split(/\r?\n/u)
@@ -176,9 +178,7 @@ function rejectedCallReason(output) {
176
178
  if (isNetworkFailure(output)) {
177
179
  return "network-unreachable";
178
180
  }
179
- if (/\b(?:not logged in|login required|log in to|authentication required|unauthenticated|unauthorized)\b/iu.test(output) ||
180
- /\b(?:invalid|expired)\s+(?:api key|token|credential)/iu.test(output) ||
181
- /\b401\b/u.test(output)) {
181
+ if (isLoginFailure(output)) {
182
182
  return "login-required";
183
183
  }
184
184
  return "capability-unavailable";
@@ -740,15 +740,18 @@ export function createReviewExecutor(options) {
740
740
  attempts: []
741
741
  };
742
742
  }
743
- const result = await options.preflightTarget(target, {
743
+ const answer = await options.preflightTarget(target, {
744
744
  timeoutMs: Math.max(1, remainingMs())
745
745
  });
746
+ const result = typeof answer === "string" ? answer : answer.result;
746
747
  if (result === "ok") {
747
748
  preflight.push({ target, status: "PASS" });
748
749
  continue;
749
750
  }
750
751
  const reason = preflightUnavailableReason(result);
751
- const diagnostic = `target preflight returned ${result}`;
752
+ const complaint = typeof answer === "string" ? undefined : answer.diagnostic;
753
+ const diagnostic = `target preflight returned ${result}` +
754
+ (complaint === undefined ? "" : `: ${complaint}`);
752
755
  preflight.push({ target, status: "NOT_RUN", reason, diagnostic });
753
756
  report(`${target}: preflight ${reason} (${diagnostic})`);
754
757
  return {
@@ -11,6 +11,15 @@ const NETWORK_FAILURE = /\b(?:ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AG
11
11
  export function isNetworkFailure(output) {
12
12
  return NETWORK_FAILURE.test(output);
13
13
  }
14
+ /**
15
+ * Only output that says so is a login problem. A CLI that dies for any other
16
+ * reason (a sandbox denying its state directory, a crash) used to be reported
17
+ * as "not logged in" too, which sent users to re-login a working install.
18
+ */
19
+ const LOGIN_FAILURE = /\b(?:not logged in|login required|log in to|authentication required|unauthenticated|unauthorized)\b|\b(?:invalid|expired)\s+(?:api key|token|credential)|\b401\b/iu;
20
+ export function isLoginFailure(output) {
21
+ return LOGIN_FAILURE.test(output);
22
+ }
14
23
  /** The host each reviewer talks to, so a reachability check tests its real path. */
15
24
  export const TARGET_API_HOST = {
16
25
  agy: "cloudcode-pa.googleapis.com",
@@ -161,11 +161,24 @@ export function buildTargetInvocation(request) {
161
161
  };
162
162
  }
163
163
  /** A deep doctor probe uses stdin but keeps its simple text response contract. */
164
+ /**
165
+ * What the probe runs with where it differs from a review. The probe asks for
166
+ * one word, not a review, and agy's plan mode tells the model to research the
167
+ * task first: on a real project it explores, runs a command the headless user
168
+ * never allowed, is soft-denied, and prints nothing, which reads as
169
+ * `probe-failed`. In 40 replays, plan mode failed 4 of 20 and without it all 20
170
+ * answered in exactly two model calls. Only the probe drops it; a review keeps
171
+ * `READ_ONLY_ARGS`, and the sandbox stays on for both.
172
+ */
173
+ const PROBE_ARGS = {
174
+ agy: ["--sandbox"]
175
+ };
164
176
  export function buildProbeInvocation(request) {
165
- const readOnly = READ_ONLY_ARGS[request.target];
166
- if (readOnly === undefined || readOnly.length === 0) {
177
+ const eligible = READ_ONLY_ARGS[request.target];
178
+ if (eligible === undefined || eligible.length === 0) {
167
179
  return undefined;
168
180
  }
181
+ const readOnly = PROBE_ARGS[request.target] ?? eligible;
169
182
  if (request.target === "codex") {
170
183
  return {
171
184
  command: "codex",