diffsplain 0.7.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,10 +13,12 @@ npx diffsplain
13
13
 
14
14
  The command opens a local page and compares the checkout with its default
15
15
  branch. It starts at port `2299` and uses the next free port when needed. You
16
- need Node.js 22.13 or newer and a signed-in Codex, Claude, Copilot, or OpenCode
17
- CLI. Diffsplain tries them in that order. Pull requests also need a signed-in
18
- GitHub CLI. Cursor reviews stay disabled because Cursor Agent has no supported
19
- read-only, no-network, no-tool mode.
16
+ need Node.js 22.13 or newer and a signed-in Codex, Claude, Copilot, Cursor, or
17
+ OpenCode CLI. Diffsplain tries them in that order. Cursor Agent must be version
18
+ 2026.08.11 or newer. It uses the signed-in Cursor CLI in the user's home and
19
+ still contacts the Cursor service. Pull requests also need a signed-in GitHub
20
+ CLI. Once Diffsplain chooses an agent, a failed check or run ends the command;
21
+ it does not switch agents.
20
22
 
21
23
  Common targets:
22
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "diffsplain",
3
- "version": "0.7.0",
3
+ "version": "0.8.2",
4
4
  "description": "Review Git diffs one file at a time with coding agent notes beside each patch.",
5
5
  "keywords": [
6
6
  "codex",
@@ -143,11 +143,42 @@ const excludedPaths = new Set(
143
143
  function command(commandName, commandArgs, options = {}) {
144
144
  return execFileSync(commandName, commandArgs, {
145
145
  cwd: options.cwd,
146
+ env: options.env,
146
147
  encoding: 'utf8',
147
148
  stdio: ['ignore', 'pipe', 'pipe'],
148
149
  });
149
150
  }
150
151
 
152
+ function githubHttpsRemote(url) {
153
+ if (typeof url !== 'string' || !url) return false;
154
+ try {
155
+ const parsed = new URL(url);
156
+ return (
157
+ parsed.protocol === 'https:' &&
158
+ (parsed.hostname === 'github.com' || parsed.hostname === 'www.github.com')
159
+ );
160
+ } catch {
161
+ return false;
162
+ }
163
+ }
164
+
165
+ function runGit(gitArgs, { gitDir, remoteUrl } = {}) {
166
+ const githubHttps = githubHttpsRemote(remoteUrl);
167
+ return command(
168
+ 'git',
169
+ [
170
+ ...(gitDir ? ['--git-dir', gitDir] : ['-C', repo]),
171
+ ...(githubHttps
172
+ ? ['-c', 'credential.helper=!gh auth git-credential']
173
+ : []),
174
+ ...gitArgs,
175
+ ],
176
+ githubHttps
177
+ ? { env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } }
178
+ : {},
179
+ );
180
+ }
181
+
151
182
  const runRepo = (gitArgs) => command('git', ['-C', repo, ...gitArgs]);
152
183
  const tryRepo = (gitArgs) => {
153
184
  try {
@@ -383,16 +414,19 @@ function bareCache(remoteUrl) {
383
414
 
384
415
  function fetchInto(cache, remoteUrl, refspecs) {
385
416
  try {
386
- cache.run([
387
- 'fetch',
388
- '--quiet',
389
- '--no-tags',
390
- '--no-write-fetch-head',
391
- '--no-auto-maintenance',
392
- '--force',
393
- remoteUrl,
394
- ...refspecs,
395
- ]);
417
+ runGit(
418
+ [
419
+ 'fetch',
420
+ '--quiet',
421
+ '--no-tags',
422
+ '--no-write-fetch-head',
423
+ '--no-auto-maintenance',
424
+ '--force',
425
+ remoteUrl,
426
+ ...refspecs,
427
+ ],
428
+ { gitDir: cache.path, remoteUrl },
429
+ );
396
430
  } catch (error) {
397
431
  const detail = error?.stderr?.toString().trim();
398
432
  throw new Error(
@@ -422,7 +456,7 @@ function uniqueMergeBase(runGit, base, head) {
422
456
  function remoteDefaultBranchInfo(remoteUrl) {
423
457
  let raw;
424
458
  try {
425
- raw = runRepo(['ls-remote', '--symref', remoteUrl, 'HEAD']);
459
+ raw = runGit(['ls-remote', '--symref', remoteUrl, 'HEAD'], { remoteUrl });
426
460
  } catch {
427
461
  throw new Error('Could not read the remote default branch');
428
462
  }
@@ -441,7 +475,7 @@ function remoteDefaultBranch(remoteUrl) {
441
475
  function remoteContainsCommits(remoteUrl, commits) {
442
476
  let raw;
443
477
  try {
444
- raw = runRepo(['ls-remote', remoteUrl]);
478
+ raw = runGit(['ls-remote', remoteUrl], { remoteUrl });
445
479
  } catch {
446
480
  return false;
447
481
  }
@@ -86,7 +86,7 @@ Targets:
86
86
  Options:
87
87
  --repo PATH|URL|OWNER/NAME
88
88
  Repo to review (default: current repo)
89
- --agent NAME Use codex, claude, copilot, or opencode
89
+ --agent NAME Use codex, claude, copilot, cursor, or opencode
90
90
  --no-agent Do not write agent notes
91
91
  --model NAME Model for agent notes
92
92
  --reasoning LEVEL Agent reasoning effort when supported
@@ -107,12 +107,12 @@ Options:
107
107
  -h, --help Show this help
108
108
  -v, --version Show the installed version
109
109
 
110
- Agent fallback:
111
- codex, claude, copilot, opencode
110
+ Automatic agent selection:
111
+ codex, claude, copilot, cursor, opencode
112
112
 
113
113
  Cursor:
114
- Disabled because Cursor Agent has no supported read-only, no-network,
115
- no-tool mode
114
+ Requires Cursor Agent 2026.08.11 or newer. Uses the signed-in CLI in the
115
+ user's home. Cursor still contacts its service.
116
116
 
117
117
  Examples:
118
118
  diffsplain
@@ -1,6 +1,8 @@
1
1
  import { constants } from 'node:fs';
2
2
  import { access } from 'node:fs/promises';
3
+ import { spawnSync } from 'node:child_process';
3
4
  import {
5
+ basename,
4
6
  delimiter,
5
7
  dirname,
6
8
  isAbsolute,
@@ -60,17 +62,103 @@ export function summaryAgentEnvironment(env = process.env) {
60
62
  );
61
63
  }
62
64
 
63
- const cursorDisabledReason =
64
- 'Cursor review is disabled: Cursor Agent has no supported read-only, no-network, no-tool mode.';
65
+ const minimumCursorVersion = [2026, 8, 11];
66
+ const cursorRequirementSummary =
67
+ 'Cursor needs version 2026.08.11 or newer with Ask mode, --sandbox, --trust, and --workspace.';
65
68
 
66
- export function agentDisabledReason(agent) {
67
- if (agent === 'cursor') return cursorDisabledReason;
68
- return undefined;
69
+ export const enabledCodingAgents = codingAgents;
70
+
71
+ function firstLine(value) {
72
+ return value
73
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
74
+ .split('\n')
75
+ .map((line) => line.trim())
76
+ .find(Boolean);
77
+ }
78
+
79
+ function cursorVersionParts(version) {
80
+ const match = version?.match(/^(\d{4})\.(\d{1,2})\.(\d{1,2})(?:-|$)/);
81
+ return match?.slice(1).map(Number);
69
82
  }
70
83
 
71
- export const enabledCodingAgents = codingAgents.filter(
72
- (agent) => !agentDisabledReason(agent),
73
- );
84
+ function versionAtLeast(current, minimum) {
85
+ for (const [index, part] of current.entries()) {
86
+ if (part !== minimum[index]) return part > minimum[index];
87
+ }
88
+ return true;
89
+ }
90
+
91
+ function cursorCompatibilityError(detail) {
92
+ return `Cursor Agent is incompatible: ${detail} ${cursorRequirementSummary} Upgrade Cursor Agent.`;
93
+ }
94
+
95
+ export function inspectCursorCompatibility(
96
+ command,
97
+ {
98
+ env = process.env,
99
+ timeout = 5_000,
100
+ } = {},
101
+ ) {
102
+ const run = (args) => spawnSync(command, args, {
103
+ encoding: 'utf8',
104
+ env,
105
+ timeout,
106
+ windowsHide: true,
107
+ });
108
+ const versionResult = run(['--version']);
109
+ const version = firstLine(
110
+ `${versionResult.stdout || ''}\n${versionResult.stderr || ''}`,
111
+ );
112
+ if (versionResult.error || versionResult.status !== 0 || !version) {
113
+ return {
114
+ compatible: false,
115
+ version,
116
+ reason: cursorCompatibilityError('The version check failed.'),
117
+ };
118
+ }
119
+ const parts = cursorVersionParts(version);
120
+ if (!parts || !versionAtLeast(parts, minimumCursorVersion)) {
121
+ return {
122
+ compatible: false,
123
+ version,
124
+ reason: cursorCompatibilityError(
125
+ `Found ${version}; version 2026.08.11 or newer is required.`,
126
+ ),
127
+ };
128
+ }
129
+ const helpResult = run(['--help']);
130
+ const help = `${helpResult.stdout || ''}\n${helpResult.stderr || ''}`;
131
+ if (helpResult.error || helpResult.status !== 0) {
132
+ return {
133
+ compatible: false,
134
+ version,
135
+ reason: cursorCompatibilityError('The CLI help check failed.'),
136
+ };
137
+ }
138
+ const requiredHelp = [
139
+ ['--mode <mode>', 'Ask mode'],
140
+ ['"ask"', 'Ask mode'],
141
+ ['--sandbox <mode>', 'sandbox control'],
142
+ ['"enabled"', 'sandbox control'],
143
+ ['--workspace <path-or-name>', 'workspace selection'],
144
+ ['--output-format <format>', 'structured output'],
145
+ ['--model <model>', 'model selection'],
146
+ ['--trust', 'workspace trust control'],
147
+ ];
148
+ const missing = requiredHelp
149
+ .filter(([text]) => !help.includes(text))
150
+ .map(([, label]) => label);
151
+ if (missing.length) {
152
+ return {
153
+ compatible: false,
154
+ version,
155
+ reason: cursorCompatibilityError(
156
+ `The CLI lacks ${[...new Set(missing)].join(', ')}.`,
157
+ ),
158
+ };
159
+ }
160
+ return { compatible: true, version };
161
+ }
74
162
 
75
163
  async function executable(path) {
76
164
  try {
@@ -123,7 +211,30 @@ export async function commandAvailable(command, options) {
123
211
  return Boolean(await findCommand(command, options));
124
212
  }
125
213
 
126
- // fallow-ignore-next-line complexity -- validation and fallback share one public selector.
214
+ export async function codingAgentAvailability(
215
+ agent,
216
+ {
217
+ binary = codingAgentBinary(agent),
218
+ env = process.env,
219
+ platform = process.platform,
220
+ } = {},
221
+ ) {
222
+ const path = await findCommand(binary, { env, platform });
223
+ if (!path) return { available: false, installed: false };
224
+ if (agent !== 'cursor') {
225
+ return { available: true, installed: true, path };
226
+ }
227
+ const inspection = inspectCursorCompatibility(path, { env });
228
+ return {
229
+ available: inspection.compatible,
230
+ installed: true,
231
+ path,
232
+ version: inspection.version,
233
+ reason: inspection.reason,
234
+ };
235
+ }
236
+
237
+ // fallow-ignore-next-line complexity -- validation and discovery share one public selector.
127
238
  export async function selectCodingAgent(
128
239
  requested,
129
240
  available = commandAvailable,
@@ -134,19 +245,32 @@ export async function selectCodingAgent(
134
245
  `Unsupported agent "${requested}". Choose ${enabledCodingAgents.join(', ')}.`,
135
246
  );
136
247
  }
137
- const disabled = agentDisabledReason(requested);
138
- if (disabled) throw new Error(disabled);
139
- if (!(await available(requested))) {
248
+ const result = await available(requested);
249
+ const availableResult = typeof result === 'object'
250
+ ? result.available
251
+ : result;
252
+ if (!availableResult) {
253
+ if (typeof result === 'object' && result.reason) {
254
+ throw new Error(result.reason);
255
+ }
140
256
  throw new Error(`Coding agent "${requested}" is not available.`);
141
257
  }
142
258
  return requested;
143
259
  }
144
260
 
261
+ let cursorReason;
145
262
  for (const agent of enabledCodingAgents) {
146
- if (await available(agent)) return agent;
263
+ const result = await available(agent);
264
+ const availableResult = typeof result === 'object'
265
+ ? result.available
266
+ : result;
267
+ if (availableResult) return agent;
268
+ if (agent === 'cursor' && typeof result === 'object') {
269
+ cursorReason = result.reason;
270
+ }
147
271
  }
148
272
  throw new Error(
149
- `No coding agent is available. Install one of: ${enabledCodingAgents.join(', ')}. ${cursorDisabledReason}`,
273
+ `No coding agent is available. Install one of: ${enabledCodingAgents.join(', ')}.${cursorReason ? ` ${cursorReason}` : ''}`,
150
274
  );
151
275
  }
152
276
 
@@ -169,12 +293,29 @@ export function codingAgentBinary(
169
293
  );
170
294
  }
171
295
 
296
+ function extractJsonValue(text) {
297
+ const start = text.search(/[{[]/);
298
+ if (start === -1) return undefined;
299
+ const opening = text[start];
300
+ const closing = opening === '{' ? '}' : ']';
301
+ const end = text.lastIndexOf(closing);
302
+ if (end <= start) return undefined;
303
+ try {
304
+ return JSON.parse(text.slice(start, end + 1));
305
+ } catch {
306
+ return undefined;
307
+ }
308
+ }
309
+
172
310
  function parseJsonText(text, agent) {
173
311
  const trimmed = text.trim();
174
312
  const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
313
+ const candidate = fenced ? fenced[1] : trimmed;
175
314
  try {
176
- return JSON.parse(fenced ? fenced[1] : trimmed);
315
+ return JSON.parse(candidate);
177
316
  } catch {
317
+ const extracted = extractJsonValue(candidate);
318
+ if (extracted !== undefined) return extracted;
178
319
  throw new Error(`${agent} did not return valid summary JSON`);
179
320
  }
180
321
  }
@@ -207,8 +348,35 @@ function parseOpenCodeResponse(stdout) {
207
348
  return parseJsonText(parts.join(''), 'OpenCode');
208
349
  }
209
350
 
351
+ export function parseCursorStreamResponse(stdout) {
352
+ const trimmed = stdout.trim();
353
+ const lines = trimmed.split('\n').filter(Boolean);
354
+ const events = lines.map(parseEvent);
355
+ if (!lines.length || !events.every(Boolean)) {
356
+ throw new Error('Cursor did not return a valid event stream');
357
+ }
358
+ const envelope = [...events]
359
+ .reverse()
360
+ .find((event) => event.type === 'result');
361
+ if (!envelope || envelope.subtype !== 'success' || envelope.is_error) {
362
+ throw new Error('Cursor did not return a successful result');
363
+ }
364
+ if (typeof envelope.result !== 'string') {
365
+ throw new Error('Cursor did not return summary JSON');
366
+ }
367
+ return {
368
+ events,
369
+ response: parseJsonText(envelope.result, 'Cursor'),
370
+ };
371
+ }
372
+
210
373
  function parseCursorResponse(stdout) {
211
- const envelope = parseJsonText(stdout, 'Cursor');
374
+ const trimmed = stdout.trim();
375
+ const lines = trimmed.split('\n').filter(Boolean);
376
+ if (lines.length > 1) {
377
+ return parseCursorStreamResponse(trimmed).response;
378
+ }
379
+ const envelope = parseJsonText(trimmed, 'Cursor');
212
380
  if (typeof envelope?.result === 'string') {
213
381
  return parseJsonText(envelope.result, 'Cursor');
214
382
  }
@@ -380,6 +548,53 @@ function openCodeCommand({
380
548
  };
381
549
  }
382
550
 
551
+ function cursorCommand({
552
+ binary,
553
+ inputPath,
554
+ model,
555
+ prompt,
556
+ schema,
557
+ summaryDirectory,
558
+ summaryEnv,
559
+ sourceEnv,
560
+ }) {
561
+ const args = [
562
+ '--print',
563
+ '--output-format',
564
+ 'stream-json',
565
+ '--mode',
566
+ 'ask',
567
+ '--sandbox',
568
+ 'enabled',
569
+ '--trust',
570
+ '--workspace',
571
+ summaryDirectory,
572
+ ];
573
+ if (model) args.push('--model', model);
574
+ args.push(
575
+ `${prompt}\n\nRead the snapshot JSON from ${basename(inputPath)} in this workspace. Return JSON that matches this schema:\n${JSON.stringify(schema)}`,
576
+ );
577
+ return {
578
+ command: binary,
579
+ args,
580
+ input: 'stdin',
581
+ cwd: summaryDirectory,
582
+ env: {
583
+ ...summaryEnv,
584
+ ...(sourceEnv.XDG_CONFIG_HOME
585
+ ? { XDG_CONFIG_HOME: sourceEnv.XDG_CONFIG_HOME }
586
+ : {}),
587
+ ...(sourceEnv.APPDATA ? { APPDATA: sourceEnv.APPDATA } : {}),
588
+ ...(sourceEnv.CURSOR_API_KEY
589
+ ? { CURSOR_API_KEY: sourceEnv.CURSOR_API_KEY }
590
+ : {}),
591
+ ...(sourceEnv.CURSOR_AUTH_TOKEN
592
+ ? { CURSOR_AUTH_TOKEN: sourceEnv.CURSOR_AUTH_TOKEN }
593
+ : {}),
594
+ },
595
+ };
596
+ }
597
+
383
598
  export function agentCommand({
384
599
  agent,
385
600
  binary = agent,
@@ -391,8 +606,6 @@ export function agentCommand({
391
606
  inputPath,
392
607
  env = process.env,
393
608
  }) {
394
- const disabled = agentDisabledReason(agent);
395
- if (disabled) throw new Error(disabled);
396
609
  const options = {
397
610
  binary,
398
611
  inputPath,
@@ -403,9 +616,11 @@ export function agentCommand({
403
616
  schemaPath,
404
617
  summaryDirectory: dirname(inputPath),
405
618
  summaryEnv: summaryAgentEnvironment(env),
619
+ sourceEnv: env,
406
620
  };
407
621
  if (agent === 'codex') return codexCommand(options);
408
622
  if (agent === 'claude') return claudeCommand(options);
409
623
  if (agent === 'copilot') return copilotCommand(options);
624
+ if (agent === 'cursor') return cursorCommand(options);
410
625
  return openCodeCommand(options);
411
626
  }
@@ -1,9 +1,9 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import {
3
- agentDisabledReason,
4
3
  codingAgentBinary,
5
4
  codingAgents,
6
5
  findCommand,
6
+ inspectCursorCompatibility,
7
7
  } from './coding-agents.mjs';
8
8
 
9
9
  const agentLabels = {
@@ -76,6 +76,28 @@ async function inspectDependency(label, command, { env, platform }) {
76
76
  };
77
77
  }
78
78
 
79
+ async function inspectAgent(agent, { env, platform }) {
80
+ const label = agentLabels[agent];
81
+ const command = codingAgentBinary(agent, { env });
82
+ if (agent !== 'cursor') {
83
+ return inspectDependency(label, command, { env, platform });
84
+ }
85
+ const path = await findCommand(command, { env, platform });
86
+ if (!path) {
87
+ return { label, command, installed: false, compatible: 'not-checked' };
88
+ }
89
+ const inspection = inspectCursorCompatibility(path, { env });
90
+ return {
91
+ label,
92
+ command,
93
+ installed: true,
94
+ path,
95
+ version: inspection.version,
96
+ compatible: inspection.compatible ? 'yes' : 'no',
97
+ ...(inspection.reason ? { boundaryError: inspection.reason } : {}),
98
+ };
99
+ }
100
+
79
101
  function dependencyLine(dependency) {
80
102
  const label = dependency.label.padEnd(9);
81
103
  if (dependency.disabled) {
@@ -84,6 +106,9 @@ function dependencyLine(dependency) {
84
106
  if (!dependency.installed) {
85
107
  return ` ✗ ${label} not found (${dependency.command})`;
86
108
  }
109
+ if (dependency.boundaryError) {
110
+ return ` ! ${label} ${dependency.version || 'version unavailable'} (${dependency.path}; ${dependency.boundaryError})`;
111
+ }
87
112
  const mark = dependency.version ? '✓' : '!';
88
113
  return ` ${mark} ${label} ${dependency.version || 'version unavailable'} (${dependency.path})`;
89
114
  }
@@ -132,23 +157,7 @@ async function inspectDependencies(env, platform) {
132
157
  const [git, gh, ...agents] = await Promise.all([
133
158
  inspectDependency('Git', 'git', { env, platform }),
134
159
  inspectDependency('gh', 'gh', { env, platform }),
135
- ...codingAgents.map((agent) => {
136
- const disabled = agentDisabledReason(agent);
137
- if (disabled) {
138
- return Promise.resolve({
139
- label: agentLabels[agent],
140
- command: codingAgentBinary(agent, { env }),
141
- installed: false,
142
- compatible: 'no',
143
- disabled,
144
- });
145
- }
146
- return inspectDependency(
147
- agentLabels[agent],
148
- codingAgentBinary(agent, { env }),
149
- { env, platform },
150
- );
151
- }),
160
+ ...codingAgents.map((agent) => inspectAgent(agent, { env, platform })),
152
161
  ]);
153
162
  return { git, gh, agents };
154
163
  }
@@ -15,8 +15,8 @@ import { fileURLToPath } from 'node:url';
15
15
  import {
16
16
  agentCommand,
17
17
  assertReasoningSupported,
18
+ codingAgentAvailability,
18
19
  codingAgentBinary,
19
- commandAvailable,
20
20
  parseAgentResponse,
21
21
  selectCodingAgent,
22
22
  } from './coding-agents.mjs';
@@ -104,8 +104,8 @@ Options:
104
104
  --summaries FILE Agent note file
105
105
  --output FILE Rebuilt Diffsplain JSON
106
106
  --cache-dir PATH Bare cache for fetched Git objects
107
- --agent NAME Use codex, claude, copilot, or opencode
108
- Cursor is disabled because it cannot meet the review boundary
107
+ --agent NAME Use codex, claude, copilot, cursor, or opencode
108
+ Cursor needs version 2026.08.11 or newer
109
109
  --codex-bin FILE Codex CLI path (default: codex)
110
110
  --model NAME Model passed to the coding agent
111
111
  --reasoning LEVEL Agent reasoning effort when supported
@@ -173,6 +173,7 @@ const proseCodePointLimit = 1_200;
173
173
  const detailItemLimit = 4;
174
174
  const riskItemLimit = 3;
175
175
  const listItemCodePointLimit = 500;
176
+ const fileNoteAttemptLimit = 3;
176
177
  const jobsValue = option('--jobs') || '3';
177
178
  if (!/^[1-9]\d*$/.test(jobsValue) || Number(jobsValue) > 8) {
178
179
  fail('--jobs must be a number from 1 to 8');
@@ -930,7 +931,9 @@ async function selectAgentForNotes() {
930
931
  try {
931
932
  selectedAgent = await selectCodingAgent(
932
933
  requestedAgent,
933
- (agent) => commandAvailable(codingAgentBinary(agent, { codexBin })),
934
+ (agent) => codingAgentAvailability(agent, {
935
+ binary: codingAgentBinary(agent, { codexBin }),
936
+ }),
934
937
  );
935
938
  assertReasoningSupported(selectedAgent, reasoning);
936
939
  agentBinary = codingAgentBinary(selectedAgent, { codexBin });
@@ -973,7 +976,7 @@ function failureReason(error) {
973
976
  'Agent note generation failed.';
974
977
  }
975
978
 
976
- function runAgent(invocation, input) {
979
+ function runAgent(invocation, input, { timeoutMs } = {}) {
977
980
  return new Promise((resolvePromise, rejectPromise) => {
978
981
  const child = spawn(invocation.command, invocation.args, {
979
982
  cwd: invocation.cwd || root,
@@ -981,6 +984,15 @@ function runAgent(invocation, input) {
981
984
  stdio: ['pipe', 'pipe', 'pipe'],
982
985
  });
983
986
  activeAgentProcesses.add(child);
987
+ const timeout = timeoutMs
988
+ ? setTimeout(() => {
989
+ child.kill('SIGTERM');
990
+ rejectPromise(
991
+ new Error(`${selectedAgent} timed out`),
992
+ );
993
+ }, timeoutMs)
994
+ : undefined;
995
+ timeout?.unref();
984
996
  const stdout = [];
985
997
  const stderr = [];
986
998
  let outputBytes = 0;
@@ -1001,10 +1013,12 @@ function runAgent(invocation, input) {
1001
1013
  if (error.code !== 'EPIPE') rejectPromise(error);
1002
1014
  });
1003
1015
  child.once('error', (error) => {
1016
+ if (timeout) clearTimeout(timeout);
1004
1017
  activeAgentProcesses.delete(child);
1005
1018
  rejectPromise(error);
1006
1019
  });
1007
1020
  child.once('close', (status, signal) => {
1021
+ if (timeout) clearTimeout(timeout);
1008
1022
  activeAgentProcesses.delete(child);
1009
1023
  if (interrupted) {
1010
1024
  rejectPromise(new Error('Agent note generation was interrupted'));
@@ -1013,7 +1027,7 @@ function runAgent(invocation, input) {
1013
1027
  const stdoutText = Buffer.concat(stdout).toString('utf8');
1014
1028
  const stderrText = Buffer.concat(stderr).toString('utf8');
1015
1029
  if (status !== 0 || signal) {
1016
- const detail = stderrText
1030
+ const detail = `${stdoutText}\n${stderrText}`
1017
1031
  .split('\n')
1018
1032
  .map((line) =>
1019
1033
  line.replace(
@@ -1120,6 +1134,10 @@ const temporaryDirectory = mkdtempSync(
1120
1134
  let workingSummaries;
1121
1135
  let workingSnapshot;
1122
1136
 
1137
+ function agentTemporaryPath(name) {
1138
+ return resolve(temporaryDirectory, name);
1139
+ }
1140
+
1123
1141
  try {
1124
1142
  recordSyncStage('cache', acquireOwnership);
1125
1143
  const { rawSnapshot, snapshot } = recordSyncStage('snapshot', () => {
@@ -1269,10 +1287,9 @@ try {
1269
1287
  }
1270
1288
  if (batch.length) batches.push(batch);
1271
1289
  let nextBatch = 0;
1272
- const requestBatch = async (index, batchPaths) => {
1273
- const schemaPath = resolve(
1274
- temporaryDirectory,
1275
- `summary-schema-${index + 1}.json`,
1290
+ const requestBatch = async (index, batchPaths, attempt) => {
1291
+ const schemaPath = agentTemporaryPath(
1292
+ `summary-schema-${index + 1}-${attempt}.json`,
1276
1293
  );
1277
1294
  writeFileSync(
1278
1295
  schemaPath,
@@ -1289,9 +1306,8 @@ try {
1289
1306
  batchPaths,
1290
1307
  workingSummaries.files,
1291
1308
  );
1292
- const inputPath = resolve(
1293
- temporaryDirectory,
1294
- `summary-input-${index + 1}.json`,
1309
+ const inputPath = agentTemporaryPath(
1310
+ `summary-input-${index + 1}-${attempt}.json`,
1295
1311
  );
1296
1312
  writeFileSync(inputPath, input);
1297
1313
  const invocation = agentCommand({
@@ -1307,7 +1323,7 @@ try {
1307
1323
  });
1308
1324
 
1309
1325
  console.error(
1310
- `Asking ${selectedAgent} for batch ${index + 1} of ${batches.length} (${batchPaths.length} changed files)...`,
1326
+ `Asking ${selectedAgent} for batch ${index + 1} of ${batches.length} (${batchPaths.length} changed files, attempt ${attempt} of ${fileNoteAttemptLimit})...`,
1311
1327
  );
1312
1328
  return requestAgent(
1313
1329
  invocation,
@@ -1321,45 +1337,73 @@ try {
1321
1337
  };
1322
1338
  const runBatch = async (index) => {
1323
1339
  const batchPaths = batches[index];
1324
- let outcome;
1325
- try {
1326
- outcome = await requestBatch(index, batchPaths);
1327
- } catch (error) {
1328
- if (interrupted) throw error;
1329
- const reason = failureReason(error);
1330
- console.error(
1331
- error instanceof Error ? error.message : String(error),
1340
+ let pendingPaths = batchPaths;
1341
+ for (
1342
+ let attempt = 1;
1343
+ attempt <= fileNoteAttemptLimit && pendingPaths.length;
1344
+ attempt += 1
1345
+ ) {
1346
+ let outcome;
1347
+ let requestFailed = false;
1348
+ try {
1349
+ outcome = await requestBatch(index, pendingPaths, attempt);
1350
+ } catch (error) {
1351
+ if (interrupted) throw error;
1352
+ requestFailed = true;
1353
+ const reason = failureReason(error);
1354
+ console.error(
1355
+ error instanceof Error ? error.message : String(error),
1356
+ );
1357
+ outcome = {
1358
+ files: {},
1359
+ failedFiles: pendingPaths.map((path) => ({ path, reason })),
1360
+ errors: [],
1361
+ };
1362
+ }
1363
+ const requestedPaths = new Set(pendingPaths);
1364
+ const retryableFailures = requestFailed
1365
+ ? []
1366
+ : outcome.failedFiles.filter(
1367
+ (failure) => requestedPaths.has(failure.path),
1368
+ );
1369
+ const finalAttempt =
1370
+ requestFailed || attempt === fileNoteAttemptLimit;
1371
+ const keptFailures = outcome.failedFiles.filter(
1372
+ (failure) =>
1373
+ requestedPaths.has(failure.path)
1374
+ ? finalAttempt
1375
+ : !completeFileNote(workingSummaries.files[failure.path]),
1332
1376
  );
1333
- outcome = {
1334
- files: {},
1335
- failedFiles: batchPaths.map((path) => ({ path, reason })),
1336
- errors: [],
1377
+ workingSummaries = {
1378
+ ...(workingSummaries.change
1379
+ ? { change: workingSummaries.change }
1380
+ : {}),
1381
+ files: {
1382
+ ...workingSummaries.files,
1383
+ ...outcome.files,
1384
+ },
1385
+ meta: {
1386
+ ...workingSummaries.meta,
1387
+ status: 'generating',
1388
+ generatedAt: new Date().toISOString(),
1389
+ },
1337
1390
  };
1338
- }
1339
- workingSummaries = {
1340
- ...(workingSummaries.change
1341
- ? { change: workingSummaries.change }
1342
- : {}),
1343
- files: {
1344
- ...workingSummaries.files,
1345
- ...outcome.files,
1346
- },
1347
- meta: {
1348
- ...workingSummaries.meta,
1349
- status: 'generating',
1350
- generatedAt: new Date().toISOString(),
1351
- },
1352
- };
1353
- workingSummaries = addFailures(
1354
- workingSummaries,
1355
- outcome.failedFiles,
1356
- outcome.errors,
1357
- );
1358
- storeProgress(rawSnapshot, workingSummaries);
1359
- if (batchPaths.length) {
1360
- console.log(
1361
- `Wrote ${Object.keys(workingSummaries.files).length} of ${paths.length} agent notes to ${summariesPath}`,
1391
+ workingSummaries = addFailures(
1392
+ workingSummaries,
1393
+ keptFailures,
1394
+ finalAttempt || retryableFailures.length === 0
1395
+ ? outcome.errors
1396
+ : [],
1362
1397
  );
1398
+ storeProgress(rawSnapshot, workingSummaries);
1399
+ if (pendingPaths.length) {
1400
+ console.log(
1401
+ `Wrote ${Object.keys(workingSummaries.files).length} of ${paths.length} agent notes to ${summariesPath}`,
1402
+ );
1403
+ }
1404
+ pendingPaths = [...new Set(
1405
+ retryableFailures.map((failure) => failure.path),
1406
+ )];
1363
1407
  }
1364
1408
  };
1365
1409
  const workers = Array.from(
@@ -1375,10 +1419,7 @@ try {
1375
1419
  await Promise.all(workers);
1376
1420
  if (changeNeedsRefresh) {
1377
1421
  try {
1378
- const schemaPath = resolve(
1379
- temporaryDirectory,
1380
- 'change-summary-schema.json',
1381
- );
1422
+ const schemaPath = agentTemporaryPath('change-summary-schema.json');
1382
1423
  const schema = outputSchema([]);
1383
1424
  writeFileSync(
1384
1425
  schemaPath,
@@ -1390,10 +1431,7 @@ try {
1390
1431
  [],
1391
1432
  workingSummaries.files,
1392
1433
  );
1393
- const inputPath = resolve(
1394
- temporaryDirectory,
1395
- 'change-summary-input.json',
1396
- );
1434
+ const inputPath = agentTemporaryPath('change-summary-input.json');
1397
1435
  writeFileSync(inputPath, input);
1398
1436
  const invocation = agentCommand({
1399
1437
  agent: selectedAgent,
@@ -18,8 +18,8 @@ import { fileURLToPath } from 'node:url';
18
18
  import { helpText, parseCliArgs } from './cli-args.mjs';
19
19
  import {
20
20
  assertReasoningSupported,
21
+ codingAgentAvailability,
21
22
  codingAgentBinary,
22
- commandAvailable,
23
23
  selectCodingAgent,
24
24
  } from './coding-agents.mjs';
25
25
  import { doctorReport } from './doctor.mjs';
@@ -189,9 +189,9 @@ if (agentEnabled) {
189
189
  selectedAgent = await selectCodingAgent(
190
190
  cli.agent,
191
191
  (agent) =>
192
- commandAvailable(
193
- codingAgentBinary(agent, { codexBin: cli.codexBin }),
194
- ),
192
+ codingAgentAvailability(agent, {
193
+ binary: codingAgentBinary(agent, { codexBin: cli.codexBin }),
194
+ }),
195
195
  );
196
196
  assertReasoningSupported(selectedAgent, cli.reasoning);
197
197
  const agentBinary = codingAgentBinary(selectedAgent, {