diffsplain 0.9.0 → 0.11.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 (88) hide show
  1. package/README.md +21 -4
  2. package/dist/assets/{angular-html-m_m91ZYB.js → angular-html-CjYwnGyV.js} +1 -1
  3. package/dist/assets/{angular-ts-CO5Z--Rb.js → angular-ts-DSPf2G4O.js} +1 -1
  4. package/dist/assets/{apl-ogYYgLUu.js → apl-B9ytghJx.js} +1 -1
  5. package/dist/assets/{astro-DAfY3cNR.js → astro-BsGV0yBF.js} +1 -1
  6. package/dist/assets/{blade-E6RzsLUV.js → blade-Cc2eCYlt.js} +1 -1
  7. package/dist/assets/{c-kKdxhtHF.js → c-CWKyCnUx.js} +1 -1
  8. package/dist/assets/{cobol-3oDDyFNf.js → cobol-DkamOI1E.js} +1 -1
  9. package/dist/assets/{coffee-dsiILuWe.js → coffee-DyaEXePe.js} +1 -1
  10. package/dist/assets/{cpp-DYyNFq__.js → cpp-16-d2Eyo.js} +1 -1
  11. package/dist/assets/{crystal-B7dVp9TF.js → crystal-CavPPRhQ.js} +1 -1
  12. package/dist/assets/{css-DlxJ0aex.js → css-BME_wHJq.js} +1 -1
  13. package/dist/assets/{edge-BjEVphkC.js → edge-DeVi2hvK.js} +1 -1
  14. package/dist/assets/{elixir-0xTOtMcM.js → elixir-CCWgH_vc.js} +1 -1
  15. package/dist/assets/{elm-iQk_Wrxq.js → elm-CZ570J7x.js} +1 -1
  16. package/dist/assets/{erb-BO74ztiE.js → erb-CDPT5bea.js} +1 -1
  17. package/dist/assets/{git-rebase-BN667QRY.js → git-rebase-Dq5Z37DJ.js} +1 -1
  18. package/dist/assets/{glimmer-js-DTGUIHeS.js → glimmer-js-BoPVZK8p.js} +1 -1
  19. package/dist/assets/{glimmer-ts-BU3oQn2d.js → glimmer-ts-DazEWfg1.js} +1 -1
  20. package/dist/assets/{glsl-KLiqLJi2.js → glsl-DCUjYCZY.js} +1 -1
  21. package/dist/assets/{graphql-D_idcsoJ.js → graphql-DkCeSxy7.js} +1 -1
  22. package/dist/assets/{hack-BXlsgPIr.js → hack-tiY3l-W-.js} +1 -1
  23. package/dist/assets/{haml-fP0aUg4-.js → haml-DyQZrJg_.js} +1 -1
  24. package/dist/assets/{handlebars-C1ZEaot-.js → handlebars-Bvu5Rr0t.js} +1 -1
  25. package/dist/assets/{html-D04qIuup.js → html-C60VFgKW.js} +1 -1
  26. package/dist/assets/{html-derivative-C6ai-9QL.js → html-derivative-DB69gpnL.js} +1 -1
  27. package/dist/assets/{http-C7bz3ZvQ.js → http-DmS8hddd.js} +1 -1
  28. package/dist/assets/{hurl-jL6B6Ahr.js → hurl-Cdp6lmUx.js} +1 -1
  29. package/dist/assets/{index-BPaJGi-m.js → index-CUiLG0r9.js} +4 -4
  30. package/dist/assets/{index-VAyBpb3u.css → index-YnLIxcTm.css} +1 -1
  31. package/dist/assets/{java-BVzvLCON.js → java-CkOnwoVQ.js} +1 -1
  32. package/dist/assets/{javascript-UDbZHxCI.js → javascript-D2tF_Ylj.js} +1 -1
  33. package/dist/assets/{jinja-DB-QKo2A.js → jinja-CdWpLcff.js} +1 -1
  34. package/dist/assets/{jison-B1ZbR2FF.js → jison-BII58uuR.js} +1 -1
  35. package/dist/assets/{json-DV4WwJzT.js → json-BKlPRLrU.js} +1 -1
  36. package/dist/assets/{jsx-DS6_VTvW.js → jsx-Ba5UouxN.js} +1 -1
  37. package/dist/assets/{julia-C4tevET0.js → julia-CwdQF8Ff.js} +1 -1
  38. package/dist/assets/{just-CWMjefzB.js → just-Be3sr1-h.js} +1 -1
  39. package/dist/assets/{latex-tyEFf_Af.js → latex-CDF6t93N.js} +1 -1
  40. package/dist/assets/{liquid-C3d1X1rL.js → liquid-BE3LVHkw.js} +1 -1
  41. package/dist/assets/{lua-sXvrzLQY.js → lua-D_W8Ux9S.js} +1 -1
  42. package/dist/assets/{marko-CQweucif.js → marko-BeUfMD-U.js} +1 -1
  43. package/dist/assets/{mdc-TrxLWF7s.js → mdc-C8tRJlVU.js} +1 -1
  44. package/dist/assets/{nginx-ChE2nSNJ.js → nginx-CLPimMM6.js} +1 -1
  45. package/dist/assets/{nim-BY1CTRfx.js → nim-ikQhXKQf.js} +1 -1
  46. package/dist/assets/{perl-Cvj2PQQH.js → perl-C27cToFa.js} +1 -1
  47. package/dist/assets/{php-CGmHp8HW.js → php-Btdg-RP3.js} +1 -1
  48. package/dist/assets/{pug-DM4S0bRr.js → pug-Bk73ERXL.js} +1 -1
  49. package/dist/assets/{qml-DI89Al9K.js → qml-DhY7FDIl.js} +1 -1
  50. package/dist/assets/{r-Bs5_4yYh.js → r-CEwua7nj.js} +1 -1
  51. package/dist/assets/{razor-DimzayhZ.js → razor-sPO7Sh9d.js} +1 -1
  52. package/dist/assets/{regexp-BLACz0cx.js → regexp-D9DLFGXE.js} +1 -1
  53. package/dist/assets/{rst-DcWzxQh2.js → rst-YhauCnId.js} +1 -1
  54. package/dist/assets/{ruby-Dlat5E8d.js → ruby-DpMkRaoz.js} +1 -1
  55. package/dist/assets/{sas-CCDmMaBY.js → sas-0KdmKGRO.js} +1 -1
  56. package/dist/assets/{scss-BPiU2nu6.js → scss-CohvHWvq.js} +1 -1
  57. package/dist/assets/{shellscript-CKinVNrN.js → shellscript-C93Q8fLt.js} +1 -1
  58. package/dist/assets/{shellsession-Cea8Pu5d.js → shellsession-YCh2AQXu.js} +1 -1
  59. package/dist/assets/{soy--BRAPY4-.js → soy-CWVYdXUH.js} +1 -1
  60. package/dist/assets/{sql-DjKiCyWK.js → sql-CJNtax5I.js} +1 -1
  61. package/dist/assets/{stata-CDvo3J6y.js → stata-C7v41XAM.js} +1 -1
  62. package/dist/assets/{surrealql-C7b7E5sh.js → surrealql-CRUSOxDf.js} +1 -1
  63. package/dist/assets/{svelte-DO0tKtiK.js → svelte-Z0harRr1.js} +1 -1
  64. package/dist/assets/{templ-DhhmR_rv.js → templ-BplDouJX.js} +1 -1
  65. package/dist/assets/{tex-Cn_V-Gg4.js → tex-MsB6StN5.js} +1 -1
  66. package/dist/assets/{ts-tags-B6c-FTmA.js → ts-tags-BWx8aq5t.js} +1 -1
  67. package/dist/assets/{tsx-Cvtt2sJ-.js → tsx-DBT1gmNl.js} +1 -1
  68. package/dist/assets/{twig-B7ZbCUmB.js → twig-jz67rR3C.js} +1 -1
  69. package/dist/assets/{typescript-i3C6um2B.js → typescript-CzwGN36B.js} +1 -1
  70. package/dist/assets/{vue-html-D362KpK3.js → vue-html-Dhrmu7sO.js} +1 -1
  71. package/dist/assets/{vue-vine-D34cBbwO.js → vue-vine-BfQGXZs6.js} +1 -1
  72. package/dist/assets/{vue-DyPX0Vum.js → vue-xsrSiVMC.js} +1 -1
  73. package/dist/assets/{xml-DJEk7u4n.js → xml-W7cP4qeN.js} +1 -1
  74. package/dist/assets/{xsl-CoTtf90f.js → xsl-BlXf8qm_.js} +1 -1
  75. package/dist/assets/{yaml-Cv5epQ4-.js → yaml-BtNwxUT9.js} +1 -1
  76. package/dist/index.html +2 -2
  77. package/package.json +4 -2
  78. package/scripts/agent-config.mjs +184 -0
  79. package/scripts/agent-usage.mjs +141 -0
  80. package/scripts/build-diff-data.mjs +32 -12
  81. package/scripts/cli-args.mjs +36 -2
  82. package/scripts/coding-agents.mjs +265 -13
  83. package/scripts/generate-summaries.mjs +72 -13
  84. package/scripts/present.mjs +156 -58
  85. package/scripts/review-chat-context.mjs +6 -4
  86. package/scripts/review-chat-controller.mjs +142 -17
  87. package/scripts/review-chat-provider.mjs +40 -7
  88. package/scripts/serve-built.mjs +139 -7
