@jpkme/diff-review 0.1.6 → 0.1.7

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 (83) hide show
  1. package/README.md +32 -1
  2. package/bin/diff-review.mjs +221 -17
  3. package/lib/agents.mjs +304 -0
  4. package/lib/agents.test.mjs +195 -0
  5. package/lib/fingerprint.mjs +24 -0
  6. package/lib/git.mjs +2 -19
  7. package/lib/output-name.mjs +1 -1
  8. package/lib/prompt.mjs +6 -6
  9. package/lib/wait-output.mjs +43 -17
  10. package/lib/wait-output.test.mjs +67 -0
  11. package/package.json +1 -1
  12. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css +1 -0
  13. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css.br +0 -0
  14. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css.gz +0 -0
  15. package/web/build/client/_app/immutable/chunks/C4--ziCr.js +2 -0
  16. package/web/build/client/_app/immutable/chunks/C4--ziCr.js.br +0 -0
  17. package/web/build/client/_app/immutable/chunks/C4--ziCr.js.gz +0 -0
  18. package/web/build/client/_app/immutable/chunks/{DRga3k74.js → D8sAxZVo.js} +20 -20
  19. package/web/build/client/_app/immutable/chunks/D8sAxZVo.js.br +0 -0
  20. package/web/build/client/_app/immutable/chunks/D8sAxZVo.js.gz +0 -0
  21. package/web/build/client/_app/immutable/entry/{app.BP_R_kTF.js → app.Dl9OTE-n.js} +2 -2
  22. package/web/build/client/_app/immutable/entry/app.Dl9OTE-n.js.br +0 -0
  23. package/web/build/client/_app/immutable/entry/app.Dl9OTE-n.js.gz +0 -0
  24. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js +1 -0
  25. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js.br +1 -0
  26. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js.gz +0 -0
  27. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js +1 -0
  28. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js.br +0 -0
  29. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js.gz +0 -0
  30. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js +66 -0
  31. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js.br +0 -0
  32. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js.gz +0 -0
  33. package/web/build/client/_app/immutable/nodes/{3.Bn5l-P1e.js → 3.C-USexyX.js} +2 -2
  34. package/web/build/client/_app/immutable/nodes/3.C-USexyX.js.br +0 -0
  35. package/web/build/client/_app/immutable/nodes/3.C-USexyX.js.gz +0 -0
  36. package/web/build/client/_app/version.json +1 -1
  37. package/web/build/client/_app/version.json.br +0 -0
  38. package/web/build/client/_app/version.json.gz +0 -0
  39. package/web/build/handler.js +3 -3
  40. package/web/build/index.js +3 -3
  41. package/web/build/server/chunks/1-0X6Jp9nH.js +7 -0
  42. package/web/build/server/chunks/1-0X6Jp9nH.js.map +1 -0
  43. package/web/build/server/chunks/2-HlcVALg6.js +7 -0
  44. package/web/build/server/chunks/2-HlcVALg6.js.map +1 -0
  45. package/web/build/server/chunks/{3-DaeIJXI7.js → 3-BN1eS7L2.js} +2 -2
  46. package/web/build/server/chunks/{3-DaeIJXI7.js.map → 3-BN1eS7L2.js.map} +1 -1
  47. package/web/build/server/chunks/{handler-2eew4UOl.js → handler-cq3QoEzW.js} +3 -3
  48. package/web/build/server/chunks/{handler-2eew4UOl.js.map → handler-cq3QoEzW.js.map} +1 -1
  49. package/web/build/server/chunks/{index.js-DbiNsix3.js → index.js-D4qkHe3r.js} +2 -2
  50. package/web/build/server/chunks/{index.js-DbiNsix3.js.map → index.js-D4qkHe3r.js.map} +1 -1
  51. package/web/build/server/chunks/{manifest.js-BYNXhn6E.js → manifest.js-DQHMKyJh.js} +6 -6
  52. package/web/build/server/chunks/{manifest.js-BYNXhn6E.js.map → manifest.js-DQHMKyJh.js.map} +1 -1
  53. package/web/build/server/chunks/{remote-1g4ayag-DESnyPRn.js → remote-1g4ayag-B8KZPTkn.js} +154 -149
  54. package/web/build/server/chunks/remote-1g4ayag-B8KZPTkn.js.map +1 -0
  55. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css +0 -1
  56. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.br +0 -0
  57. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.gz +0 -0
  58. package/web/build/client/_app/immutable/chunks/Ber_0nz7.js +0 -1
  59. package/web/build/client/_app/immutable/chunks/Ber_0nz7.js.br +0 -2
  60. package/web/build/client/_app/immutable/chunks/Ber_0nz7.js.gz +0 -0
  61. package/web/build/client/_app/immutable/chunks/DRga3k74.js.br +0 -0
  62. package/web/build/client/_app/immutable/chunks/DRga3k74.js.gz +0 -0
  63. package/web/build/client/_app/immutable/chunks/zvcaSoGP.js +0 -2
  64. package/web/build/client/_app/immutable/chunks/zvcaSoGP.js.br +0 -0
  65. package/web/build/client/_app/immutable/chunks/zvcaSoGP.js.gz +0 -0
  66. package/web/build/client/_app/immutable/entry/app.BP_R_kTF.js.br +0 -0
  67. package/web/build/client/_app/immutable/entry/app.BP_R_kTF.js.gz +0 -0
  68. package/web/build/client/_app/immutable/entry/start.lXpYdwbt.js +0 -1
  69. package/web/build/client/_app/immutable/entry/start.lXpYdwbt.js.br +0 -1
  70. package/web/build/client/_app/immutable/entry/start.lXpYdwbt.js.gz +0 -0
  71. package/web/build/client/_app/immutable/nodes/1.CGJk77oo.js +0 -1
  72. package/web/build/client/_app/immutable/nodes/1.CGJk77oo.js.br +0 -3
  73. package/web/build/client/_app/immutable/nodes/1.CGJk77oo.js.gz +0 -0
  74. package/web/build/client/_app/immutable/nodes/2.Dl2xIvoV.js +0 -66
  75. package/web/build/client/_app/immutable/nodes/2.Dl2xIvoV.js.br +0 -0
  76. package/web/build/client/_app/immutable/nodes/2.Dl2xIvoV.js.gz +0 -0
  77. package/web/build/client/_app/immutable/nodes/3.Bn5l-P1e.js.br +0 -0
  78. package/web/build/client/_app/immutable/nodes/3.Bn5l-P1e.js.gz +0 -0
  79. package/web/build/server/chunks/1-CRg9pswO.js +0 -7
  80. package/web/build/server/chunks/1-CRg9pswO.js.map +0 -1
  81. package/web/build/server/chunks/2-BmYzresl.js +0 -7
  82. package/web/build/server/chunks/2-BmYzresl.js.map +0 -1
  83. package/web/build/server/chunks/remote-1g4ayag-DESnyPRn.js.map +0 -1
