@jpkme/diff-review 0.1.6 → 0.1.10

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 (97) hide show
  1. package/README.md +32 -1
  2. package/bin/diff-review.mjs +227 -18
  3. package/lib/agents.mjs +319 -0
  4. package/lib/agents.test.mjs +195 -0
  5. package/lib/check-report.mjs +2 -1
  6. package/lib/check-report.test.mjs +1 -0
  7. package/lib/fingerprint.mjs +24 -0
  8. package/lib/git.mjs +2 -19
  9. package/lib/output-name.mjs +1 -1
  10. package/lib/prompt.mjs +19 -18
  11. package/lib/review-meta.mjs +33 -0
  12. package/lib/review-meta.test.mjs +30 -0
  13. package/lib/review.test.mjs +1 -0
  14. package/lib/validate.mjs +28 -11
  15. package/lib/validate.test.mjs +25 -0
  16. package/lib/wait-output.mjs +43 -17
  17. package/lib/wait-output.test.mjs +68 -0
  18. package/package.json +1 -1
  19. package/schema.json +9 -10
  20. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css +1 -0
  21. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.br +0 -0
  22. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.gz +0 -0
  23. package/web/build/client/_app/immutable/assets/{3.qhPDz1xQ.css → 3.PHTPLlZ7.css} +1 -1
  24. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css.br +0 -0
  25. package/web/build/client/_app/immutable/assets/{3.qhPDz1xQ.css.gz → 3.PHTPLlZ7.css.gz} +0 -0
  26. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js +2 -0
  27. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js.br +0 -0
  28. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js.gz +0 -0
  29. package/web/build/client/_app/immutable/chunks/{DRga3k74.js → QdZF3nkf.js} +35 -33
  30. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js.br +0 -0
  31. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js.gz +0 -0
  32. package/web/build/client/_app/immutable/entry/{app.BP_R_kTF.js → app.Dce6uJU3.js} +2 -2
  33. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.br +0 -0
  34. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.gz +0 -0
  35. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js +1 -0
  36. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js.br +0 -0
  37. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js.gz +0 -0
  38. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js +1 -0
  39. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.br +0 -0
  40. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.gz +0 -0
  41. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js +66 -0
  42. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.br +0 -0
  43. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.gz +0 -0
  44. package/web/build/client/_app/immutable/nodes/{3.Bn5l-P1e.js → 3.CeUs4lBX.js} +2 -2
  45. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.br +0 -0
  46. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.gz +0 -0
  47. package/web/build/client/_app/version.json +1 -1
  48. package/web/build/client/_app/version.json.br +0 -0
  49. package/web/build/client/_app/version.json.gz +0 -0
  50. package/web/build/handler.js +3 -3
  51. package/web/build/index.js +3 -3
  52. package/web/build/server/chunks/1-2OO2bb7H.js +7 -0
  53. package/web/build/server/chunks/1-2OO2bb7H.js.map +1 -0
  54. package/web/build/server/chunks/2-BJvuqKN6.js +7 -0
  55. package/web/build/server/chunks/2-BJvuqKN6.js.map +1 -0
  56. package/web/build/server/chunks/3-ZIl1rHef.js +7 -0
  57. package/web/build/server/chunks/{3-DaeIJXI7.js.map → 3-ZIl1rHef.js.map} +1 -1
  58. package/web/build/server/chunks/{handler-2eew4UOl.js → handler-B5P65tLk.js} +3 -3
  59. package/web/build/server/chunks/{handler-2eew4UOl.js.map → handler-B5P65tLk.js.map} +1 -1
  60. package/web/build/server/chunks/{index.js-DbiNsix3.js → index.js-CL95e0Gl.js} +2 -2
  61. package/web/build/server/chunks/index.js-CL95e0Gl.js.map +1 -0
  62. package/web/build/server/chunks/{manifest.js-BYNXhn6E.js → manifest.js-uD_a0079.js} +6 -6
  63. package/web/build/server/chunks/{manifest.js-BYNXhn6E.js.map → manifest.js-uD_a0079.js.map} +1 -1
  64. package/web/build/server/chunks/{remote-1g4ayag-DESnyPRn.js → remote-1g4ayag-B67QMR_2.js} +154 -149
  65. package/web/build/server/chunks/remote-1g4ayag-B67QMR_2.js.map +1 -0
  66. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css +0 -1
  67. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.br +0 -0
  68. package/web/build/client/_app/immutable/assets/2.Xnfq26gJ.css.gz +0 -0
  69. package/web/build/client/_app/immutable/assets/3.qhPDz1xQ.css.br +0 -0
  70. package/web/build/client/_app/immutable/chunks/Ber_0nz7.js +0 -1
  71. package/web/build/client/_app/immutable/chunks/Ber_0nz7.js.br +0 -2
  72. package/web/build/client/_app/immutable/chunks/Ber_0nz7.js.gz +0 -0
  73. package/web/build/client/_app/immutable/chunks/DRga3k74.js.br +0 -0
  74. package/web/build/client/_app/immutable/chunks/DRga3k74.js.gz +0 -0
  75. package/web/build/client/_app/immutable/chunks/zvcaSoGP.js +0 -2
  76. package/web/build/client/_app/immutable/chunks/zvcaSoGP.js.br +0 -0
  77. package/web/build/client/_app/immutable/chunks/zvcaSoGP.js.gz +0 -0
  78. package/web/build/client/_app/immutable/entry/app.BP_R_kTF.js.br +0 -0
  79. package/web/build/client/_app/immutable/entry/app.BP_R_kTF.js.gz +0 -0
  80. package/web/build/client/_app/immutable/entry/start.lXpYdwbt.js +0 -1
  81. package/web/build/client/_app/immutable/entry/start.lXpYdwbt.js.br +0 -1
  82. package/web/build/client/_app/immutable/entry/start.lXpYdwbt.js.gz +0 -0
  83. package/web/build/client/_app/immutable/nodes/1.CGJk77oo.js +0 -1
  84. package/web/build/client/_app/immutable/nodes/1.CGJk77oo.js.br +0 -3
  85. package/web/build/client/_app/immutable/nodes/1.CGJk77oo.js.gz +0 -0
  86. package/web/build/client/_app/immutable/nodes/2.Dl2xIvoV.js +0 -66
  87. package/web/build/client/_app/immutable/nodes/2.Dl2xIvoV.js.br +0 -0
  88. package/web/build/client/_app/immutable/nodes/2.Dl2xIvoV.js.gz +0 -0
  89. package/web/build/client/_app/immutable/nodes/3.Bn5l-P1e.js.br +0 -0
  90. package/web/build/client/_app/immutable/nodes/3.Bn5l-P1e.js.gz +0 -0
  91. package/web/build/server/chunks/1-CRg9pswO.js +0 -7
  92. package/web/build/server/chunks/1-CRg9pswO.js.map +0 -1
  93. package/web/build/server/chunks/2-BmYzresl.js +0 -7
  94. package/web/build/server/chunks/2-BmYzresl.js.map +0 -1
  95. package/web/build/server/chunks/3-DaeIJXI7.js +0 -7
  96. package/web/build/server/chunks/index.js-DbiNsix3.js.map +0 -1
  97. 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,14 @@ 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, resolvedRunModel, 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';