@@ -0,0 +1,141 @@
1
+ const tokenFields = [
2
+ 'inputTokens',
3
+ 'outputTokens',
4
+ 'cacheReadTokens',
5
+ 'cacheWriteTokens',
6
+ ];
7
+
8
+ function tokenCount(value) {
9
+ return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
10
+ }
11
+
12
+ function usageObject(value) {
13
+ return value && typeof value === 'object' && !Array.isArray(value)
14
+ ? value
15
+ : undefined;
16
+ }
17
+
18
+ function usageEntries(value) {
19
+ return tokenFields.reduce((entries, field) => {
20
+ const count = tokenCount(value[field]);
21
+ return count === undefined ? entries : [...entries, [field, count]];
22
+ }, []);
23
+ }
24
+
25
+ function providerUsage(value) {
26
+ const usage = usageObject(value);
27
+ if (!usage) return undefined;
28
+ const tokens = Object.fromEntries(usageEntries(usage));
29
+ return Object.keys(tokens).length > 0 ? tokens : undefined;
30
+ }
31
+
32
+ function countOrZero(value) {
33
+ return tokenCount(value) || 0;
34
+ }
35
+
36
+ function summaryTokens(value) {
37
+ return providerUsage(value) || {};
38
+ }
39
+
40
+ export function emptyUsageAccumulator() {
41
+ return { calls: 0, reportedCalls: 0, tokens: {} };
42
+ }
43
+
44
+ function mergedTokens(left, right) {
45
+ const tokens = { ...left };
46
+ for (const [field, count] of Object.entries(right || {})) {
47
+ tokens[field] = (tokens[field] || 0) + count;
48
+ }
49
+ return tokens;
50
+ }
51
+
52
+ export function recordUsage(accumulator, usage) {
53
+ const reported = providerUsage(usage);
54
+ return {
55
+ calls: accumulator.calls + 1,
56
+ reportedCalls: accumulator.reportedCalls + Number(Boolean(reported)),
57
+ tokens: mergedTokens(accumulator.tokens, reported),
58
+ };
59
+ }
60
+
61
+ export function usageSummary(accumulator) {
62
+ if (accumulator.calls === 0) {
63
+ return {
64
+ status: 'complete',
65
+ calls: 0,
66
+ reportedCalls: 0,
67
+ tokens: { inputTokens: 0, outputTokens: 0 },
68
+ };
69
+ }
70
+ if (accumulator.reportedCalls === 0) {
71
+ return {
72
+ status: 'unavailable',
73
+ calls: accumulator.calls,
74
+ reportedCalls: 0,
75
+ };
76
+ }
77
+ return {
78
+ status: accumulator.reportedCalls === accumulator.calls
79
+ ? 'complete'
80
+ : 'partial',
81
+ calls: accumulator.calls,
82
+ reportedCalls: accumulator.reportedCalls,
83
+ tokens: { ...accumulator.tokens },
84
+ };
85
+ }
86
+
87
+ function accumulatorFromSummary(summary) {
88
+ const usage = usageObject(summary);
89
+ if (!usage) return emptyUsageAccumulator();
90
+ return {
91
+ calls: countOrZero(usage.calls),
92
+ reportedCalls: countOrZero(usage.reportedCalls),
93
+ tokens: summaryTokens(usage.tokens),
94
+ };
95
+ }
96
+
97
+ export function combineUsage(left, right) {
98
+ const first = accumulatorFromSummary(left);
99
+ const second = accumulatorFromSummary(right);
100
+ return usageSummary({
101
+ calls: first.calls + second.calls,
102
+ reportedCalls: first.reportedCalls + second.reportedCalls,
103
+ tokens: mergedTokens(first.tokens, second.tokens),
104
+ });
105
+ }
106
+
107
+ export function reviewUsage(agentNotes, reviewChat) {
108
+ return {
109
+ agentNotes,
110
+ reviewChat,
111
+ combined: combineUsage(agentNotes, reviewChat),
112
+ };
113
+ }
114
+
115
+ const terminalLabels = {
116
+ inputTokens: 'input',
117
+ outputTokens: 'output',
118
+ cacheReadTokens: 'cache read',
119
+ cacheWriteTokens: 'cache write',
120
+ };
121
+
122
+ function formatUsageTokens(tokens) {
123
+ return Object.entries(tokens || {})
124
+ .map(([field, count]) => `${terminalLabels[field]} ${count.toLocaleString('en-US')}`)
125
+ .join(', ');
126
+ }
127
+
128
+ export function formatUsage(label, summary) {
129
+ if (summary.status === 'unavailable') return `${label}: Unavailable`;
130
+ const totals = formatUsageTokens(summary.tokens);
131
+ const prefix = summary.status === 'partial' ? 'Partial, ' : '';
132
+ return `${label}: ${prefix}${totals || '0 tokens'}`;
133
+ }
134
+
135
+ export function formatReviewUsage(usage) {
136
+ return [
137
+ formatUsage('Agent note usage', usage.agentNotes),
138
+ formatUsage('Review chat usage', usage.reviewChat),
139
+ formatUsage('Combined agent usage', usage.combined),
140
+ ].join('\n');
141
+ }
@@ -32,6 +32,12 @@ import {
32
32
  createAgentReviewFingerprint,
33
33
  } from './agent-review.mjs';