package/README.md CHANGED
@@ -32,13 +32,44 @@ Desde un **repo git**, el default es terminal-first:
32
32
 
33
33
  Si pasás `--branch` y `--base` juntos, saltea los selects. Si quedan iguales (diff vacío), abre la UI.
34
34
 
35
+ Si ya hay un JSON válido para esos mismos commits, pregunta si abrir el existente o generarlo de nuevo; en ese
36
+ caso el archivo viejo no cuenta hasta que el agente lo reescriba. Sin terminal interactiva no pregunta: con
37
+ `--agent` lo genera de nuevo y sin `--agent` abre el existente.
38
+
39
+ ### Lanzar el agente (`--agent`)
40
+
41
+ Si hay agentes instalados, después de branch y base un solo select lista copiar el prompt al portapapeles (primera
42
+ opción) y cada agente detectado. Con `--agent` saltea esa lista y va directo a elegir agente y modelo:
43
+
44
+ ```bash
45
+ diff-review --agent
46
+ ```
47
+
48
+ Al enviarlo a un agente, en vez de copiar el prompt pregunta el modelo (con `--agent`, primero el agente):
49
+
50
+ 1. **Agente**: los instalados que encuentra (Claude Code, Codex, Cursor CLI, GitHub Copilot CLI, Gemini CLI). Si ya lo elegiste en la lista anterior, este paso no se repite. Se buscan por nombre en el `PATH` y en algunas rutas fijas: en Windows, el Claude Code que trae la app de
51
+ escritorio (`%APPDATA%\Claude\claude-code\<versión>\…\claude.exe`) y `%LOCALAPPDATA%\cursor-agent\cursor-agent.cmd`;
52
+ en Linux/macOS, `~/.local/bin/claude`, `~/.claude/local/claude` y `~/.local/bin/cursor-agent`.
53
+ 2. **Modelo**: arranca en el default del agente (el configurado en `~/.claude/settings.json` /
54
+ `ANTHROPIC_MODEL`, `~/.codex/config.toml`, `~/.cursor/cli-config.json`, `~/.copilot/settings.json` o
55
+ `~/.gemini/settings.json`; si no hay, no se pasa `--model`). También lista los conocidos (para Claude, los
56
+ alias `opus` / `sonnet` / `haiku`; para Copilot, `auto`) y "Otro…" para escribir un id.
57
+
58
+ El prompt viaja por stdin y la salida del agente se ve en la terminal. Claude Code y Copilot corren con permisos
59
+ acotados: leer, `git`, el `validate` y escribir solo el JSON del review (Copilot no puede acotar por argumentos,
60
+ así que permite `npx` en general). Cursor corre con `--force` (no tiene
61
+ flag para pasar una allowlist y sin él bloquearía el `validate`) y Gemini con `--yolo`. Si no hay agentes
62
+ instalados, sigue con el portapapeles. Sin terminal interactiva usa el primer agente con su modelo por defecto.
63
+
64
+ El agente tiene que estar logueado (p. ej. `claude /login` una vez).
65
+
35
66
  Para abrir la UI primero (sin esperar el JSON):