26
+ import { stampReviewProvenance } from '../lib/review-meta.mjs';
24
27
  import { runValidateCli } from '../lib/check-report.mjs';
25
28
 
26
29
  const here = dirname(fileURLToPath(import.meta.url));
@@ -54,17 +57,21 @@ Usage:
54
57
  diff-review validate <file.json>
55
58
 
56
59
  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.
60
+ copia el prompt, espera el JSON y abre el viewer. Si hay agentes instalados,
61
+ un solo select lista el portapapeles y cada agente detectado.
58
62
  \`validate\` revisa el JSON y, si falla, dice cómo seguir (exit 0 = OK).
59
63
  Si pasás --branch y --base juntos, saltea los selects.
64
+ Con --agent: saltea esa lista; elegís un agente instalado y el modelo,
65
+ y diff-review lo lanza con el prompt.
60
66
  Con --ui: abre la UI de inmediato (web-first).
61
67
 
62
68
  Options:
63
- -p, --port <n> Puerto (default ${DEFAULT_PORT})
69
+ -p, --port <n> Puerto (default ${DEFAULT_PORT}; si está ocupado, el siguiente libre)
64
70
  --host <h> Host (default ${DEFAULT_HOST})
65
71
  --ui Abrir la UI primero (sin esperar JSON)
66
72
  --base <b> Base del merge-base (salta el select de base)
67
73
  --branch <b> Branch a revisar (salta el select de branch)
74
+ --agent Lanzar un agente instalado (${AGENTS.map((a) => a.bins[0]).join(', ')}) con el prompt
68
75
  --no-open No abrir el navegador
69
76
  -h, --help Esta ayuda
70
77
 
@@ -79,6 +86,7 @@ function parseArgs(argv) {
79
86
  open: true,
80
87
  help: false,
81
88
  ui: false,
89
+ agent: false,
82
90
  base: '',
83
91
  branch: ''
84
92
  };
@@ -87,6 +95,7 @@ function parseArgs(argv) {
87
95
  if (a === '-h' || a === '--help') opts.help = true;
88
96
  else if (a === '--no-open') opts.open = false;
89
97
  else if (a === '--ui') opts.ui = true;
98
+ else if (a === '--agent') opts.agent = true;
90
99
  else if (a === '--host') opts.host = argv[++i] || opts.host;
91
100
  else if (a === '--base') opts.base = argv[++i] || opts.base;
92
101
  else if (a === '--branch') opts.branch = argv[++i] || opts.branch;
@@ -136,6 +145,18 @@ function canListen(host, port) {
136
145
  });
137
146
  }
138
147
 
148
+ const PORT_SCAN = 50;
149
+
150
+ /** Primer puerto libre desde `start`, o `null` si ninguno de los siguientes cabe. */
151
+ async function findFreePort(host, start) {
152
+ const first = Math.max(1, Math.floor(start));
153
+ const last = Math.min(65535, first + PORT_SCAN - 1);
154
+ for (let port = first; port <= last; port++) {
155
+ if (await canListen(host, port)) return port;
156
+ }
157
+ return null;
158
+ }
159
+
139
160
  /**
140
161
  * @returns {{
141
162
  * repo: string,
@@ -170,6 +191,11 @@ function branchOptions(listed) {
170
191
  }));
171
192
  }
172
193
 
194
+ /** Selects solo con terminal interactiva. (`p.isTTY` de Clack es una función, no un booleano.) */
195
+ function isInteractive() {
196
+ return p.isTTY(process.stdin) && p.isTTY(process.stdout);
197
+ }
198
+
173
199
  function ensureValue(value, label) {
174
200
  if (p.isCancel(value)) {
175
201
  p.cancel('Cancelado.');
@@ -196,7 +222,7 @@ async function resolveBranchBase(launch, opts) {
196
222
  return { branch: flagBranch, base: flagBase, prompted: false };
197
223
  }
198
224
 
199
- if (!p.isTTY) {
225
+ if (!isInteractive()) {
200
226
  const branch = flagBranch || defaultBranch(launch.listed);
201
227
  const base = flagBase || defaultBase(launch.listed, branch);
202
228
  return { branch, base, prompted: false };
@@ -281,6 +307,146 @@ function copyPromptOrWarn(prompt) {
281
307
  return false;
282
308
  }
283
309
 
310
+ /**
311
+ * Modelo del agente: select con TTY; sin TTY, el modelo por defecto (string vacío).
312
+ * @param {import('../lib/agents.mjs').DetectedAgent} agent
313
+ */
314
+ async function pickModel(agent) {
315
+ if (!isInteractive()) return '';
316
+
317
+ const { options, initialValue } = modelChoices(agent.def);
318
+ const choice = await p.select({ message: 'Modelo', options, initialValue });
319
+ if (p.isCancel(choice)) {
320
+ p.cancel('Cancelado.');
321
+ process.exit(0);
322
+ }
323
+ if (choice !== OTHER_MODEL) return /** @type {string} */ (choice);
324
+
325
+ const typed = ensureValue(
326
+ await p.text({
327
+ message: 'Id del modelo',
328
+ validate: (v) => (String(v ?? '').trim() ? undefined : 'Escribí el id del modelo.')
329
+ }),
330
+ 'modelo'
331
+ );
332
+ return typed.trim();
333
+ }
334
+
335
+ /**
336
+ * Agente y modelo: selects con TTY; sin TTY, el primer agente detectado con su modelo por defecto.
337
+ * Si `preset` ya viene elegido, solo pregunta el modelo.
338
+ * @param {import('../lib/agents.mjs').DetectedAgent[]} detected
339
+ * @param {import('../lib/agents.mjs').DetectedAgent} [preset]
340
+ */
341
+ async function pickAgentAndModel(detected, preset) {
342
+ if (!isInteractive()) return { agent: preset ?? detected[0], model: '' };
343
+
344
+ let agent = preset;
345
+ if (!agent) {
346
+ const id = ensureValue(
347
+ await p.select({
348
+ message: 'Agente',
349
+ options: detected.map((a) => ({ value: a.def.id, label: a.def.label, hint: a.path })),
350
+ initialValue: detected[0].def.id
351
+ }),
352
+ 'agente'
353
+ );
354
+ agent = detected.find((a) => a.def.id === id) ?? detected[0];
355
+ }
356
+
357
+ return { agent, model: await pickModel(agent) };
358
+ }
359
+
360
+ /**
361
+ * Portapapeles primero y después cada agente detectado.
362
+ * @param {import('../lib/agents.mjs').DetectedAgent[]} detected
363
+ * @returns {Promise<import('../lib/agents.mjs').DetectedAgent | null>} `null` = portapapeles
364
+ */
365
+ async function pickDelivery(detected) {
366
+ const choice = ensureValue(
367
+ await p.select({
368
+ message: '¿Cómo generás el review?',
369
+ options: [
370
+ { value: 'clipboard', label: 'Copiar el prompt al portapapeles', hint: 'lo pegás en tu agente' },
371
+ ...detected.map((a) => ({ value: a.def.id, label: a.def.label, hint: a.path }))
372
+ ],
373
+ initialValue: 'clipboard'
374
+ }),
375
+ 'modo'
376
+ );
377
+ if (choice === 'clipboard') return null;
378
+ return detected.find((a) => a.def.id === choice) ?? detected[0];
379
+ }
380
+
381
+ /**
382
+ * Ya hay un review válido para estos commits: pregunta si abrirlo o generarlo de nuevo.
383
+ * @param {{ path: string }} existing
384
+ * @returns {Promise<{ path: string } | null>} el existente, o `null` para generar de nuevo
385
+ */
386
+ async function reuseExistingReview(existing, outputFilename) {
387
+ const choice = ensureValue(
388
+ await p.select({
389
+ message: `Ya hay un review válido para estos commits (${outputFilename})`,
390
+ options: [
391
+ { value: 'open', label: 'Abrir el existente' },
392
+ { value: 'regenerate', label: 'Generar de nuevo', hint: 'el agente lo reemplaza' }
393
+ ],
394
+ initialValue: 'open'
395
+ }),
396
+ 'review'
397
+ );
398
+ if (choice !== 'open') return null;
399
+ p.outro('Abro el review existente.');
400
+ return existing;
401
+ }
402
+
403
+ /**
404
+ * Lanza el agente y espera el JSON. Devuelve el reporte válido; si el agente termina sin dejarlo, sale con error.
405
+ * @param {import('../lib/agents.mjs').DetectedAgent} agent
406
+ * @param {{ repo: string, prompt: string, model: string, outputFilename: string, useClack: boolean }} run
407
+ */
408
+ async function runAgentAndWait(agent, { repo, prompt, model, outputFilename, useClack }) {
409
+ const what = `${agent.def.label} (${model || 'modelo por defecto'})`;
410
+ const msg = `Lanzando ${what}. Espera ${outputFilename} en la raíz del repo; Ctrl+C cancela.`;
411
+ if (useClack) p.outro(msg);
412
+ else console.log(msg);
413
+
414
+ // Solo cuenta lo que escriba este agente: un JSON anterior de los mismos commits no es el resultado.
415
+ const since = Date.now();
416
+ const { exited } = runAgent(agent, { cwd: repo, prompt, model, outputFilename });
417
+ const waiting = waitForReviewOutput(repo, outputFilename, {
418
+ since,
419
+ onInvalid: (errors) => {
420
+ console.log(`JSON encontrado pero inválido (${errors.length} error(es)). El agente puede corregirlo…`);
421
+ for (const e of errors.slice(0, 5)) console.log(` · ${e}`);
422
+ }
423
+ });
424
+
425
+ let code;
426
+ try {
427
+ code = await exited;
428
+ } catch (err) {
429
+ console.error(`No se pudo lanzar ${agent.path}: ${err instanceof Error ? err.message : err}`);
430
+ process.exit(1);
431
+ }
432
+ // El watcher puede ir un tick atrás de la última escritura del agente.
433
+ const result = await Promise.race([waiting, new Promise((r) => setTimeout(() => r(null), 2000))]);
434
+ if (!result) {
435
+ console.error(
436
+ code === 0
437
+ ? `${agent.def.label} terminó sin dejar un ${outputFilename} válido.`
438
+ : `${agent.def.label} terminó con código ${code} sin dejar un ${outputFilename} válido.`
439
+ );
440
+ process.exit(1);
441
+ }
442
+ if (code !== 0) console.warn(`${agent.def.label} terminó con código ${code}, pero el JSON es válido.`);
443
+ stampReviewProvenance(result.path, {
444
+ agent: agent.def.label,
445
+ model: resolvedRunModel(agent.def, model)
446
+ });
447
+ return /** @type {{ path: string }} */ (result);
448
+ }
449
+
284
450
  function explainWaitingForAgent(outputFilename, { copied, useClack }) {
285
451
  const copyMsg = copied
286
452
  ? 'Se copió un prompt al portapapeles: pegalo en el chat de tu agente de IA para generar el contenido de la review.'
@@ -309,12 +475,17 @@ O reinstalá el paquete (el tarball de npm incluye el build).`);
309
475
  process.exit(1);
