@expo/code-review-cli 0.3.0 → 0.5.0

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 (43) hide show
  1. package/README.md +307 -47
  2. package/build/cli.js +24 -17
  3. package/build/commands/ci.js +410 -43
  4. package/build/commands/dismiss.js +16 -16
  5. package/build/commands/doctor.js +219 -26
  6. package/build/commands/init.js +244 -34
  7. package/build/commands/review.js +118 -30
  8. package/build/commands/verify-config.js +252 -0
  9. package/build/config/load.js +200 -55
  10. package/build/config/routing.js +122 -0
  11. package/build/config/schema.js +153 -19
  12. package/build/core/auth.js +237 -75
  13. package/build/core/coordinator.js +7 -7
  14. package/build/core/diff.js +19 -19
  15. package/build/core/exec.js +10 -10
  16. package/build/core/log.js +3 -3
  17. package/build/core/noise.js +52 -52
  18. package/build/core/opencode.js +495 -95
  19. package/build/core/prompts.js +220 -150
  20. package/build/core/render.js +202 -48
  21. package/build/core/review.js +277 -102
  22. package/build/core/router.js +10 -10
  23. package/build/core/schema.js +26 -12
  24. package/build/core/step-summary.js +18 -0
  25. package/build/core/suppress.js +7 -7
  26. package/build/core/tools.js +9 -9
  27. package/build/core/util.js +2 -2
  28. package/build/core/verify.js +28 -26
  29. package/build/reporters/github.js +103 -51
  30. package/build/reporters/terminal.js +19 -19
  31. package/build/sources/github-pr.js +21 -21
  32. package/build/sources/local-git.js +20 -20
  33. package/build/sources/source.js +35 -1
  34. package/package.json +8 -3
  35. package/templates/agents/security.md +5 -0
  36. package/templates/command.yml +167 -0
  37. package/templates/config.jsonc +26 -13
  38. package/templates/coordinator.md +5 -3
  39. package/templates/dismiss.yml +110 -0
  40. package/templates/routing.jsonc +27 -0
  41. package/templates/scope-config.jsonc +25 -0
  42. package/templates/shared.md +12 -0
  43. package/templates/workflow.yml +61 -26
@@ -1,6 +1,8 @@
1
- import { createOpencode } from '@opencode-ai/sdk';
2
- import { toolMap } from './tools.js';
3
- import { errorMessage, sleep } from './util.js';
1
+ import { createRequire } from "node:module";
2
+ import path from "node:path";
3
+ import { createOpencode } from "@opencode-ai/sdk";
4
+ import { toolMap } from "./tools.js";
5
+ import { errorMessage, sleep } from "./util.js";
4
6
  /** Sum token usage across attempts (for per-task/run totals). */