36
67
 
37
68
  ```bash
38
69
  diff-review --ui
39
70
  ```
40
71
 
41
- Opciones: `--port`, `--host`, `--base`, `--branch`, `--no-open`.
72
+ Opciones: `--port` (si está ocupado, usa el siguiente libre), `--host`, `--base`, `--branch`, `--agent`, `--no-open`.
42
73
 
43
74
  Si lo lanzás desde un repositorio git, la UI precarga esa ruta y el branch actual.
44
75
 
@@ -6,6 +6,7 @@
6
6
  *
7
7
  * diff-review --port 5191
8
8
  * diff-review --branch x --base y → saltea selects de Clack
9
+ * diff-review --agent → elegir agente + modelo y lanzarlo con el prompt (sin portapapeles)
9
10
  * diff-review --no-open
10
11
  */
11
12
 
@@ -15,12 +16,13 @@ import { createServer } from 'node:net';
15
16
  import { dirname, join } from 'node:path';
16
17
  import { fileURLToPath } from 'node:url';
17
18
  import * as p from '@clack/prompts';
19
+ import { AGENTS, detectAgents, modelChoices, OTHER_MODEL, runAgent } from '../lib/agents.mjs';
18
20
  import { autocompleteEs } from '../lib/autocomplete-es.mjs';
19
21
  import { copyTextToClipboard } from '../lib/clipboard.mjs';
20
22
  import { branchTips, getRemoteUrl, listBranches, tryResolveGitRoot } from '../lib/git.mjs';
21
23
  import { makeOutputFilename } from '../lib/output-name.mjs';
22
24
  import { buildPrompt } from '../lib/prompt.mjs';
23
- import { waitForReviewOutput } from '../lib/wait-output.mjs';
25
+ import { existingReviewOutput, waitForReviewOutput } from '../lib/wait-output.mjs';
24
26
  import { runValidateCli } from '../lib/check-report.mjs';
25
27
 
26
28
  const here = dirname(fileURLToPath(import.meta.url));
@@ -54,17 +56,21 @@ Usage:
54
56
  diff-review validate <file.json>
55
57
 