310
476
  }
311
477
 
312
- if (!(await canListen(cfg.host, cfg.port))) {
313
- console.error(`No se pudo usar ${cfg.host}:${cfg.port} (¿ocupado?). Probá --port otro.`);
478
+ const port = await findFreePort(cfg.host, cfg.port);
479
+ if (port == null) {
480
+ const last = Math.min(65535, cfg.port + PORT_SCAN - 1);
481
+ console.error(`No encontré un puerto libre entre ${cfg.port} y ${last} en ${cfg.host}.`);
314
482
  process.exit(1);
315
483
  }
484
+ if (port !== cfg.port) {
485
+ console.log(`Puerto ${cfg.port} ocupado; la UI usa ${port}.`);
486
+ }
316
487
 
317
- const origin = `http://${cfg.host}:${cfg.port}`;
488
+ const origin = `http://${cfg.host}:${port}`;
318
489
  const openUrl = cfg.openPath ? `${origin}${cfg.openPath}` : origin;
319
490
  const nodeBin = resolveNodeBin();
320
491
  const child = spawn(nodeBin, [entry], {
@@ -322,7 +493,7 @@ O reinstalá el paquete (el tarball de npm incluye el build).`);
322
493
  env: {
323
494
  ...process.env,
324
495
  HOST: cfg.host,
325
- PORT: String(cfg.port),
496
+ PORT: String(port),
326
497
  ORIGIN: origin,
327
498
  DIFF_REVIEW_LAUNCH_CWD: cfg.launchCwd,
328
499
  ...(cfg.importFile ? { DIFF_REVIEW_IMPORT_FILE: cfg.importFile } : {})
@@ -401,7 +572,9 @@ async function runUiMode(opts, launch, launchCwd) {
401
572
  port: opts.port,
402
573
  open: opts.open,
403
574
  launchCwd,
404
- openPath: launch ? `/?notify=prompt&out=${encodeURIComponent(outputFilename)}` : '/'
575
+ openPath: launch
576
+ ? `/?watch=1&notify=prompt&out=${encodeURIComponent(outputFilename)}`
577
+ : '/'
405
578
  });
406
579
  }
407
580
 
@@ -455,7 +628,7 @@ async function main() {
455
628
  console.log(`Repo: ${launch.repo}`);
456
629
  console.log(`Branch: ${branch} (base: ${base})`);
457
630
  } else {
458
- p.note(`${branch} ← ${base}\n${outputFilename}`, 'Review');
631
+ p.note(`${branch} → ${base}\n${outputFilename}`, 'Review');
459
632
  }
460
633
 
461
634
  const prompt = buildPrompt(
@@ -469,15 +642,51 @@ async function main() {
469
642
  { outputFilename }
470
643
  );
471
644
 
472
- const copied = copyPromptOrWarn(prompt);
473
- explainWaitingForAgent(outputFilename, { copied, useClack: prompted });
645
+ // Sin --agent también se detectan (con terminal interactiva) para ofrecer lanzar uno en vez del portapapeles.
646
+ const detected = opts.agent || isInteractive() ? detectAgents() : [];
647
+ if (opts.agent && !detected.length) {
648
+ const names = AGENTS.map((a) => a.bins[0]).join(', ');
649
+ const warn = `No encontré agentes instalados (${names}). Sigo copiando el prompt.`;
650
+ if (prompted) p.log.warn(warn);
651
+ else console.warn(warn);
652
+ }
474
653
 
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
- });
654
+ // Hay selects (agente/modelo, o abrir vs. regenerar un review existente) solo con terminal interactiva.
655
+ const existing = isInteractive() ? existingReviewOutput(launch.repo, outputFilename) : null;
656
+ const useClack = prompted || (isInteractive() && (detected.length > 0 || existing != null));
657
+ // Con flags de branch/base no hubo intro, pero estos selects abren sesión de Clack igual.
658
+ if (useClack && !prompted) p.intro('diff-review');
659
+
660
+ let result = existing ? await reuseExistingReview(existing, outputFilename) : null;
661
+ /** @type {import('../lib/agents.mjs').DetectedAgent | null} */
662
+ let chosen = null;
663
+ if (!result && detected.length > 0 && !opts.agent && isInteractive()) {
664
+ chosen = await pickDelivery(detected);
665
+ }
666
+ const useAgent = !result && detected.length > 0 && (opts.agent || chosen != null);
667
+ if (useAgent) {
668
+ const { agent, model } = await pickAgentAndModel(detected, chosen ?? undefined);
669
+ result = await runAgentAndWait(agent, {
670
+ repo: launch.repo,
671
+ prompt,
672
+ model,
673
+ outputFilename,
674
+ useClack
675
+ });
676
+ } else if (!result) {
677
+ // Si eligió generar de nuevo, el JSON existente no cuenta hasta que el agente lo reescriba.
678
+ const since = existing ? Date.now() : undefined;
679
+ const copied = copyPromptOrWarn(prompt);
680
+ explainWaitingForAgent(outputFilename, { copied, useClack });
681
+
682
+ result = await waitForReviewOutput(launch.repo, outputFilename, {
683
+ since,
684
+ onInvalid: (errors) => {
685
+ console.log(`JSON encontrado pero inválido (${errors.length} error(es)). Esperando corrección…`);
686
+ for (const e of errors.slice(0, 5)) console.log(` · ${e}`);
687
+ }
688
+ });
689
+ }
481
690
 
482
691
  console.log('JSON OK. Abriendo la UI web…');
483
692
  await startServer({