5
7
  export function addTokenUsage(into, from) {
6
8
  if (!from) {
@@ -17,27 +19,32 @@ export function addTokenUsage(into, from) {
17
19
  }
18
20
  // The coordinator consolidates findings; it needs no repo tools.
19
21
  const COORDINATOR_TOOLS = toolMap([]);
22
+ // Every tool disabled, passed per-REQUEST (not per-agent) to make a "reply now,
23
+ // don't investigate" prompt physically unable to call tools. A prompt-level plea is
24
+ // not enough: in eas-cli#4084 the finalize ("stop and return what you have") reply
25
+ // opened 7 more files and then blew its window, losing the whole pass.
26
+ const NO_TOOLS = toolMap([]);
20
27
  // Agent id for the single combined cross-cutting pass (see review.ts). It MUST be
21
28
  // defined here so OpenCode uses this restricted tool set — otherwise the model
22
29
  // falls back to a default agent with full tools and crawls the whole repo, which
23
30
  // is why the cross-file pass used to wander for its entire time budget.
24
- export const CROSS_CUTTING_AGENT = 'cross-cutting';
31
+ export const CROSS_CUTTING_AGENT = "cross-cutting";
25
32
  // Deliberately NO `glob`/`list`: the cross-file pass is given the changed files'
26
33
  // patch paths already, and directory crawling is exactly what made it wander into
27
34
  // unrelated packages. `read` (open a known file) + `grep` (find a cross-reference
28
35
  // among the changed files) are enough to trace interactions.
29
- const CROSS_CUTTING_TOOLS = toolMap(['read', 'grep']);
36
+ const CROSS_CUTTING_TOOLS = toolMap(["read", "grep"]);
30
37
  // Verifies a finding by re-reading the actual file (adversarial refute pass). Same
31
38
  // restricted tool set — it opens the cited file and checks the claim.
32
- export const VERIFIER_AGENT = 'verifier';
33
- const VERIFIER_TOOLS = toolMap(['read', 'grep']);
39
+ export const VERIFIER_AGENT = "verifier";
40
+ const VERIFIER_TOOLS = toolMap(["read", "grep"]);
34
41
  /** Build the inline OpenCode config (agents + coordinator) from a repo config. */
35
42
  export function buildOpencodeConfig(config) {
36
43
  const agent = {};
37
44
  for (const reviewer of config.agents) {
38
45
  agent[reviewer.id] = {
39
46
  description: `${reviewer.id} reviewer`,
40
- mode: 'all',
47
+ mode: "all",
41
48
  model: reviewer.model,
42
49
  temperature: reviewer.temperature,
43
50
  prompt: `You are the ${reviewer.id} code reviewer. Follow the user message exactly and return only the requested JSON.`,
@@ -45,50 +52,261 @@ export function buildOpencodeConfig(config) {
45
52
  };
46
53
  }
47
54
  agent[CROSS_CUTTING_AGENT] = {
48
- description: 'Cross-file reviewer: issues spanning multiple changed files.',
49
- mode: 'all',
55
+ description: "Cross-file reviewer: issues spanning multiple changed files.",
56
+ mode: "all",
50
57
  // Use the default reviewing model (agents share it unless overridden).
51
58
  model: config.agents[0]?.model ?? config.coordinator.model,
52
59
  temperature: config.agents[0]?.temperature ?? 0.1,
53
- prompt: 'You are the cross-file code reviewer. Follow the user message exactly and return only the requested JSON.',
60
+ prompt: "You are the cross-file code reviewer. Follow the user message exactly and return only the requested JSON.",
54
61
  tools: CROSS_CUTTING_TOOLS,
55
62
  };
56
63
  agent[VERIFIER_AGENT] = {
57
- description: 'Verifies a finding against the real file (adversarial refute pass).',
58
- mode: 'all',
64
+ description: "Verifies a finding against the real file (adversarial refute pass).",
65
+ mode: "all",
59
66
  model: config.agents[0]?.model ?? config.coordinator.model,
60
67
  temperature: config.agents[0]?.temperature ?? 0.1,
61
- prompt: 'You verify code-review findings against the actual source. Follow the user message exactly and return only the requested JSON.',
68
+ prompt: "You verify code-review findings against the actual source. Follow the user message exactly and return only the requested JSON.",
62
69
  tools: VERIFIER_TOOLS,
63
70
  };
64
- agent['coordinator'] = {
65
- description: 'Consolidates specialist findings into one decision.',
66
- mode: 'all',
71
+ agent["coordinator"] = {
72
+ description: "Consolidates specialist findings into one decision.",
73
+ mode: "all",
67
74
  model: config.coordinator.model,
68
75
  temperature: config.coordinator.temperature,
69
- prompt: 'You are the review coordinator. Follow the user message exactly and return only the requested JSON.',
76
+ prompt: "You are the review coordinator. Follow the user message exactly and return only the requested JSON.",
70
77
  tools: COORDINATOR_TOOLS,
71
78
  };
72
- return { $schema: 'https://opencode.ai/config.json', agent };
79
+ // Synthesize a provider block for each upstream-alias auth entry, so one
80
+ // upstream can be reached with two credentials at once (e.g. "openai" on a
81
+ // ChatGPT/Codex subscription for the default models, plus an "openai-api"
82
+ // alias holding a metered API key for pro-tier models the subscription
83
+ // doesn't offer). The alias's model list is exactly the ids the roster
84
+ // references under that provider — OpenCode needs custom providers' models
85
+ // declared, and declaring only what's used keeps the preflight meaningful.
86
+ const provider = {};
87
+ const referencedModels = [
88
+ ...config.agents.map((reviewer) => reviewer.model),
89
+ config.coordinator.model,
90
+ ];
91
+ for (const entry of config.auth) {
92
+ if (!entry.upstream || !entry.tokenEnv) {
93
+ continue;
94
+ }
95
+ const models = {};
96
+ for (const id of referencedModels) {
97
+ const slash = id.indexOf("/");
98
+ if (slash > 0 && id.slice(0, slash) === entry.provider) {
99
+ models[id.slice(slash + 1)] = {};
100
+ }
101
+ }
102
+ provider[entry.provider] = {
103
+ npm: entry.upstream === "openai"
104
+ ? "@ai-sdk/openai"
105
+ : entry.upstream === "anthropic"
106
+ ? "@ai-sdk/anthropic"
107
+ : "@ai-sdk/openai-compatible",
108
+ name: entry.provider,
109
+ options: { apiKey: `{env:${entry.tokenEnv}}` },
110
+ models,
111
+ };
112
+ }
113
+ return {
114
+ $schema: "https://opencode.ai/config.json",
115
+ agent,
116
+ ...(Object.keys(provider).length > 0 ? { provider } : {}),
117
+ };
73
118
  }
74
119
  /** hey-api style responses come back as { data, error }; unwrap or throw. */
75
120
  function unwrap(res) {
76
- if (res && typeof res === 'object' && ('data' in res || 'error' in res)) {
121
+ if (res && typeof res === "object" && ("data" in res || "error" in res)) {
77
122
  if (res.error) {
78
- throw new Error(typeof res.error === 'string' ? res.error : JSON.stringify(res.error));
123
+ throw new Error(typeof res.error === "string" ? res.error : JSON.stringify(res.error));
79
124
  }
80
125
  return res.data;
81
126
  }
82
127
  return res;
83
128
  }
129
+ /**
130
+ * Directory holding the `opencode` binary from OUR dependency tree, or null if it
131
+ * can't be resolved.
132
+ *
133
+ * The SDK spawns the server with a bare `launch("opencode", …)`, i.e. whatever comes
134
+ * first on PATH. That silently couples every run to the machine's global install:
135
+ * in CI, `npx -p @expo/code-review-cli` puts the temp prefix's `.bin` first and the
136
+ * pinned version wins, but on a developer machine an older global `opencode` shadows
137
+ * it and the pair drifts (a 1.18.1 CLI against a 1.18.4 SDK surfaced as
138
+ * `ProviderModelNotFoundError: Model not found: anthropic/claude-opus-4-8` — the CLI
139
+ * resolving a model id the SDK considered valid). Prepending this directory to PATH
140
+ * makes the version we declare in package.json the version we actually run.
141
+ */
142
+ function bundledOpencodeBinDir() {
143
+ try {
144
+ const require = createRequire(import.meta.url);
145
+ // <…>/node_modules/opencode-ai/package.json → <…>/node_modules/.bin, which holds
146
+ // the correctly-named `opencode` shim (the package's own bin file is
147
+ // `opencode.exe`, so the package dir itself is NOT a usable PATH entry).
148
+ const pkg = require.resolve("opencode-ai/package.json");
149
+ return path.join(path.dirname(pkg), "..", ".bin");
150
+ }
151
+ catch {
152
+ return null;
153
+ }
154
+ }
155
+ /** The `opencode` the SDK will actually spawn: ours if resolvable, else PATH's. */
156
+ export function opencodeBinSource() {
157
+ const dir = bundledOpencodeBinDir();
158
+ return { dir, pinned: dir !== null };
159
+ }
84
160
  /** Start an in-process OpenCode server with the given inline config. */
85
161
  export async function startOpencode(config) {
162
+ // Make our pinned CLI win over any global install (see bundledOpencodeBinDir).
163
+ // The SDK takes no `env`, so PATH is the only lever; it spreads `process.env` at
164
+ // spawn time, so setting it here reaches the child.
165
+ const binDir = bundledOpencodeBinDir();
166
+ if (binDir) {
167
+ process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ""}`;
168
+ }
86
169
  const { client, server } = await createOpencode({
87
- hostname: '127.0.0.1',
170
+ hostname: "127.0.0.1",
171
+ // Port 0 = let the OS pick a free one. The SDK defaults to a FIXED 4096 and reads
172
+ // the real URL back from the server's startup line, so the default meant any
173
+ // already-running opencode (a developer's own session is the common case) made
174
+ // every local run die with an opaque `ServeError`.
175
+ port: 0,
88
176
  config: config,
89
177
  });
90
178
  return { client, url: server.url, close: () => server.close() };
91
179
  }
180
+ /** `provider/model` as the server reported it, or undefined if it reported neither. */
181
+ export function formatModel(providerID, modelID) {
182
+ if (!providerID && !modelID) {
183
+ return undefined;
184
+ }
185
+ return `${providerID ?? "?"}/${modelID ?? "?"}`;
186
+ }
187
+ /**
188
+ * Check configured model ids against what the server can resolve. Pure so the
189
+ * matching rules are testable without a live server.
190
+ *
191
+ * A wrong model id is a CONFIG error: it hits every pass identically, no retry or
192
+ * smaller scope can fix it, and the run should say so once instead of reporting N
193
+ * indistinguishable "pass failed" gaps (or, worse, burning the whole budget first).
194
+ */
195
+ export function findUnknownModels(models, available,
196
+ /** Provider(s) we supplied a credential for, if any (see reason: "credential"). */
197
+ credentialedProvider) {
198
+ const credentialed = new Set(typeof credentialedProvider === "string"
199
+ ? [credentialedProvider]
200
+ : (credentialedProvider ?? []));
201
+ const unknown = [];
202
+ for (const model of new Set(models)) {
203
+ // OpenCode model ids are `provider/model`; a model id may itself contain slashes
204
+ // (e.g. openrouter's `vendor/name`), so only the FIRST segment is the provider.
205
+ const slash = model.indexOf("/");
206
+ const providerID = slash === -1 ? model : model.slice(0, slash);
207
+ const modelID = slash === -1 ? "" : model.slice(slash + 1);
208
+ const providerModels = available[providerID];
209
+ if (!providerModels) {
210
+ unknown.push({
211
+ model,
212
+ // We configured this provider's credential and the server still doesn't
213
+ // offer it ⇒ the credential was refused, not the provider misnamed.
214
+ reason: credentialed.has(providerID) ? "credential" : "provider",
215
+ suggestions: Object.keys(available).sort(),
216
+ });
217
+ continue;
218
+ }
219
+ if (!providerModels.includes(modelID)) {
220
+ // Suggest ids that share a prefix with what was asked for, else the whole list.
221
+ const stem = modelID.split(/[-/]/)[0] ?? "";
222
+ const near = stem ? providerModels.filter((id) => id.startsWith(stem)) : [];
223
+ unknown.push({
224
+ model,
225
+ reason: "model",
226
+ suggestions: (near.length > 0 ? near : providerModels).slice(0, 8).sort(),
227
+ });
228
+ }
229
+ }
230
+ return unknown;
231
+ }
232
+ /** Ask the running server which providers/models it can resolve. */
233
+ export async function fetchProviderModels(handle) {
234
+ const data = unwrap(await handle.client.config.providers());
235
+ const available = {};
236
+ for (const provider of data?.providers ?? []) {
237
+ if (provider?.id) {
238
+ available[provider.id] = Object.keys(provider.models ?? {});
239
+ }
240
+ }
241
+ return available;
242
+ }
243
+ /** Human-readable, copy-pasteable explanation of unresolvable model ids. */
244
+ export function formatUnknownModels(unknown, auths) {
245
+ // A refused credential is one fact about the run, not one per model: report it once
246
+ // and name the token to check, rather than repeating it for every configured model.
247
+ const refused = unknown.filter((entry) => entry.reason === "credential");
248
+ if (refused.length > 0) {
249
+ const refusedProvider = refused[0].model.split("/")[0];
250
+ const entries = auths ? (Array.isArray(auths) ? auths : [auths]) : [];
251
+ const auth = entries.find((entry) => entry.provider === refusedProvider) ?? entries[0];
252
+ const provider = auth?.provider ?? refusedProvider;
253
+ const tokenEnv = auth?.tokenEnv;
254
+ // Do NOT blame the token alone: the most common causes have nothing to do with
255
+ // the credential's validity (see below). An earlier version of this message sent
256
+ // us to re-issue two perfectly good tokens.
257
+ const oauth = auth?.mode === "oauth";
258
+ const deadOauth = oauth && provider === "anthropic";
259
+ return (`The OpenCode server does not offer the "${provider}" provider, even though this run ` +
260
+ `supplied a ${auth?.mode ?? "configured"} credential for it. OpenCode drops a provider whose ` +
261
+ `credential it could not use, which makes every ${provider} model look nonexistent: ` +
262
+ `${refused.map((entry) => entry.model).join(", ")}.\n` +
263
+ `The credential itself is often FINE. Check these in order:\n` +
264
+ (deadOauth
265
+ ? ` 1. anthropic OAuth cannot work through OpenCode at all. Anthropic does not permit ` +
266
+ `Pro/Max subscription tokens in third-party tools, and OpenCode (since 1.3.0) ships no ` +
267
+ `anthropic OAuth support — an oauth credential never registers the provider, no matter ` +
268
+ `how valid the token is. Switch auth in .expo-code-review/config.jsonc to ` +
269
+ `{ "mode": "api-key", "provider": "anthropic", "tokenEnv": "ANTHROPIC_API_KEY" } with a ` +
270
+ `Console API key, or run with REVIEWER_MODEL set to a model you are logged into ` +
271
+ `(e.g. REVIEWER_MODEL=openai/gpt-5.5).\n`
272
+ : "") +
273
+ (tokenEnv
274
+ ? ` ${deadOauth ? "2" : "1"}. The credential is wrong for the mode. ` +
275
+ `auth.mode "api-key" expects a plain API key for ${provider}; an OAuth/subscription ` +
276
+ `token is not an API key. A truncated or half-pasted ${tokenEnv} fails the same way.\n`
277
+ : ` ${deadOauth ? "2" : "1"}. The credential is wrong for the configured auth.mode.\n`) +
278
+ `Providers the server does offer: ${refused[0].suggestions.join(", ") || "(none)"}.`);
279
+ }
280
+ const lines = unknown.map((entry) => entry.reason === "provider"
281
+ ? ` ${entry.model} — unknown provider "${entry.model.split("/")[0]}". Configured providers: ${entry.suggestions.join(", ") || "(none — is the credential set?)"}`
282
+ : ` ${entry.model} — that provider has no such model. Close matches: ${entry.suggestions.join(", ") || "(none)"}`);
283
+ return (`The configured model id(s) do not exist on the running OpenCode server:\n${lines.join("\n")}\n` +
284
+ `Fix the model in .expo-code-review/config.jsonc (agents' \`model\`, \`coordinator.model\`) ` +
285
+ `or REVIEWER_MODEL. Note that a model id must be "provider/model" (e.g. anthropic/claude-sonnet-5), ` +
286
+ `and that an out-of-date \`opencode\` can reject an id a newer one accepts — run \`ecr doctor\`.`);
287
+ }
288
+ /**
289
+ * Fail fast when a configured model can't be resolved, BEFORE any pass runs. Never
290
+ * blocks the run on its own failure: if the providers endpoint can't be read (an
291
+ * older server, a transport blip), the review proceeds and any real problem surfaces
292
+ * per-pass as before.
293
+ */
294
+ export async function assertModelsResolvable(handle, models, auths) {
295
+ let available;
296
+ try {
297
+ available = await fetchProviderModels(handle);
298
+ }
299
+ catch {
300
+ return;
301
+ }
302
+ if (Object.keys(available).length === 0) {
303
+ return;
304
+ }
305
+ const unknown = findUnknownModels(models, available, auths?.map((entry) => entry.provider));
306
+ if (unknown.length > 0) {
307
+ throw new Error(formatUnknownModels(unknown, auths));
308
+ }
309
+ }
92
310
  const POLL_INTERVAL_MS = 1000;
93
311
  // Emit a "still working" heartbeat if this long passes with no tool activity, so
94
312
  // a long model-thinking stretch doesn't look hung in the logs.
@@ -100,12 +318,62 @@ const HEARTBEAT_MS = 45_000;
100
318
  // over. Callers must treat AgentTimeoutError as "abandon", never "retry".
101
319
  const DEFAULT_MAX_WAIT_MS = 8 * 60 * 1000;
102
320
  // Extra budget for the "stop and summarize what you have" finalization prompt.
103
- const FINALIZE_WAIT_MS = 90 * 1000;
104
- const FINALIZE_PROMPT = 'You have reached your time budget. STOP investigating now do NOT read, grep, ' +
105
- 'glob, list, or open any more files, and do not call any tools. Based ONLY on ' +
106
- 'what you have already examined, reply with the single JSON object exactly as ' +
107
- 'specified in your instructions, containing whatever findings you are already ' +
108
- 'confident about. If you have nothing solid, return an empty findings array.';
321
+ // Deliberately generous: this is the ONLY chance to salvage a pass that ran out of
322
+ // time, and at 90s it was losing that race. Tools are disabled for the request
323
+ // (NO_TOOLS), so the reply is a single emit and normally lands in seconds.
324
+ const FINALIZE_WAIT_MS = 3 * 60 * 1000;
325
+ // ---- stall detection ----
326
+ //
327
+ // A pass is STALLED when its in-progress assistant message stops changing at all:
328
+ // no new tool call, no streamed text or reasoning, no token growth. A model that is
329
+ // genuinely thinking still grows that message every few seconds, so a gap this long
330
+ // means the provider request is wedged (or stuck in an internal retry we cannot
331
+ // see) — not that the work is hard.
332
+ //
333
+ // Motivating incident (eas-cli#4084, 2026-07-26): the cross-file pass ran 7 `read`
334
+ // calls in its first 6 seconds and then sat completely silent for 25 minutes,
335
+ // recording ZERO tokens (input, output, reasoning and cache all 0 in the run log),
336
+ // until its wall-clock cap fired. The finalize salvage then also went silent, so the
337
+ // pass's entire work product was lost and the PR got a coverage gap. Nothing in the
338
+ // run distinguished "wedged" from "thinking" — the heartbeat just printed elapsed
339
+ // seconds. A wall-clock cap alone cannot fix this: raising it only buys a longer
340
+ // silence, which is why the cap is NOT the lever here.
341
+ //
342
+ // Unlike the wall-clock cap (non-convergence → abandon, never retry), a stall is
343
+ // transient, so it earns ONE clean-slate retry inside the pass's existing budget.
344
+ const STALL_MS = 4 * 60 * 1000;
345
+ // Never let the watchdog outlast the pass it guards: a short pass (the 3m verifier,
346
+ // a 4m no-tools fallback) would otherwise hit its deadline before the watchdog could
347
+ // fire, and get none of this protection. Half the cap, with a floor that leaves room
348
+ // for a slow first token.
349
+ const MIN_STALL_MS = 30 * 1000;
350
+ /** Exported for tests. */
351
+ export function stallWindowMs(maxWaitMs) {
352
+ return Math.min(STALL_MS, Math.max(MIN_STALL_MS, Math.floor(maxWaitMs / 2)));
353
+ }
354
+ // The finalize reply does no investigation and cannot call tools, so it should
355
+ // stream within seconds; a much shorter silence already means wedged.
356
+ const FINALIZE_STALL_MS = 60 * 1000;
357
+ // Breathing room before the retry: if the silence came from provider-side throttling
358
+ // or backoff, reconnecting instantly is the worst move.
359
+ const STALL_RETRY_BACKOFF_MS = 20 * 1000;
360
+ // Only retry when enough of the pass's budget remains for the fresh attempt to
361
+ // plausibly finish; otherwise go straight to the soft landing.
362
+ const STALL_RETRY_MIN_REMAINING_MS = STALL_MS + 60 * 1000;
363
+ /**
364
+ * What to do about a stalled attempt: start over from a clean session, or stop and
365
+ * try to salvage findings. Exactly ONE retry, and only with enough budget left for it
366
+ * to land — a second wedged attempt would just spend the rest of the pass's window,
367
+ * which is the failure this whole mechanism exists to end. Exported for tests.
368
+ */
369
+ export function stallAction(attempt, remainingMs) {
370
+ return attempt === 0 && remainingMs > STALL_RETRY_MIN_REMAINING_MS ? "retry" : "soft-land";
371
+ }
372
+ const FINALIZE_PROMPT = "You have reached your time budget. STOP investigating now — do NOT read, grep, " +
373
+ "glob, list, or open any more files, and do not call any tools. Based ONLY on " +
374
+ "what you have already examined, reply with the single JSON object exactly as " +
375
+ "specified in your instructions, containing whatever findings you are already " +
376
+ "confident about. If you have nothing solid, return an empty findings array.";
109
377
  /**
110
378
  * Internal signal that a poll loop passed its deadline. Carries the best-effort
111
379
  * cost/tokens of the in-progress (never-completed) assistant message so a
@@ -115,12 +383,52 @@ class DeadlineReached extends Error {
115
383
  cost;
116
384
  tokens;
117
385
  constructor(cost = 0, tokens) {
118
- super('deadline reached');
386
+ super("deadline reached");
387
+ this.cost = cost;
388
+ this.tokens = tokens;
389
+ }
390
+ }
391
+ /**
392
+ * Internal signal that an in-progress reply went silent (see STALL_MS). Distinct
393
+ * from DeadlineReached: the pass's time budget is NOT spent, so the caller can spend
394
+ * what's left on a fresh attempt instead of abandoning the work.
395
+ */
396
+ class NoProgress extends Error {
397
+ cost;
398
+ tokens;
399
+ idleMs;
400
+ constructor(cost = 0, tokens,
401
+ /** How long the reply had been unchanged when we gave up on it. */
402
+ idleMs = 0) {
403
+ super("no progress");
119
404
  this.cost = cost;
120
405
  this.tokens = tokens;
406
+ this.idleMs = idleMs;
121
407
  }
122
408
  }
123
- const DEADLINE_SENTINEL = Symbol('deadline');
409
+ /**
410
+ * A cheap signature of how far along an in-progress reply is: its parts (count,
411
+ * type, streamed length, tool status) plus the message's token/cost counters. ANY
412
+ * real progress — a new tool call, another chunk of text or reasoning, a tool
413
+ * advancing pending → running → completed — changes it; a wedged request leaves it
414
+ * byte-identical poll after poll. Exported for tests.
415
+ */
416
+ export function progressFingerprint(message) {
417
+ const shape = (message.parts ?? [])
418
+ .map((part) => `${part.type ?? ""}:${(part.text ?? "").length}:${part.state?.status ?? ""}`)
419
+ .join("|");
420
+ const tokens = message.info?.tokens;
421
+ return [
422
+ shape,
423
+ tokens?.input ?? 0,
424
+ tokens?.output ?? 0,
425
+ tokens?.reasoning ?? 0,
426
+ tokens?.cache?.read ?? 0,
427
+ tokens?.cache?.write ?? 0,
428
+ message.info?.cost ?? 0,
429
+ ].join("~");
430
+ }
431
+ const DEADLINE_SENTINEL = Symbol("deadline");
124
432
  /**
125
433
  * Race a promise against the poll deadline. Without this, a stalled message fetch
126
434
  * (a wedged/overloaded OpenCode server) blocks the poll loop past its deadline,
@@ -134,7 +442,7 @@ async function raceDeadline(work, deadline) {
134
442
  return DEADLINE_SENTINEL;
135
443
  }
136
444
  let timer;
137
- const timeout = new Promise(resolve => {
445
+ const timeout = new Promise((resolve) => {
138
446
  timer = setTimeout(() => resolve(DEADLINE_SENTINEL), remaining);
139
447
  });
140
448
  try {
@@ -153,11 +461,22 @@ async function raceDeadline(work, deadline) {
153
461
  export class AgentTimeoutError extends Error {
154
462
  cost;
155
463
  tokens;
156
- constructor(agent, minutes, cost = 0, tokens) {
157
- super(`Agent "${agent}" timed out after ${minutes} minutes (including finalize)`);
158
- this.name = 'AgentTimeoutError';
464
+ /**
465
+ * Why the pass was abandoned: it investigated without converging ("time"), or its
466
+ * model request went silent and did not recover after a fresh attempt ("stall").
467
+ * The caller's handling is the same (abandon), but the two have different causes —
468
+ * a wander is ours to bound, a stall is the provider's — so logs and coverage
469
+ * notes must not conflate them.
470
+ */
471
+ reason;
472
+ constructor(agent, minutes, cost = 0, tokens, reason = "time") {
473
+ super(reason === "stall"
474
+ ? `Agent "${agent}" stalled: its model request went silent and produced nothing after a retry (${minutes} minutes)`
475
+ : `Agent "${agent}" timed out after ${minutes} minutes (including finalize)`);
476
+ this.name = "AgentTimeoutError";
159
477
  this.cost = cost;
160
478
  this.tokens = tokens;
479
+ this.reason = reason;
161
480
  }
162
481
  }
163
482
  /**
@@ -171,72 +490,117 @@ export class AgentTimeoutError extends Error {
171
490
  * message completes.
172
491
  */
173
492
  export async function promptAgent(handle, args) {
174
- const session = unwrap(await handle.client.session.create({ body: { title: args.title } }));
175
- const reportedTools = new Set();
176
- await sendSessionPrompt(handle, session.id, {
177
- agent: args.agent,
178
- system: args.system,
179
- text: args.text,
180
- });
181
493
  const maxWaitMs = args.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
182
- try {
183
- return await pollForCompletion(handle, session.id, {
184
- agent: args.agent,
185
- fromIndex: 0,
186
- deadline: Date.now() + maxWaitMs,
187
- onActivity: args.onActivity,
188
- reportedTools,
189
- maxToolCalls: args.maxToolCalls,
190
- });
191
- }
192
- catch (error) {
193
- if (!(error instanceof DeadlineReached)) {
194
- throw error;
195
- }
196
- // Cost/tokens burned during the (never-completed) investigation, so the
197
- // finalize reply or the abandon path still accounts for them.
198
- const spentCost = error.cost;
199
- const spentTokens = error.tokens;
200
- // Time budget hit. Interrupt the wandering run first.
201
- await abortQuietly(handle, session.id);
494
+ // ONE deadline for the whole pass, shared by the first attempt and any stall
495
+ // retry, so retrying a wedged request can never push the pass past its declared
496
+ // cap (the budget math in review.ts depends on that being true).
497
+ const deadline = Date.now() + maxWaitMs;
498
+ // Spend from abandoned attempts, carried forward so a wedged attempt's cost and
499
+ // tokens still land in the run's metrics instead of vanishing.
500
+ let carriedCost = 0;
501
+ let carriedTokens;
502
+ const carry = (result) => ({
503
+ ...result,
504
+ cost: result.cost + carriedCost,
505
+ tokens: addTokenUsage(addTokenUsage({}, carriedTokens), result.tokens),
506
+ });
507
+ const absorb = (spent) => {
508
+ carriedCost += spent.cost;
509
+ carriedTokens = addTokenUsage(addTokenUsage({}, carriedTokens), spent.tokens);
510
+ };
511
+ /**
512
+ * Last chance to get something out of a pass that hit its ceiling: interrupt the
513
+ * run and ask the SAME (context-carrying) session for whatever it already has.
514
+ * Tools are disabled for this request, so it cannot resume investigating — the
515
+ * only thing it can do is emit.
516
+ */
517
+ const softLand = async (sessionID, reason, reportedTools) => {
518
+ await abortQuietly(handle, sessionID);
519
+ const minutes = Math.round(maxWaitMs / 60000);
202
520
  if (!args.finalizeOnTimeout) {
203
- throw new AgentTimeoutError(args.agent, Math.round(maxWaitMs / 60000), spentCost, spentTokens);
521
+ throw new AgentTimeoutError(args.agent, minutes, carriedCost, carriedTokens, reason);
204
522
  }
205
- // Soft landing: ask the (same, context-carrying) session to return whatever
206
- // it has now. Only messages after this point count as the answer.
207
- const baseline = (await fetchMessages(handle, session.id)).length;
208
- args.onActivity?.('time budget reached — asking for findings so far');
209
- await sendSessionPrompt(handle, session.id, {
523
+ // Only messages after this point count as the answer.
524
+ const baseline = (await fetchMessages(handle, sessionID)).length;
525
+ args.onActivity?.(reason === "stall"
526
+ ? "no output after a retry — asking for findings so far"
527
+ : "time budget reached — asking for findings so far");
528
+ await sendSessionPrompt(handle, sessionID, {
210
529
  agent: args.agent,
211
530
  system: args.system,
212
531
  text: FINALIZE_PROMPT,
532
+ tools: NO_TOOLS,
213
533
  });
214
534
  try {
215
- const result = await pollForCompletion(handle, session.id, {
535
+ const result = await pollForCompletion(handle, sessionID, {
216
536
  agent: args.agent,
217
537
  fromIndex: baseline,
218
538
  deadline: Date.now() + FINALIZE_WAIT_MS,
219
539
  onActivity: args.onActivity,
220
540
  reportedTools,
541
+ stallMs: FINALIZE_STALL_MS,
221
542
  });
222
- return {
223
- ...result,
224
- cost: result.cost + spentCost,
225
- tokens: addTokenUsage(addTokenUsage({}, spentTokens), result.tokens),
226
- truncated: true,
227
- };
543
+ return { ...carry(result), truncated: true };
228
544
  }
229
545
  catch (finalizeError) {
230
- if (finalizeError instanceof DeadlineReached) {
231
- await abortQuietly(handle, session.id);
232
- throw new AgentTimeoutError(args.agent, Math.round((maxWaitMs + FINALIZE_WAIT_MS) / 60000), spentCost + finalizeError.cost, addTokenUsage(addTokenUsage({}, spentTokens), finalizeError.tokens));
546
+ if (finalizeError instanceof DeadlineReached || finalizeError instanceof NoProgress) {
547
+ await abortQuietly(handle, sessionID);
548
+ absorb(finalizeError);
549
+ throw new AgentTimeoutError(args.agent, Math.round((maxWaitMs + FINALIZE_WAIT_MS) / 60000), carriedCost, carriedTokens, reason);
233
550
  }
234
551
  throw finalizeError;
235
552
  }
553
+ };
554
+ for (let attempt = 0;; attempt++) {
555
+ const session = unwrap(await handle.client.session.create({
556
+ body: { title: attempt === 0 ? args.title : `${args.title}-retry${attempt}` },
557
+ }));
558
+ const reportedTools = new Set();
559
+ await sendSessionPrompt(handle, session.id, {
560
+ agent: args.agent,
561
+ system: args.system,
562
+ text: args.text,
563
+ });
564
+ try {
565
+ return carry(await pollForCompletion(handle, session.id, {
566
+ agent: args.agent,
567
+ fromIndex: 0,
568
+ deadline,
569
+ onActivity: args.onActivity,
570
+ reportedTools,
571
+ maxToolCalls: args.maxToolCalls,
572
+ stallMs: stallWindowMs(maxWaitMs),
573
+ }));
574
+ }
575
+ catch (error) {
576
+ // Went silent. The request is wedged, not slow, so the first move is a clean
577
+ // slate — a fresh session, not the finalize prompt, which would be asking the
578
+ // wedged request to answer. Exactly one retry, and only when enough budget
579
+ // remains for it to land; after that the finalize is still worth a try as the
580
+ // only remaining salvage (in eas-cli#4084 the session did respond once aborted).
581
+ if (error instanceof NoProgress) {
582
+ await abortQuietly(handle, session.id);
583
+ absorb(error);
584
+ const remaining = deadline - Date.now();
585
+ if (stallAction(attempt, remaining) === "retry") {
586
+ args.onActivity?.(`stalled — no output for ${Math.round(error.idleMs / 1000)}s; ` +
587
+ `retrying once from a clean session (${Math.round(remaining / 60000)}m of budget left)`);
588
+ await sleep(STALL_RETRY_BACKOFF_MS);
589
+ continue;
590
+ }
591
+ return await softLand(session.id, "stall", reportedTools);
592
+ }
593
+ // Ran the clock down while investigating: converge on what it has.
594
+ if (error instanceof DeadlineReached) {
595
+ absorb(error);
596
+ return await softLand(session.id, "time", reportedTools);
597
+ }
598
+ throw error;
599
+ }
236
600
  }
237
601
  }
238
- const CORRECTIVE = '\n\nIMPORTANT: your previous reply could not be parsed. Reply with ONLY the single ' +
239
- 'JSON object described above — no prose, no code fences, no partial output.';
602
+ const CORRECTIVE = "\n\nIMPORTANT: your previous reply could not be parsed. Reply with ONLY the single " +
603
+ "JSON object described above — no prose, no code fences, no partial output.";
240
604
  // Budget for a corrective "re-emit the JSON" reply — no fresh investigation, so
241
605
  // it should return almost immediately.
242
606
  const CORRECTIVE_WAIT_MS = 2 * 60 * 1000;
@@ -270,7 +634,7 @@ export function isTransientApiError(error) {
270
634
  return false;
271
635
  }
272
636
  const message = errorMessage(error);
273
- return TRANSIENT_PATTERNS.some(pattern => pattern.test(message));
637
+ return TRANSIENT_PATTERNS.some((pattern) => pattern.test(message));
274
638
  }
275
639
  /**
276
640
  * Run a model call, retrying with bounded backoff on a transient API error. This
@@ -307,16 +671,19 @@ async function withTransientRetry(label, onActivity, fn) {
307
671
  export async function promptAndParse(handle, args, parse) {
308
672
  let cost = 0;
309
673
  let truncated = false;
674
+ let model;
310
675
  const tokens = {};
311
676
  const record = (result) => {
312
677
  cost += result.cost;
313
678
  truncated = truncated || (result.truncated ?? false);
314
679
  addTokenUsage(tokens, result.tokens);
680
+ // Keep the model from whichever attempt actually answered.
681
+ model = result.model ?? model;
315
682
  };
316
683
  const first = await withTransientRetry(`Agent "${args.agent}"`, args.onActivity, () => promptAgent(handle, args));
317
684
  record(first);
318
685
  try {
319
- return { value: parse(first.text), cost, truncated, tokens };
686
+ return { value: parse(first.text), cost, truncated, tokens, model };
320
687
  }
321
688
  catch {
322
689
  // Same-session corrective retry: send the nudge as a follow-up and wait for
@@ -327,6 +694,9 @@ export async function promptAndParse(handle, args, parse) {
327
694
  agent: args.agent,
328
695
  system: args.system,
329
696
  text: CORRECTIVE,
697
+ // Re-emit only: the investigation is done, so no tools are needed and
698
+ // disabling them keeps the corrective from turning into a second wander.
699
+ tools: NO_TOOLS,
330
700
  });
331
701
  const retry = await pollForCompletion(handle, first.sessionID, {
332
702
  agent: args.agent,
@@ -334,9 +704,10 @@ export async function promptAndParse(handle, args, parse) {
334
704
  deadline: Date.now() + CORRECTIVE_WAIT_MS,
335
705
  onActivity: args.onActivity,
336
706
  reportedTools: new Set(),
707
+ stallMs: FINALIZE_STALL_MS,
337
708
  });
338
709
  record(retry);
339
- return { value: parse(retry.text), cost, truncated, tokens };
710
+ return { value: parse(retry.text), cost, truncated, tokens, model };
340
711
  }
341
712
  catch {
342
713
  // Fresh-session last resort: a clean slate for a genuinely confused run.
@@ -347,7 +718,7 @@ export async function promptAndParse(handle, args, parse) {
347
718
  });
348
719
  record(fresh);
349
720
  try {
350
- return { value: parse(fresh.text), cost, truncated, tokens };
721
+ return { value: parse(fresh.text), cost, truncated, tokens, model };
351
722
  }
352
723
  catch (finalError) {
353
724
  throw new Error(`Agent "${args.agent}" did not return parseable JSON after retries: ${finalError instanceof Error ? finalError.message : String(finalError)}`);
@@ -364,7 +735,8 @@ async function sendSessionPrompt(handle, sessionID, args) {
364
735
  body: {
365
736
  agent: args.agent,
366
737
  system: args.system,
367
- parts: [{ type: 'text', text: args.text }],
738
+ parts: [{ type: "text", text: args.text }],
739
+ ...(args.tools ? { tools: args.tools } : {}),
368
740
  },
369
741
  }));
370
742
  }
@@ -380,7 +752,8 @@ async function abortQuietly(handle, sessionID) {
380
752
  * Poll a session for the first assistant message at or after `fromIndex` to
381
753
  * complete. `fromIndex` lets a follow-up prompt (finalize, corrective retry)
382
754
  * skip the earlier completed message and wait for the NEW reply instead. Throws
383
- * DeadlineReached once `deadline` passes.
755
+ * DeadlineReached once `deadline` passes, or NoProgress once the reply has been
756
+ * unchanged for `stallMs` (see STALL_MS).
384
757
  */
385
758
  async function pollForCompletion(handle, sessionID, opts) {
386
759
  // Best-effort usage of the in-progress assistant message, so a task that times
@@ -389,6 +762,11 @@ async function pollForCompletion(handle, sessionID, opts) {
389
762
  let lastTokens;
390
763
  const startedAt = Date.now();
391
764
  let lastEmitAt = startedAt;
765
+ // Stall watchdog state: when the reply last changed in any way, and what it looked
766
+ // like then. Starts at "now" so a prompt that never produces an assistant message
767
+ // at all (a wedged submission) also trips the watchdog.
768
+ let lastProgressAt = startedAt;
769
+ let lastFingerprint = "";
392
770
  const emit = (line) => {
393
771
  lastEmitAt = Date.now();
394
772
  opts.onActivity?.(line);
@@ -399,9 +777,14 @@ async function pollForCompletion(handle, sessionID, opts) {
399
777
  }
400
778
  await sleep(POLL_INTERVAL_MS);
401
779
  // Heartbeat if nothing has been reported for a while (e.g. the model is
402
- // reasoning without calling tools), so a long pass doesn't look hung.
780
+ // reasoning without calling tools), so a long pass doesn't look hung. Say how
781
+ // long the reply has been unchanged, not just how long the pass has run — that
782
+ // distinction is what tells a slow investigation apart from a wedged request,
783
+ // and its absence is why eas-cli#4084 took a forensic dig to explain.
403
784
  if (opts.onActivity && Date.now() - lastEmitAt >= HEARTBEAT_MS) {
404
- emit(`still working… ${Math.round((Date.now() - startedAt) / 1000)}s elapsed`);
785
+ const idleMs = Date.now() - lastProgressAt;
786
+ emit(`still working… ${Math.round((Date.now() - startedAt) / 1000)}s elapsed` +
787
+ (idleMs >= HEARTBEAT_MS ? ` (no new output for ${Math.round(idleMs / 1000)}s)` : ""));
405
788
  }
406
789
  // Bound the fetch by the deadline: a stalled server can't push the task past
407
790
  // its time cap (the overshoot we saw when the server was overloaded).
@@ -410,28 +793,34 @@ async function pollForCompletion(handle, sessionID, opts) {
410
793
  throw new DeadlineReached(lastCost, lastTokens);
411
794
  }
412
795
  const recent = messages.slice(opts.fromIndex);
413
- const assistant = [...recent].reverse().find(message => message.info?.role === 'assistant');
796
+ const assistant = [...recent].reverse().find((message) => message.info?.role === "assistant");
414
797
  if (!assistant) {
415
798
  continue;
416
799
  }
417
- if (typeof assistant.info?.cost === 'number') {
800
+ if (typeof assistant.info?.cost === "number") {
418
801
  lastCost = assistant.info.cost;
419
802
  }
420
803
  if (assistant.info?.tokens) {
421
804
  lastTokens = assistant.info.tokens;
422
805
  }
806
+ // Stall watchdog: did the reply change AT ALL since the last poll?
807
+ const fingerprint = progressFingerprint(assistant);
808
+ if (fingerprint !== lastFingerprint) {
809
+ lastFingerprint = fingerprint;
810
+ lastProgressAt = Date.now();
811
+ }
423
812
  // Track each distinct tool call once (for the tool-call cap) and, the first
424
813
  // time it starts, emit a live line so a long run shows what the agent is doing.
425
814
  for (const part of assistant.parts ?? []) {
426
- if (part?.type !== 'tool') {
815
+ if (part?.type !== "tool") {
427
816
  continue;
428
817
  }
429
818
  const key = part.callID ?? part.id;
430
819
  const status = part.state?.status;
431
- if (key && status && status !== 'pending' && !opts.reportedTools.has(key)) {
820
+ if (key && status && status !== "pending" && !opts.reportedTools.has(key)) {
432
821
  opts.reportedTools.add(key);
433
822
  if (opts.onActivity) {
434
- const tool = part.tool ?? 'tool';
823
+ const tool = part.tool ?? "tool";
435
824
  const title = part.state?.title;
436
825
  emit(title ? `${tool}: ${title}` : tool);
437
826
  }
@@ -444,15 +833,16 @@ async function pollForCompletion(handle, sessionID, opts) {
444
833
  // work is done, so there's nothing to finalize.
445
834
  if (assistant.info?.time?.completed != null) {
446
835
  const text = (assistant.parts ?? [])
447
- .filter(part => part?.type === 'text' && typeof part.text === 'string')
448
- .map(part => part.text)
449
- .join('\n')
836
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
837
+ .map((part) => part.text)
838
+ .join("\n")
450
839
  .trim();
451
840
  return {
452
841
  text,
453
842
  cost: assistant.info?.cost ?? 0,
454
843
  sessionID,
455
844
  tokens: assistant.info?.tokens,
845
+ model: formatModel(assistant.info?.providerID, assistant.info?.modelID),
456
846
  };
457
847
  }
458
848
  // Still in progress: enforce the tool-call cap. An agent that has made this
@@ -462,5 +852,15 @@ async function pollForCompletion(handle, sessionID, opts) {
462
852
  emit(`made ${opts.reportedTools.size} tool calls — wrapping up to stay on budget`);
463
853
  throw new DeadlineReached(lastCost, lastTokens);
464
854
  }
855
+ // Still in progress and completely silent: the request is wedged, not slow.
856
+ // Bail out NOW rather than spending the rest of the cap on a dead request —
857
+ // the caller retries a stall from a clean session (see promptAgent).
858
+ if (opts.stallMs != null) {
859
+ const idleMs = Date.now() - lastProgressAt;
860
+ if (idleMs >= opts.stallMs) {
861
+ emit(`no new output for ${Math.round(idleMs / 1000)}s — treating the model request as stalled`);
862
+ throw new NoProgress(lastCost, lastTokens, idleMs);
863
+ }
864
+ }
465
865
  }
466
866
  }