56
58
  Por defecto (desde un repo git): elegís branch/base en la terminal (Clack),
57
- copia el prompt, espera el JSON y abre el viewer.
59
+ copia el prompt, espera el JSON y abre el viewer. Si hay agentes instalados,
60
+ un solo select lista el portapapeles y cada agente detectado.
58
61
  \`validate\` revisa el JSON y, si falla, dice cómo seguir (exit 0 = OK).
59
62
  Si pasás --branch y --base juntos, saltea los selects.
63
+ Con --agent: saltea esa lista; elegís un agente instalado y el modelo,
64
+ y diff-review lo lanza con el prompt.
60
65
  Con --ui: abre la UI de inmediato (web-first).
61
66
 
62
67
  Options:
63
- -p, --port <n> Puerto (default ${DEFAULT_PORT})
68
+ -p, --port <n> Puerto (default ${DEFAULT_PORT}; si está ocupado, el siguiente libre)
64
69
  --host <h> Host (default ${DEFAULT_HOST})
65
70
  --ui Abrir la UI primero (sin esperar JSON)
66
71
  --base <b> Base del merge-base (salta el select de base)
67
72
  --branch <b> Branch a revisar (salta el select de branch)
73
+ --agent Lanzar un agente instalado (${AGENTS.map((a) => a.bins[0]).join(', ')}) con el prompt
68
74
  --no-open No abrir el navegador
69
75
  -h, --help Esta ayuda
70
76
 
@@ -79,6 +85,7 @@ function parseArgs(argv) {
79
85
  open: true,
80
86
  help: false,
81
87
  ui: false,
88
+ agent: false,
82
89
  base: '',
83
90
  branch: ''
84
91
  };
@@ -87,6 +94,7 @@ function parseArgs(argv) {
87
94
  if (a === '-h' || a === '--help') opts.help = true;
88
95
  else if (a === '--no-open') opts.open = false;
89
96
  else if (a === '--ui') opts.ui = true;
97
+ else if (a === '--agent') opts.agent = true;
90
98
  else if (a === '--host') opts.host = argv[++i] || opts.host;
91
99
  else if (a === '--base') opts.base = argv[++i] || opts.base;
92
100
  else if (a === '--branch') opts.branch = argv[++i] || opts.branch;
@@ -136,6 +144,18 @@ function canListen(host, port) {
136
144
  });
137
145
  }
138
146
 
147
+ const PORT_SCAN = 50;
148
+
149
+ /** Primer puerto libre desde `start`, o `null` si ninguno de los siguientes cabe. */
150
+ async function findFreePort(host, start) {
151
+ const first = Math.max(1, Math.floor(start));
152
+ const last = Math.min(65535, first + PORT_SCAN - 1);
153
+ for (let port = first; port <= last; port++) {
154
+ if (await canListen(host, port)) return port;
155
+ }
156
+ return null;
157
+ }
158
+
139
159
  /**
140
160
  * @returns {{
141
161
  * repo: string,
@@ -170,6 +190,11 @@ function branchOptions(listed) {
170
190
  }));
171
191
  }
172
192
 
193
+ /** Selects solo con terminal interactiva. (`p.isTTY` de Clack es una función, no un booleano.) */
194
+ function isInteractive() {
195
+ return p.isTTY(process.stdin) && p.isTTY(process.stdout);
196
+ }
197
+
173
198
  function ensureValue(value, label) {
174
199
  if (p.isCancel(value)) {
175
200
  p.cancel('Cancelado.');
@@ -196,7 +221,7 @@ async function resolveBranchBase(launch, opts) {
196
221
  return { branch: flagBranch, base: flagBase, prompted: false };
197
222
  }
198
223
 
199
- if (!p.isTTY) {
224
+ if (!isInteractive()) {
200
225
  const branch = flagBranch || defaultBranch(launch.listed);
201
226
  const base = flagBase || defaultBase(launch.listed, branch);
202
227
  return { branch, base, prompted: false };
@@ -281,6 +306,142 @@ function copyPromptOrWarn(prompt) {
281
306
  return false;
282
307
  }
283
308
 
309
+ /**
310
+ * Modelo del agente: select con TTY; sin TTY, el modelo por defecto (string vacío).
311
+ * @param {import('../lib/agents.mjs').DetectedAgent} agent
312
+ */
313
+ async function pickModel(agent) {
314
+ if (!isInteractive()) return '';
315
+
316
+ const { options, initialValue } = modelChoices(agent.def);
317
+ const choice = await p.select({ message: 'Modelo', options, initialValue });
318
+ if (p.isCancel(choice)) {
319
+ p.cancel('Cancelado.');
320
+ process.exit(0);
321
+ }
322
+ if (choice !== OTHER_MODEL) return /** @type {string} */ (choice);
323
+
324
+ const typed = ensureValue(
325
+ await p.text({
326
+ message: 'Id del modelo',
327
+ validate: (v) => (String(v ?? '').trim() ? undefined : 'Escribí el id del modelo.')
328
+ }),
329
+ 'modelo'
330
+ );
331
+ return typed.trim();
332
+ }
333
+
334
+ /**
335
+ * Agente y modelo: selects con TTY; sin TTY, el primer agente detectado con su modelo por defecto.
336
+ * Si `preset` ya viene elegido, solo pregunta el modelo.
337
+ * @param {import('../lib/agents.mjs').DetectedAgent[]} detected
338
+ * @param {import('../lib/agents.mjs').DetectedAgent} [preset]
339
+ */
340
+ async function pickAgentAndModel(detected, preset) {
341
+ if (!isInteractive()) return { agent: preset ?? detected[0], model: '' };
342
+
343
+ let agent = preset;
344
+ if (!agent) {
345
+ const id = ensureValue(
346
+ await p.select({
347
+ message: 'Agente',
348
+ options: detected.map((a) => ({ value: a.def.id, label: a.def.label, hint: a.path })),
349
+ initialValue: detected[0].def.id
350
+ }),
351
+ 'agente'
352
+ );
353
+ agent = detected.find((a) => a.def.id === id) ?? detected[0];
354
+ }
355
+
356
+ return { agent, model: await pickModel(agent) };
357
+ }
358
+
359
+ /**
360
+ * Portapapeles primero y después cada agente detectado.
361
+ * @param {import('../lib/agents.mjs').DetectedAgent[]} detected
362
+ * @returns {Promise<import('../lib/agents.mjs').DetectedAgent | null>} `null` = portapapeles
363
+ */
364
+ async function pickDelivery(detected) {
365
+ const choice = ensureValue(
366
+ await p.select({
367
+ message: '¿Cómo generás el review?',
368
+ options: [
369
+ { value: 'clipboard', label: 'Copiar el prompt al portapapeles', hint: 'lo pegás en tu agente' },
370
+ ...detected.map((a) => ({ value: a.def.id, label: a.def.label, hint: a.path }))
371
+ ],
372
+ initialValue: 'clipboard'
373
+ }),
374
+ 'modo'
375
+ );
376
+ if (choice === 'clipboard') return null;
377
+ return detected.find((a) => a.def.id === choice) ?? detected[0];
378
+ }
379
+
380
+ /**
381
+ * Ya hay un review válido para estos commits: pregunta si abrirlo o generarlo de nuevo.
382
+ * @param {{ path: string }} existing
383
+ * @returns {Promise<{ path: string } | null>} el existente, o `null` para generar de nuevo
384
+ */
385
+ async function reuseExistingReview(existing, outputFilename) {
386
+ const choice = ensureValue(
387
+ await p.select({
388
+ message: `Ya hay un review válido para estos commits (${outputFilename})`,
389
+ options: [
390
+ { value: 'open', label: 'Abrir el existente' },
391
+ { value: 'regenerate', label: 'Generar de nuevo', hint: 'el agente lo reemplaza' }
392
+ ],
393
+ initialValue: 'open'
394
+ }),
395
+ 'review'
396
+ );
397
+ if (choice !== 'open') return null;
398
+ p.outro('Abro el review existente.');
399
+ return existing;
400
+ }
401
+
402
+ /**
403
+ * Lanza el agente y espera el JSON. Devuelve el reporte válido; si el agente termina sin dejarlo, sale con error.
404
+ * @param {import('../lib/agents.mjs').DetectedAgent} agent
405
+ * @param {{ repo: string, prompt: string, model: string, outputFilename: string, useClack: boolean }} run
406
+ */
407
+ async function runAgentAndWait(agent, { repo, prompt, model, outputFilename, useClack }) {
408
+ const what = `${agent.def.label} (${model || 'modelo por defecto'})`;
409
+ const msg = `Lanzando ${what}. Espera ${outputFilename} en la raíz del repo; Ctrl+C cancela.`;
410
+ if (useClack) p.outro(msg);
411
+ else console.log(msg);
412
+
413
+ // Solo cuenta lo que escriba este agente: un JSON anterior de los mismos commits no es el resultado.
414
+ const since = Date.now();
415
+ const { exited } = runAgent(agent, { cwd: repo, prompt, model, outputFilename });
416
+ const waiting = waitForReviewOutput(repo, outputFilename, {
417
+ since,
418
+ onInvalid: (errors) => {
419
+ console.log(`JSON encontrado pero inválido (${errors.length} error(es)). El agente puede corregirlo…`);
420
+ for (const e of errors.slice(0, 5)) console.log(` · ${e}`);
421
+ }
422
+ });
423
+
424
+ let code;
425
+ try {
426
+ code = await exited;
427
+ } catch (err) {
428
+ console.error(`No se pudo lanzar ${agent.path}: ${err instanceof Error ? err.message : err}`);
429
+ process.exit(1);
430
+ }
431
+ // El watcher puede ir un tick atrás de la última escritura del agente.
432
+ const result = await Promise.race([waiting, new Promise((r) => setTimeout(() => r(null), 2000))]);
433
+ if (!result) {
434
+ console.error(
435
+ code === 0
436
+ ? `${agent.def.label} terminó sin dejar un ${outputFilename} válido.`
437
+ : `${agent.def.label} terminó con código ${code} sin dejar un ${outputFilename} válido.`
438
+ );
439
+ process.exit(1);
440
+ }
441
+ if (code !== 0) console.warn(`${agent.def.label} terminó con código ${code}, pero el JSON es válido.`);
442
+ return /** @type {{ path: string }} */ (result);
443
+ }
444
+
284
445
  function explainWaitingForAgent(outputFilename, { copied, useClack }) {
285
446
  const copyMsg = copied
286
447
  ? 'Se copió un prompt al portapapeles: pegalo en el chat de tu agente de IA para generar el contenido de la review.'
@@ -309,12 +470,17 @@ O reinstalá el paquete (el tarball de npm incluye el build).`);
309
470
  process.exit(1);