34
34
  import { summaryPath } from './summary-path.mjs';
35
+ import {
36
+ combineUsage,
37
+ emptyUsageAccumulator,
38
+ reviewUsage,
39
+ usageSummary,
40
+ } from './agent-usage.mjs';
35
41
 
36
42
  const args = process.argv.slice(2);
37
43
  const fail = (message) => {
@@ -1338,10 +1344,18 @@ function build() {
1338
1344
  ...(typeof summaryDoc.meta?.reasoning === 'string'
1339
1345
  ? { reasoning: summaryDoc.meta.reasoning }
1340
1346
  : {}),
1347
+ fast: summaryDoc.meta?.fast === true,
1341
1348
  ...(typeof summaryDoc.meta?.accessMode === 'string'
1342
1349
  ? { accessMode: summaryDoc.meta.accessMode }
1343
1350
  : {}),
1344
1351
  },
1352
+ usage: reviewUsage(
1353
+ combineUsage(
1354
+ sourceSummaries.meta?.usage,
1355
+ usageSummary(emptyUsageAccumulator()),
1356
+ ),
1357
+ usageSummary(emptyUsageAccumulator()),
1358
+ ),
1345
1359
  };
1346
1360
  const version = createHash('sha256')
1347
1361
  .update(JSON.stringify(content))