310
471
  }
311
472
 
312
- if (!(await canListen(cfg.host, cfg.port))) {
313
- console.error(`No se pudo usar ${cfg.host}:${cfg.port} (¿ocupado?). Probá --port otro.`);
473
+ const port = await findFreePort(cfg.host, cfg.port);
474
+ if (port == null) {
475
+ const last = Math.min(65535, cfg.port + PORT_SCAN - 1);
476
+ console.error(`No encontré un puerto libre entre ${cfg.port} y ${last} en ${cfg.host}.`);
314
477
  process.exit(1);
315
478
  }
479
+ if (port !== cfg.port) {
480
+ console.log(`Puerto ${cfg.port} ocupado; la UI usa ${port}.`);
481
+ }
316
482
 
317
- const origin = `http://${cfg.host}:${cfg.port}`;
483
+ const origin = `http://${cfg.host}:${port}`;
318
484
  const openUrl = cfg.openPath ? `${origin}${cfg.openPath}` : origin;
319
485
  const nodeBin = resolveNodeBin();
320
486
  const child = spawn(nodeBin, [entry], {
@@ -322,7 +488,7 @@ O reinstalá el paquete (el tarball de npm incluye el build).`);
322
488
  env: {
323
489
  ...process.env,
324
490
  HOST: cfg.host,
325
- PORT: String(cfg.port),
491
+ PORT: String(port),
326
492
  ORIGIN: origin,
327
493
  DIFF_REVIEW_LAUNCH_CWD: cfg.launchCwd,
328
494
  ...(cfg.importFile ? { DIFF_REVIEW_IMPORT_FILE: cfg.importFile } : {})
@@ -401,7 +567,9 @@ async function runUiMode(opts, launch, launchCwd) {
401
567
  port: opts.port,
402
568
  open: opts.open,
403
569
  launchCwd,
404
- openPath: launch ? `/?notify=prompt&out=${encodeURIComponent(outputFilename)}` : '/'
570
+ openPath: launch
571
+ ? `/?watch=1&notify=prompt&out=${encodeURIComponent(outputFilename)}`
572
+ : '/'
405
573
  });
406
574
  }
407
575
 
@@ -469,15 +637,51 @@ async function main() {
469
637
  { outputFilename }
470
638
  );
471
639
 
472
- const copied = copyPromptOrWarn(prompt);
473
- explainWaitingForAgent(outputFilename, { copied, useClack: prompted });
640
+ // Sin --agent también se detectan (con terminal interactiva) para ofrecer lanzar uno en vez del portapapeles.
641
+ const detected = opts.agent || isInteractive() ? detectAgents() : [];
642
+ if (opts.agent && !detected.length) {
643
+ const names = AGENTS.map((a) => a.bins[0]).join(', ');
644
+ const warn = `No encontré agentes instalados (${names}). Sigo copiando el prompt.`;
645
+ if (prompted) p.log.warn(warn);
646
+ else console.warn(warn);
647
+ }
474
648
 
475
- const result = await waitForReviewOutput(launch.repo, outputFilename, {
476
- onInvalid: (errors) => {
477
- console.log(`JSON encontrado pero inválido (${errors.length} error(es)). Esperando corrección…`);
478
- for (const e of errors.slice(0, 5)) console.log(` · ${e}`);
479
- }
480
- });
649
+ // Hay selects (agente/modelo, o abrir vs. regenerar un review existente) solo con terminal interactiva.
650
+ const existing = isInteractive() ? existingReviewOutput(launch.repo, outputFilename) : null;
651
+ const useClack = prompted || (isInteractive() && (detected.length > 0 || existing != null));
652
+ // Con flags de branch/base no hubo intro, pero estos selects abren sesión de Clack igual.
653
+ if (useClack && !prompted) p.intro('diff-review');
654
+
655
+ let result = existing ? await reuseExistingReview(existing, outputFilename) : null;
656
+ /** @type {import('../lib/agents.mjs').DetectedAgent | null} */
657
+ let chosen = null;
658
+ if (!result && detected.length > 0 && !opts.agent && isInteractive()) {
659
+ chosen = await pickDelivery(detected);
660
+ }
661
+ const useAgent = !result && detected.length > 0 && (opts.agent || chosen != null);
662
+ if (useAgent) {
663
+ const { agent, model } = await pickAgentAndModel(detected, chosen ?? undefined);
664
+ result = await runAgentAndWait(agent, {
665
+ repo: launch.repo,
666
+ prompt,
667
+ model,
668
+ outputFilename,
669
+ useClack
670
+ });
671
+ } else if (!result) {
672
+ // Si eligió generar de nuevo, el JSON existente no cuenta hasta que el agente lo reescriba.
673
+ const since = existing ? Date.now() : undefined;
674
+ const copied = copyPromptOrWarn(prompt);
675
+ explainWaitingForAgent(outputFilename, { copied, useClack });
676
+
677
+ result = await waitForReviewOutput(launch.repo, outputFilename, {
678
+ since,
679
+ onInvalid: (errors) => {
680
+ console.log(`JSON encontrado pero inválido (${errors.length} error(es)). Esperando corrección…`);
681
+ for (const e of errors.slice(0, 5)) console.log(` · ${e}`);
682
+ }
683
+ });
684
+ }
481
685
 
482
686
  console.log('JSON OK. Abriendo la UI web…');
483
687
  await startServer({