@@ -1465,23 +1479,29 @@ const refresh = () => {
1465
1479
  }
1466
1480
  };
1467
1481
 
1468
- const started = refresh();
1482
+ let watcher;
1483
+ const stopWatching = (error) => {
1484
+ console.error(error instanceof Error ? error.message : String(error));
1485
+ process.exitCode = 1;
1486
+ if (watcher) clearInterval(watcher);
1487
+ };
1488
+ let initialFingerprint;
1489
+ let startupFailed = false;
1490
+ if (watching) {
1491
+ try {
1492
+ initialFingerprint = fingerprint();
1493
+ } catch (error) {
1494
+ startupFailed = true;
1495
+ stopWatching(error);
1496
+ }
1497
+ }
1498
+ const started = !startupFailed && refresh();
1469
1499
  if (watching && started) {
1470
- let last;
1500
+ let last = initialFingerprint;
1471
1501
  let remoteWait = 0;
1472
- let watcher;
1473
- const stopWatching = (error) => {
1474
- console.error(error instanceof Error ? error.message : String(error));
1475
- process.exitCode = 1;
1476
- if (watcher) clearInterval(watcher);
1477
- };
1478
1502
  const poll = () => {
1479
1503
  try {
1480
1504
  const next = fingerprint();
1481
- if (last === undefined) {
1482
- last = next;
1483
- return true;
1484
- }
1485
1505
  remoteWait += watchInterval;
1486
1506
  const remoteDue = remoteMode && remoteWait >= remoteRefreshInterval;
1487
1507
  if (next !== last || remoteDue || watchContent) {
@@ -42,6 +42,7 @@ export const cliOptions = defineCliOptions({
42
42
  '--no-agent': { kind: 'no-agent' },
43
43
  '--no-checkout-access': { kind: 'flag' },
44
44
  '--force': { kind: 'flag' },
45
+ '--fast': { kind: 'flag' },
45
46
  '--worktree': { kind: 'flag' },
46
47
  '--no-browser': { kind: 'flag' },
47
48
  '--support-record': { kind: 'flag' },
@@ -82,6 +83,8 @@ Commands:
82
83
  doctor [--json] [--deep]
83
84
  Check review, agent, and pull request capabilities
84
85
  cache Show or prune saved agent notes
86
+ config agent [NAME|--unset]
87
+ Show, set, or unset the default coding agent
85
88
 
86
89
  Targets:
87
90
  --branch NAME Show a remote branch against its default branch
@@ -100,6 +103,7 @@ Options:
100
103
  --exclude PATTERN Omit matching files from automatic agent context
101
104
  --model NAME Model for agent notes
102
105
  --reasoning LEVEL Agent reasoning effort when supported
106
+ --fast Enable provider Fast mode for agent notes and chat
103
107
  --batch-size COUNT Maximum files per agent pass (default: ${batchSizeOption.default})
104
108
  --jobs COUNT Agent passes to run at once (default: ${jobsOption.default})
105
109
  --force Regenerate all agent notes
@@ -118,8 +122,8 @@ Options:
118
122
  -v, --version Show the installed version
119
123
 
120
124
  Agent choice:
121
- Without --agent, an interactive terminal lists usable agents. In scripts,
122
- pass --agent NAME or --no-agent.
125
+ Choice order is --no-agent, --agent, then the configured default.
126
+ Without any choice, an interactive terminal lists usable agents.
123
127
 
124
128
  Cursor:
125
129
  Requires Cursor Agent 2026.08.11 or newer. Uses the signed-in CLI in the
@@ -195,6 +199,31 @@ export function parseCliArgs(
195
199
  pathExists = existsSync,
196
200
  } = {},
197
201
  ) {
202
+ if (rawArgs[0] === 'config') {
203
+ if (rawArgs[1] !== 'agent') {
204
+ fail('Use: diffsplain config agent [NAME|--unset]');
205
+ }
206
+ if (rawArgs.length === 2) {
207
+ return { config: { kind: 'show' } };
208
+ }
209
+ if (rawArgs.length !== 3) {
210
+ fail('Use: diffsplain config agent [NAME|--unset]');
211
+ }
212
+ const value = rawArgs[2];
213
+ if (value === '--unset') {
214
+ return { config: { kind: 'unset' } };
215
+ }
216
+ if (value.startsWith('--')) {
217
+ fail('Use: diffsplain config agent [NAME|--unset]');
218
+ }
219
+ if (!codingAgents.includes(value)) {
220
+ fail(
221
+ `Unsupported agent "${value}". Choose ${enabledCodingAgents.join(', ')}.`,
222
+ );
223
+ }
224
+ return { config: { kind: 'set', agent: value } };
225
+ }
226
+
198
227
  if (rawArgs[0] === 'doctor') {
199
228
  const options = new Set(rawArgs.slice(1));
200
229
  for (const option of options) {
@@ -284,6 +313,9 @@ export function parseCliArgs(
284
313
  fail('Pass the repo once, either as REPO or with --repo');
285
314
  }
286
315
  if (noAgent && agentSet) fail('--agent and --no-agent cannot be used together');
316
+ if (noAgent && options.has('--fast')) {
317
+ fail('--fast and --no-agent cannot be used together');
318
+ }
287
319
  if (noAgent && options.has('--summaries')) {
288
320
  fail('--no-agent cannot be used with --summaries');
289
321
  }
@@ -390,6 +422,7 @@ export function parseCliArgs(
390
422
  agentArgs.push(`--exclude=${pattern}`);
391
423
  }
392
424
  if (options.has('--force')) agentArgs.push('--force');
425
+ if (options.has('--fast')) agentArgs.push('--fast');
393
426
  for (const name of [
394
427
  '--codex-bin',
395
428
  '--model',
@@ -499,5 +532,6 @@ export function parseCliArgs(
499
532
  host,
500
533
  browserEnabled: !options.has('--no-browser'),
501
534
  forceSummaryRegeneration: options.has('--force'),
535
+ fast: options.has('--fast'),
502
536
  };
503
537
  }
@@ -8,13 +8,29 @@ import {
8
8
  isAbsolute,
9
9
  join,
10
10
  } from 'node:path';
11
+ import { readConfiguredAgent } from './agent-config.mjs';
11
12
 
12
13
  export const codingAgentCapabilities = {
13
- codex: { binary: 'codex', model: true, reasoning: true },
14
- claude: { binary: 'claude', model: true, reasoning: false },
15
- copilot: { binary: 'copilot', model: true, reasoning: false },
16
- cursor: { binary: 'cursor-agent', model: true, reasoning: false },
17
- opencode: { binary: 'opencode', model: true, reasoning: true },
14
+ codex: {
15
+ binary: 'codex',
16
+ model: true,
17
+ reasoning: true,
18
+ fast: { label: 'Codex CLI', minimumVersion: '0.108.0' },
19
+ },
20
+ claude: {
21
+ binary: 'claude',
22
+ model: true,
23
+ reasoning: false,
24
+ fast: { label: 'Claude Code', minimumVersion: '2.1.36' },
25
+ },
26
+ copilot: { binary: 'copilot', model: true, reasoning: false, fast: false },
27
+ cursor: {
28
+ binary: 'cursor-agent',
29
+ model: true,
30
+ reasoning: false,
31
+ fast: false,
32
+ },
33
+ opencode: { binary: 'opencode', model: true, reasoning: true, fast: false },
18
34
  };
19
35
 
20
36
  export const codingAgents = Object.keys(codingAgentCapabilities);
@@ -23,6 +39,10 @@ export function agentSupportsReasoning(agent) {
23
39
  return codingAgentCapabilities[agent]?.reasoning === true;
24
40
  }
25
41
 
42
+ export function agentSupportsFast(agent) {
43
+ return Boolean(codingAgentCapabilities[agent]?.fast);
44
+ }
45
+
26
46
  export function assertReasoningSupported(agent, reasoning) {
27
47
  if (reasoning && !agentSupportsReasoning(agent)) {
28
48
  throw new Error(
@@ -88,6 +108,67 @@ function versionAtLeast(current, minimum) {
88
108
  return true;
89
109
  }
90
110
 
111
+ function semanticVersion(value) {
112
+ const match = value?.match(/(?:^|\D)(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?/);
113
+ if (!match) return undefined;
114
+ return {
115
+ parts: match.slice(1, 4).map(Number),
116
+ prerelease: Boolean(match[4]),
117
+ };
118
+ }
119
+
120
+ export function inspectFastCompatibility(
121
+ agent,
122
+ command,
123
+ {
124
+ env = process.env,
125
+ timeout = 5_000,
126
+ run = spawnSync,
127
+ } = {},
128
+ ) {
129
+ const capability = codingAgentCapabilities[agent]?.fast;
130
+ if (!capability) {
131
+ return {
132
+ compatible: false,
133
+ reason: `--fast is supported only by codex and claude; ${agent} does not support it.`,
134
+ };
135
+ }
136
+ const result = run(command, ['--version'], {
137
+ encoding: 'utf8',
138
+ env,
139
+ timeout,
140
+ windowsHide: true,
141
+ });
142
+ const version = firstLine(`${result.stdout || ''}\n${result.stderr || ''}`);
143
+ const current = semanticVersion(version);
144
+ const minimum = semanticVersion(capability.minimumVersion);
145
+ const compatible = current && minimum &&
146
+ (versionAtLeast(current.parts, minimum.parts) &&
147
+ !(current.prerelease &&
148
+ current.parts.every((part, index) => part === minimum.parts[index])));
149
+ if (result.error || result.status !== 0 || !version || !current) {
150
+ return {
151
+ compatible: false,
152
+ version,
153
+ reason: `${capability.label} ${capability.minimumVersion} or newer is required for --fast, but its version could not be checked. Upgrade ${capability.label}.`,
154
+ };
155
+ }
156
+ if (!compatible) {
157
+ return {
158
+ compatible: false,
159
+ version,
160
+ reason: `${capability.label} ${capability.minimumVersion} or newer is required for --fast; found ${version}. Upgrade ${capability.label}.`,
161
+ };
162
+ }
163
+ return { compatible: true, version };
164
+ }
165
+
166
+ export function assertFastCompatible(agent, command, fast, options) {
167
+ if (!fast) return;
168
+ const inspection = inspectFastCompatibility(agent, command, options);
169
+ if (!inspection.compatible) throw new Error(inspection.reason);
170
+ }
171
+
91
172
  function cursorCompatibilityError(detail) {
92
173
  return `Cursor Agent is incompatible: ${detail} ${cursorRequirementSummary} Upgrade Cursor Agent.`;
93
174
  }
@@ -263,15 +344,57 @@ function nonInteractiveSelectionError() {
263
344
  );
264
345
  }
265
346
 
266
- async function selectRequestedCodingAgent(requested, available) {
267
- if (!codingAgents.includes(requested)) {
268
- throw new Error(
269
- `Unsupported agent "${requested}". Choose ${enabledCodingAgents.join(', ')}.`,
347
+ class CodingAgentSelectionError extends Error {
348
+ constructor(agent, message) {
349
+ super(message);
350
+ this.agent = agent;
351
+ }
352
+ }
353
+
354
+ export function codingAgentFromSelectionError(error) {
355
+ return error instanceof CodingAgentSelectionError
356
+ ? error.agent
357
+ : undefined;
358
+ }
359
+
360
+ function unsupportedSelectionError(requested, configured) {
361
+ if (configured) {
362
+ return new CodingAgentSelectionError(
363
+ requested,
364
+ `Configured coding agent "${requested}" is unsupported. Choose ${enabledCodingAgents.join(', ')} with diffsplain config agent NAME, or use --agent NAME for this run.`,
270
365
  );
271
366
  }
367
+ return new CodingAgentSelectionError(
368
+ requested,
369
+ `Unsupported agent "${requested}". Choose ${enabledCodingAgents.join(', ')}.`,
370
+ );
371
+ }
372
+
373
+ function unavailableSelectionError(requested, result, configured) {
374
+ if (!configured) {
375
+ return new CodingAgentSelectionError(
376
+ requested,
377
+ unavailableAgentError(requested, result).message,
378
+ );
379
+ }
380
+ const reason = typeof result === 'object' ? result.reason : undefined;
381
+ return new CodingAgentSelectionError(
382
+ requested,
383
+ `Configured coding agent "${requested}" is not available.${reason ? ` ${reason}` : ''} Change it with diffsplain config agent NAME, or use --agent NAME for this run.`,
384
+ );
385
+ }
386
+
387
+ async function selectRequestedCodingAgent(
388
+ requested,
389
+ available,
390
+ { configured = false } = {},
391
+ ) {
392
+ if (!codingAgents.includes(requested)) {
393
+ throw unsupportedSelectionError(requested, configured);
394
+ }
272
395
  const result = await available(requested);
273
396
  if (!availableResult(result)) {
274
- throw unavailableAgentError(requested, result);
397
+ throw unavailableSelectionError(requested, result, configured);
275
398
  }
276
399
  return requested;
277
400
  }
@@ -340,15 +463,23 @@ export async function selectCodingAgent(
340
463
  requested,
341
464
  {
342
465
  available = commandAvailable,
466
+ configuredAgent = readConfiguredAgent,
343
467
  input = process.stdin,
344
468
  output = process.stderr,
345
469
  signal,
346
470
  } = {},
347
471
  ) {
348
- if (requested) {
472
+ if (requested !== undefined) {
349
473
  return selectRequestedCodingAgent(requested, available);
350
474
  }
351
475
 
476
+ const configured = await configuredAgent();
477
+ if (configured !== undefined) {
478
+ return selectRequestedCodingAgent(configured, available, {
479
+ configured: true,
480
+ });
481
+ }
482
+
352
483
  if (!input?.isTTY || !output?.isTTY) {
353
484
  throw nonInteractiveSelectionError();
354
485
  }
@@ -412,6 +543,27 @@ function parseClaudeResponse(stdout) {
412
543
  return envelope;
413
544
  }
414
545
 
546
+ function jsonLines(stdout) {
547
+ const lines = stdout.trim().split('\n').filter(Boolean);
548
+ if (!lines.length) return undefined;
549
+ const events = lines.map(parseEvent);
550
+ return events.every(Boolean) ? events : undefined;
551
+ }
552
+
553
+ function parseCodexResponse(stdout) {
554
+ const events = jsonLines(stdout);
555
+ const message = events && [...events]
556
+ .reverse()
557
+ .find((event) =>
558
+ event.type === 'item.completed' &&
559
+ event.item?.type === 'agent_message' &&
560
+ typeof event.item.text === 'string',
561
+ );
562
+ return message
563
+ ? parseJsonText(message.item.text, 'Codex')
564
+ : parseJsonText(stdout, 'Codex');
565
+ }
566
+
415
567
  function parseEvent(line) {
416
568
  try {
417
569
  return JSON.parse(line);
@@ -470,8 +622,94 @@ export function parseAgentResponse(agent, stdout) {
470
622
  if (agent === 'claude') return parseClaudeResponse(stdout);
471
623
  if (agent === 'opencode') return parseOpenCodeResponse(stdout);
472
624
  if (agent === 'cursor') return parseCursorResponse(stdout);
473
- const label = agent === 'copilot' ? 'Copilot' : 'Codex';
474
- return parseJsonText(stdout, label);
625
+ if (agent === 'codex') return parseCodexResponse(stdout);
626
+ return parseJsonText(stdout, 'Copilot');
627
+ }
628
+
629
+ function addUsageField(totals, [field, count]) {
630
+ if (!Number.isSafeInteger(count) || count < 0) return;
631
+ totals[field] = (totals[field] || 0) + count;
632
+ }
633
+
634
+ function addProviderUsage(totals, usage) {
635
+ Object.entries(usage).forEach((entry) => addUsageField(totals, entry));
636
+ return totals;
637
+ }
638
+
639
+ function sumProviderUsage(usages) {
640
+ const totals = usages.reduce(addProviderUsage, {});
641
+ return Object.keys(totals).length ? totals : undefined;
642
+ }
643
+
644
+ function providerUsage(inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens) {
645
+ return { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens };
646
+ }
647
+
648
+ function codexEventUsage(event) {
649
+ if (event.type !== 'turn.completed' || !event.usage) return undefined;
650
+ const usage = event.usage;
651
+ return providerUsage(
652
+ usage.input_tokens,
653
+ usage.output_tokens,
654
+ usage.cached_input_tokens,
655
+ usage.cache_write_input_tokens,
656
+ );
657
+ }
658
+
659
+ function eventPartTokens(event, type) {
660
+ if (event.type !== type) return undefined;
661
+ return event.part?.tokens;
662
+ }
663
+
664
+ function openCodeEventUsage(event) {
665
+ const tokens = eventPartTokens(event, 'step_finish');
666
+ if (!tokens) return undefined;
667
+ const cache = tokens.cache || {};
668
+ return providerUsage(
669
+ tokens.input,
670
+ tokens.output,
671
+ cache.read,
672
+ cache.write,
673
+ );
674
+ }
675
+
676
+ function eventUsage(stdout, extractUsage) {
677
+ const events = jsonLines(stdout) || [];
678
+ return sumProviderUsage(events.map(extractUsage).filter(Boolean));
679
+ }
680
+
681
+ function codexUsage(stdout) {
682
+ return eventUsage(stdout, codexEventUsage);
683
+ }
684
+
685
+ function safeProviderEnvelope(stdout, label) {
686
+ try {
687
+ return parseJsonText(stdout, label);
688
+ } catch {
689
+ return undefined;
690
+ }
691
+ }
692
+
693
+ function claudeUsage(stdout) {
694
+ const usage = safeProviderEnvelope(stdout, 'Claude')?.usage;
695
+ if (!usage || typeof usage !== 'object') return undefined;
696
+ return sumProviderUsage([providerUsage(
697
+ usage.input_tokens,
698
+ usage.output_tokens,
699
+ usage.cache_read_input_tokens,
700
+ usage.cache_creation_input_tokens,
701
+ )]);
702
+ }
703
+
704
+ function openCodeUsage(stdout) {
705
+ return eventUsage(stdout, openCodeEventUsage);
706
+ }
707
+
708
+ export function parseAgentUsage(agent, stdout) {
709
+ if (agent === 'codex') return codexUsage(stdout);
710
+ if (agent === 'claude') return claudeUsage(stdout);
711
+ if (agent === 'opencode') return openCodeUsage(stdout);
712
+ return undefined;
475
713
  }
476
714
 
477
715
  function codexIsolationArgs(snapshotOnly) {
@@ -503,6 +741,7 @@ function codexRepoArgs(snapshotOnly) {
503
741
 
504
742
  function codexCommand({
505
743
  binary,
744
+ fast,
506
745
  model,
507
746
  reasoning,
508
747
  prompt,
@@ -514,6 +753,7 @@ function codexCommand({
514
753
  const args = [
515
754
  'exec',
516
755
  '--ephemeral',
756
+ '--json',
517
757
  '--sandbox',
518
758
  'read-only',
519
759
  ...codexIsolationArgs(snapshotOnly),
@@ -526,6 +766,14 @@ function codexCommand({
526
766
  schemaPath,
527
767
  ...codexConfigArgs(snapshotOnly),
528
768
  ];
769
+ if (fast) {
770
+ args.push(
771
+ '--config',
772
+ 'service_tier="fast"',
773
+ '--config',
774
+ 'features.fast_mode=true',
775
+ );
776
+ }
529
777
  if (model) args.push('--model', model);
530
778
  if (reasoning) {
531
779
  args.push(
@@ -545,6 +793,7 @@ function codexCommand({
545
793
 
546
794
  function claudeCommand({
547
795
  binary,
796
+ fast,
548
797
  model,
549
798
  prompt,
550
799
  schema,
@@ -561,6 +810,7 @@ function claudeCommand({
561
810
  ...(snapshotOnly ? ['--tools', ''] : ['--permission-mode', 'plan']),
562
811
  '--no-session-persistence',
563
812
  ];
813
+ if (fast) args.push('--settings', JSON.stringify({ fastMode: true }));
564
814
  if (model) args.push('--model', model);
565
815
  args.push(prompt);
566
816
  return {
@@ -713,6 +963,7 @@ export function agentCommand({
713
963
  binary = agent,
714
964
  model,
715
965
  reasoning,
966
+ fast = false,
716
967
  prompt,
717
968
  schema,
718
969
  schemaPath,
@@ -724,6 +975,7 @@ export function agentCommand({
724
975
  const checkoutAccess = accessMode?.mode === 'checkout-read-only';
725
976
  const options = {
726
977
  binary,
978
+ fast,
727
979
  inputPath,
728
980
  model,
729
981
  prompt,