@jpkme/diff-review 0.1.7 → 0.1.12

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 (158) hide show
  1. package/README.md +38 -15
  2. package/bin/diff-review.mjs +156 -34
  3. package/lib/acp-policy.mjs +136 -0
  4. package/lib/acp-policy.test.mjs +83 -0
  5. package/lib/acp-shell.mjs +379 -0
  6. package/lib/acp-shell.test.mjs +127 -0
  7. package/lib/acp.mjs +368 -0
  8. package/lib/acp.test.mjs +58 -0
  9. package/lib/agent-login.mjs +105 -0
  10. package/lib/agents.mjs +102 -14
  11. package/lib/agents.test.mjs +4 -6
  12. package/lib/check-report.mjs +31 -12
  13. package/lib/check-report.test.mjs +1 -0
  14. package/lib/extract-cache.test.mjs +162 -0
  15. package/lib/extract.mjs +197 -1
  16. package/lib/model-label.mjs +263 -0
  17. package/lib/model-label.test.mjs +77 -0
  18. package/lib/models-cache.mjs +75 -0
  19. package/lib/models-cache.test.mjs +50 -0
  20. package/lib/output-name.mjs +8 -0
  21. package/lib/prompt.mjs +178 -31
  22. package/lib/review-dir.mjs +108 -0
  23. package/lib/review-meta.mjs +35 -0
  24. package/lib/review-meta.test.mjs +41 -0
  25. package/lib/review-prep.mjs +63 -0
  26. package/lib/review.test.mjs +1 -0
  27. package/lib/validate.mjs +28 -11
  28. package/lib/validate.test.mjs +25 -0
  29. package/lib/wait-output.mjs +46 -23
  30. package/lib/wait-output.test.mjs +25 -1
  31. package/package.json +3 -1
  32. package/schema.json +9 -10
  33. package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css +1 -0
  34. package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css.br +0 -0
  35. package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css.gz +0 -0
  36. package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css +1 -0
  37. package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css.br +0 -0
  38. package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css.gz +0 -0
  39. package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css +1 -0
  40. package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css.br +0 -0
  41. package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css.gz +0 -0
  42. package/web/build/client/_app/immutable/chunks/B5ByWcPE.js +2 -0
  43. package/web/build/client/_app/immutable/chunks/B5ByWcPE.js.br +0 -0
  44. package/web/build/client/_app/immutable/chunks/B5ByWcPE.js.gz +0 -0
  45. package/web/build/client/_app/immutable/chunks/CPyWH418.js +1 -0
  46. package/web/build/client/_app/immutable/chunks/CPyWH418.js.br +0 -0
  47. package/web/build/client/_app/immutable/chunks/CPyWH418.js.gz +0 -0
  48. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js +3 -0
  49. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js.br +0 -0
  50. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js.gz +0 -0
  51. package/web/build/client/_app/immutable/chunks/NwkMsx0h.js +210 -0
  52. package/web/build/client/_app/immutable/chunks/NwkMsx0h.js.br +0 -0
  53. package/web/build/client/_app/immutable/chunks/NwkMsx0h.js.gz +0 -0
  54. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js +1 -0
  55. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js.br +0 -0
  56. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js.gz +0 -0
  57. package/web/build/client/_app/immutable/entry/app.BiBohpyO.js +2 -0
  58. package/web/build/client/_app/immutable/entry/app.BiBohpyO.js.br +0 -0
  59. package/web/build/client/_app/immutable/entry/app.BiBohpyO.js.gz +0 -0
  60. package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js +1 -0
  61. package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js.br +0 -0
  62. package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js.gz +0 -0
  63. package/web/build/client/_app/immutable/nodes/0.Cak4ehiE.js +1 -0
  64. package/web/build/client/_app/immutable/nodes/0.Cak4ehiE.js.br +0 -0
  65. package/web/build/client/_app/immutable/nodes/0.Cak4ehiE.js.gz +0 -0
  66. package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js +1 -0
  67. package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js.br +2 -0
  68. package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js.gz +0 -0
  69. package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js +10 -0
  70. package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js.br +0 -0
  71. package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js.gz +0 -0
  72. package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js +171 -0
  73. package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js.br +0 -0
  74. package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js.gz +0 -0
  75. package/web/build/client/_app/version.json +1 -1
  76. package/web/build/client/_app/version.json.br +0 -0
  77. package/web/build/client/_app/version.json.gz +0 -0
  78. package/web/build/handler.js +3 -3
  79. package/web/build/index.js +3 -3
  80. package/web/build/server/chunks/{0-SseDy__O.js → 0-BYok4D0n.js} +2 -2
  81. package/web/build/server/chunks/{0-SseDy__O.js.map → 0-BYok4D0n.js.map} +1 -1
  82. package/web/build/server/chunks/1-C7zcB0q0.js +7 -0
  83. package/web/build/server/chunks/1-C7zcB0q0.js.map +1 -0
  84. package/web/build/server/chunks/2-v6ejI_0-.js +7 -0
  85. package/web/build/server/chunks/2-v6ejI_0-.js.map +1 -0
  86. package/web/build/server/chunks/3-DAAmG6VX.js +7 -0
  87. package/web/build/server/chunks/3-DAAmG6VX.js.map +1 -0
  88. package/web/build/server/chunks/_server.ts-qYVrMYPB.js +62 -0
  89. package/web/build/server/chunks/_server.ts-qYVrMYPB.js.map +1 -0
  90. package/web/build/server/chunks/agent-sessions-DSqGavSn.js +2688 -0
  91. package/web/build/server/chunks/agent-sessions-DSqGavSn.js.map +1 -0
  92. package/web/build/server/chunks/{remote-1g4ayag-B8KZPTkn.js → git-review-CpO_G8z8.js} +70 -704
  93. package/web/build/server/chunks/git-review-CpO_G8z8.js.map +1 -0
  94. package/web/build/server/chunks/{handler-cq3QoEzW.js → handler-Di1wJQuU.js} +3 -3
  95. package/web/build/server/chunks/{handler-cq3QoEzW.js.map → handler-Di1wJQuU.js.map} +1 -1
  96. package/web/build/server/chunks/hooks.server-BLOHobEK.js +42 -0
  97. package/web/build/server/chunks/hooks.server-BLOHobEK.js.map +1 -0
  98. package/web/build/server/chunks/{index.js-D4qkHe3r.js → index.js-yLYmrAWy.js} +3 -5
  99. package/web/build/server/chunks/index.js-yLYmrAWy.js.map +1 -0
  100. package/web/build/server/chunks/manifest.js-CXj8IIo3.js +58 -0
  101. package/web/build/server/chunks/manifest.js-CXj8IIo3.js.map +1 -0
  102. package/web/build/server/chunks/remote-1g4ayag-BiJeU0Pk.js +144 -0
  103. package/web/build/server/chunks/remote-1g4ayag-BiJeU0Pk.js.map +1 -0
  104. package/web/build/server/chunks/remote-wh6t6j-CHanXurm.js +162 -0
  105. package/web/build/server/chunks/remote-wh6t6j-CHanXurm.js.map +1 -0
  106. package/web/build/server/chunks/validate-BsTKyhsq.js +1073 -0
  107. package/web/build/server/chunks/validate-BsTKyhsq.js.map +1 -0
  108. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css +0 -1
  109. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css.br +0 -0
  110. package/web/build/client/_app/immutable/assets/2.COsJT9ly.css.gz +0 -0
  111. package/web/build/client/_app/immutable/assets/3.qhPDz1xQ.css +0 -1
  112. package/web/build/client/_app/immutable/assets/3.qhPDz1xQ.css.br +0 -0
  113. package/web/build/client/_app/immutable/assets/3.qhPDz1xQ.css.gz +0 -0
  114. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css +0 -1
  115. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css.br +0 -0
  116. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css.gz +0 -0
  117. package/web/build/client/_app/immutable/chunks/C4--ziCr.js +0 -2
  118. package/web/build/client/_app/immutable/chunks/C4--ziCr.js.br +0 -0
  119. package/web/build/client/_app/immutable/chunks/C4--ziCr.js.gz +0 -0
  120. package/web/build/client/_app/immutable/chunks/D8sAxZVo.js +0 -174
  121. package/web/build/client/_app/immutable/chunks/D8sAxZVo.js.br +0 -0
  122. package/web/build/client/_app/immutable/chunks/D8sAxZVo.js.gz +0 -0
  123. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js +0 -3
  124. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js.br +0 -0
  125. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js.gz +0 -0
  126. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js +0 -1
  127. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js.br +0 -0
  128. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js.gz +0 -0
  129. package/web/build/client/_app/immutable/entry/app.Dl9OTE-n.js +0 -2
  130. package/web/build/client/_app/immutable/entry/app.Dl9OTE-n.js.br +0 -0
  131. package/web/build/client/_app/immutable/entry/app.Dl9OTE-n.js.gz +0 -0
  132. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js +0 -1
  133. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js.br +0 -1
  134. package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js.gz +0 -0
  135. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js +0 -1
  136. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js.br +0 -1
  137. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js.gz +0 -0
  138. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js +0 -1
  139. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js.br +0 -0
  140. package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js.gz +0 -0
  141. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js +0 -66
  142. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js.br +0 -0
  143. package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js.gz +0 -0
  144. package/web/build/client/_app/immutable/nodes/3.C-USexyX.js +0 -170
  145. package/web/build/client/_app/immutable/nodes/3.C-USexyX.js.br +0 -0
  146. package/web/build/client/_app/immutable/nodes/3.C-USexyX.js.gz +0 -0
  147. package/web/build/server/chunks/1-0X6Jp9nH.js +0 -7
  148. package/web/build/server/chunks/1-0X6Jp9nH.js.map +0 -1
  149. package/web/build/server/chunks/2-HlcVALg6.js +0 -7
  150. package/web/build/server/chunks/2-HlcVALg6.js.map +0 -1
  151. package/web/build/server/chunks/3-BN1eS7L2.js +0 -7
  152. package/web/build/server/chunks/3-BN1eS7L2.js.map +0 -1
  153. package/web/build/server/chunks/hooks.server-DERFlOe1.js +0 -14
  154. package/web/build/server/chunks/hooks.server-DERFlOe1.js.map +0 -1
  155. package/web/build/server/chunks/index.js-D4qkHe3r.js.map +0 -1
  156. package/web/build/server/chunks/manifest.js-DQHMKyJh.js +0 -50
  157. package/web/build/server/chunks/manifest.js-DQHMKyJh.js.map +0 -1
  158. package/web/build/server/chunks/remote-1g4ayag-B8KZPTkn.js.map +0 -1
package/lib/acp.mjs ADDED
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Sesión con un agente por ACP (Agent Client Protocol): JSON-RPC por stdin/stdout del proceso del agente.
3
+ *
4
+ * A diferencia de `runAgent` (una vuelta headless), el proceso queda vivo: la misma sesión sirve para generar la
5
+ * review y después para las preguntas y los cambios que se le pidan desde la web. Cada pedido de permiso pasa por
6
+ * `decide` (ver `acp-policy.mjs`): no hay nadie mirando una terminal para aprobar.
7
+ */
8
+ import { Readable, Writable } from "node:stream";
9
+ import * as acp from "@agentclientprotocol/sdk";
10
+ import { spawnCommand } from "./agents.mjs";
11
+ import { permissionOutcome } from "./acp-policy.mjs";
12
+ import { looksLikeAuthFailure } from "./agent-login.mjs";
13
+ import { readModelsCache, writeModelsCache } from "./models-cache.mjs";
14
+
15
+ /**
16
+ * @typedef {import('./agents.mjs').DetectedAgent} DetectedAgent
17
+ * @typedef {import('@agentclientprotocol/sdk').SessionUpdate} SessionUpdate
18
+ * @typedef {import('@agentclientprotocol/sdk').RequestPermissionRequest} PermissionRequest
19
+ * @typedef {{ allow: boolean, reason: string }} Decision
20
+ * @typedef {{ value: string, name: string }} ModelOption
21
+ * @typedef {{
22
+ * cwd: string,
23
+ * model?: string,
24
+ * resumeSessionId?: string,
25
+ * decide: (toolCall: PermissionRequest['toolCall']) => Decision,
26
+ * onUpdate?: (update: SessionUpdate) => void,
27
+ * onPermission?: (toolCall: PermissionRequest['toolCall'], decision: Decision) => void,
28
+ * // Si la política niega: pedir confirmación al usuario.
29
+ * // `true` / `{ allow: true }` = allow_once; `{ allow: true, always: true }` = allow_always (si el agente lo ofrece).
30
+ * onDeniedPermission?: (
31
+ * toolCall: PermissionRequest['toolCall'],
32
+ * decision: Decision,
33
+ * ) => Promise<boolean | { allow: boolean, always?: boolean }>,
34
+ * onExit?: (info: { code: number | null, stderr: string }) => void,
35
+ * timeoutMs?: number,
36
+ * }} AcpSessionOptions
37
+ * @typedef {{
38
+ * sessionId: string,
39
+ * resumed: boolean,
40
+ * models: ModelOption[],
41
+ * currentModel: string,
42
+ * modelWarning?: string,
43
+ * setModel: (model: string) => Promise<{ currentModel: string, warning?: string }>,
44
+ * prompt: (text: string) => Promise<{ stopReason: string }>,
45
+ * cancel: () => Promise<void>,
46
+ * close: () => void,
47
+ * closed: Promise<void>,
48
+ * stderrTail: () => string,
49
+ * }} AcpSession
50
+ */
51
+
52
+ /** Cuántos caracteres de stderr del agente se guardan para explicar un fallo. */
53
+ const STDERR_TAIL = 4000;
54
+
55
+ /** El agente tiene un modo ACP declarado (no todos los agentes lo traen). */
56
+ export function supportsAcp(agent) {
57
+ return typeof agent?.def?.acp === "function";
58
+ }
59
+
60
+ /**
61
+ * Opción de modelo (`configOptions` con id/categoría "model") de la respuesta de `session/new` o `session/load`.
62
+ * @param {{ configOptions?: any[] | null }} response
63
+ */
64
+ export function modelConfig(response) {
65
+ const option = (response?.configOptions ?? []).find((o) => o?.category === "model" || o?.id === "model");
66
+ if (!option || option.type !== "select") return null;
67
+ const flat = (option.options ?? []).flatMap((o) => (Array.isArray(o?.options) ? o.options : [o]));
68
+ return {
69
+ configId: String(option.id),
70
+ current: String(option.currentValue ?? ""),
71
+ options: flat
72
+ .filter((o, i) => o && typeof o.value === "string" && flat.findIndex((x) => x?.value === o.value) === i)
73
+ .map((o) => ({ value: o.value, name: String(o.name ?? o.value) })),
74
+ };
75
+ }
76
+
77
+ /** Tiempo máximo para initialize + session/new|load (+ set model). */
78
+ const ACP_START_TIMEOUT_MS = 90_000;
79
+
80
+ /**
81
+ * Lanza el agente en modo ACP, negocia y abre (o retoma) una sesión.
82
+ * @param {DetectedAgent} agent
83
+ * @param {AcpSessionOptions} opts
84
+ * @returns {Promise<AcpSession>}
85
+ */
86
+ export async function startAcpSession(agent, opts) {
87
+ if (!supportsAcp(agent)) throw new Error(`${agent.def.label} no tiene modo ACP.`);
88
+ const { file, args, env, sessionMeta } = agent.def.acp({ path: agent.path });
89
+ const child = spawnCommand(file, args, {
90
+ cwd: opts.cwd,
91
+ stdio: ["pipe", "pipe", "pipe"],
92
+ ...(env ? { env: { ...process.env, ...env } } : {}),
93
+ });
94
+
95
+ let stderr = "";
96
+ child.stderr?.on("data", (chunk) => {
97
+ stderr = (stderr + String(chunk)).slice(-STDERR_TAIL);
98
+ });
99
+ child.stdin?.on("error", () => {});
100
+
101
+ const conn = acp
102
+ .client({ name: "diff-review" })
103
+ .onRequest(acp.methods.client.session.requestPermission, async (ctx) => {
104
+ const toolCall = ctx.params.toolCall;
105
+ const decision = safeDecide(opts.decide, toolCall);
106
+ if (decision.allow) {
107
+ opts.onPermission?.(toolCall, decision);
108
+ return permissionOutcome(ctx.params.options, true);
109
+ }
110
+ // Política niega: no rechazar al toque — la UI puede permitir una vez / sesión / siempre.
111
+ let allow = false;
112
+ let always = false;
113
+ if (opts.onDeniedPermission) {
114
+ try {
115
+ const answer = await opts.onDeniedPermission(toolCall, decision);
116
+ if (answer && typeof answer === "object") {
117
+ allow = Boolean(answer.allow);
118
+ always = Boolean(answer.always);
119
+ } else {
120
+ allow = Boolean(answer);
121
+ }
122
+ } catch {
123
+ allow = false;
124
+ always = false;
125
+ }
126
+ }
127
+ const final = {
128
+ allow,
129
+ reason: allow ? (always ? "permitido siempre" : "permitido por el usuario") : decision.reason,
130
+ };
131
+ opts.onPermission?.(toolCall, final);
132
+ return permissionOutcome(ctx.params.options, allow, { always });
133
+ })
134
+ .onNotification(acp.methods.client.session.update, (ctx) => {
135
+ opts.onUpdate?.(ctx.params.update);
136
+ })
137
+ .connect(acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout)));
138
+
139
+ const exited = new Promise((resolve) => {
140
+ child.once("error", (err) => {
141
+ stderr += `\n${err.message}`;
142
+ resolve(null);
143
+ });
144
+ child.once("exit", (code) => resolve(code));
145
+ });
146
+ exited.then((code) => {
147
+ conn.close(new Error(`${agent.def.label} terminó (código ${code ?? "?"}).`));
148
+ opts.onExit?.({ code, stderr });
149
+ });
150
+
151
+ const close = () => {
152
+ conn.close();
153
+ if (child.exitCode == null) child.kill();
154
+ };
155
+
156
+ const timeoutMs = Math.max(15_000, Number(opts.timeoutMs) || ACP_START_TIMEOUT_MS);
157
+ let timedOut = false;
158
+ const timer = setTimeout(() => {
159
+ timedOut = true;
160
+ close();
161
+ }, timeoutMs);
162
+
163
+ try {
164
+ const init = await conn.agent.request(acp.methods.agent.initialize, {
165
+ protocolVersion: acp.PROTOCOL_VERSION,
166
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
167
+ clientInfo: { name: "diff-review", version: "0" },
168
+ });
169
+
170
+ const authMethods = Array.isArray(init.authMethods) ? init.authMethods : [];
171
+ /** Métodos donde el agente maneja el login por `authenticate` (no terminal interactiva). */
172
+ const agentAuthMethods = authMethods.filter((m) => m && (!m.type || m.type === "agent") && m.id);
173
+
174
+ /** Si session/new falla por auth, pedimos `authenticate` una vez y reintentamos. */
175
+ const tryAuthenticate = async () => {
176
+ for (const method of agentAuthMethods) {
177
+ try {
178
+ await conn.agent.request(acp.methods.agent.authenticate, { methodId: method.id });
179
+ return true;
180
+ } catch {
181
+ /* siguiente método */
182
+ }
183
+ }
184
+ return false;
185
+ };
186
+
187
+ const openNewSession = async () => {
188
+ try {
189
+ return await conn.agent.request(acp.methods.agent.session.new, {
190
+ cwd: opts.cwd,
191
+ mcpServers: [],
192
+ ...(sessionMeta ? { _meta: sessionMeta } : {}),
193
+ });
194
+ } catch (err) {
195
+ const msg = err instanceof Error ? err.message : String(err);
196
+ if (agentAuthMethods.length && looksLikeAuthFailure(msg) && (await tryAuthenticate())) {
197
+ return await conn.agent.request(acp.methods.agent.session.new, {
198
+ cwd: opts.cwd,
199
+ mcpServers: [],
200
+ ...(sessionMeta ? { _meta: sessionMeta } : {}),
201
+ });
202
+ }
203
+ throw err;
204
+ }
205
+ };
206
+
207
+ let sessionId = "";
208
+ let response;
209
+ let resumed = false;
210
+ if (opts.resumeSessionId && init.agentCapabilities?.loadSession) {
211
+ try {
212
+ response = await conn.agent.request(acp.methods.agent.session.load, {
213
+ sessionId: opts.resumeSessionId,
214
+ cwd: opts.cwd,
215
+ mcpServers: [],
216
+ ...(sessionMeta ? { _meta: sessionMeta } : {}),
217
+ });
218
+ sessionId = opts.resumeSessionId;
219
+ resumed = true;
220
+ } catch {
221
+ /* la sesión ya no existe del lado del agente: se abre una nueva */
222
+ }
223
+ }
224
+ if (!resumed) {
225
+ response = await openNewSession();
226
+ sessionId = response.sessionId;
227
+ }
228
+
229
+ const models = modelConfig(response);
230
+ let currentModel = models?.current ?? "";
231
+
232
+ /**
233
+ * @param {string} model
234
+ * @returns {Promise<{ currentModel: string, warning?: string }>}
235
+ */
236
+ const setModel = async (model) => {
237
+ const wanted = String(model || "").trim();
238
+ if (!wanted || wanted === currentModel) return { currentModel };
239
+ if (!models) {
240
+ return { currentModel, warning: `El agente no deja elegir el modelo por ACP; usa el suyo.` };
241
+ }
242
+ if (!models.options.some((o) => o.value === wanted)) {
243
+ return {
244
+ currentModel,
245
+ warning: `El agente no ofrece el modelo "${wanted}"; sigue con "${currentModel}".`,
246
+ };
247
+ }
248
+ await conn.agent.request(acp.methods.agent.session.setConfigOption, {
249
+ sessionId,
250
+ configId: models.configId,
251
+ value: wanted,
252
+ });
253
+ currentModel = wanted;
254
+ return { currentModel };
255
+ };
256
+
257
+ let modelWarning;
258
+ const initialWanted = String(opts.model || "").trim();
259
+ if (initialWanted) {
260
+ const applied = await setModel(initialWanted);
261
+ if (applied.warning) modelWarning = applied.warning;
262
+ }
263
+
264
+ clearTimeout(timer);
265
+ return {
266
+ sessionId,
267
+ resumed,
268
+ models: models?.options ?? [],
269
+ get currentModel() {
270
+ return currentModel;
271
+ },
272
+ ...(modelWarning ? { modelWarning } : {}),
273
+ setModel,
274
+ prompt: async (text) => {
275
+ const res = await conn.agent.request(acp.methods.agent.session.prompt, {
276
+ sessionId,
277
+ prompt: [{ type: "text", text }],
278
+ });
279
+ return { stopReason: String(res.stopReason) };
280
+ },
281
+ cancel: () => conn.agent.notify(acp.methods.agent.session.cancel, { sessionId }),
282
+ close,
283
+ closed: conn.closed,
284
+ stderrTail: () => stderr,
285
+ };
286
+ } catch (err) {
287
+ clearTimeout(timer);
288
+ close();
289
+ const tail = stderr.trim().split(/\r?\n/).slice(-8).join("\n");
290
+ if (timedOut) {
291
+ const msg = `Timeout (${Math.round(timeoutMs / 1000)}s) al lanzar ${agent.def.label}. ¿Está autenticado el CLI?`;
292
+ throw new Error(tail ? `${msg}\n${tail}` : msg);
293
+ }
294
+ const reason = err instanceof Error ? err.message : String(err);
295
+ throw new Error(tail ? `${reason}\n${tail}` : reason);
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Modelos ACP con caché en `~/.diff-review/models-cache.json` (TTL 24h).
301
+ * @param {DetectedAgent} agent
302
+ * @param {{ cwd: string, timeoutMs?: number, force?: boolean }} opts
303
+ * @returns {Promise<{ models: ModelOption[], currentModel: string, fromCache: boolean }>}
304
+ */
305
+ export async function listAcpModelsCached(agent, opts) {
306
+ const force = Boolean(opts?.force);
307
+ if (!force) {
308
+ const hit = readModelsCache(agent.def.id);
309
+ if (hit) {
310
+ return { models: hit.models, currentModel: hit.currentModel, fromCache: true };
311
+ }
312
+ }
313
+ const result = await listAcpModels(agent, opts);
314
+ if (result.models.length) writeModelsCache(agent.def.id, result);
315
+ return { ...result, fromCache: false };
316
+ }
317
+
318
+ /**
319
+ * Modelos que ofrece el agente por ACP (abre una sesión, lee las opciones y la cierra; no manda ningún prompt).
320
+ * @param {DetectedAgent} agent
321
+ * @param {{ cwd: string, timeoutMs?: number }} opts
322
+ * @returns {Promise<{ models: ModelOption[], currentModel: string }>}
323
+ */
324
+ export async function listAcpModels(agent, { cwd, timeoutMs = 45_000 }) {
325
+ const ms = Math.max(5_000, Number(timeoutMs) || 45_000);
326
+ /** @type {AcpSession | null} */
327
+ let session = null;
328
+ const pending = startAcpSession(agent, {
329
+ cwd,
330
+ decide: () => ({ allow: false, reason: "solo se listan modelos" }),
331
+ }).then((s) => {
332
+ session = s;
333
+ return s;
334
+ });
335
+ const timeout = new Promise((_, reject) => {
336
+ setTimeout(() => {
337
+ reject(
338
+ new Error(
339
+ `Timeout (${Math.round(ms / 1000)}s) al consultar modelos de ${agent.def.label}. Reintentá en unos segundos.`
340
+ )
341
+ );
342
+ }, ms);
343
+ });
344
+ try {
345
+ const opened = await Promise.race([pending, timeout]);
346
+ const out = { models: opened.models, currentModel: opened.currentModel };
347
+ opened.close();
348
+ session = null;
349
+ return out;
350
+ } catch (err) {
351
+ pending.then((s) => s.close()).catch(() => {});
352
+ try {
353
+ session?.close();
354
+ } catch {
355
+ /* ya cerrada */
356
+ }
357
+ throw err;
358
+ }
359
+ }
360
+
361
+ /** Un error en la política no puede colgar al agente esperando respuesta: se rechaza. */
362
+ function safeDecide(decide, toolCall) {
363
+ try {
364
+ return decide(toolCall);
365
+ } catch (err) {
366
+ return { allow: false, reason: `error en la política: ${err instanceof Error ? err.message : err}` };
367
+ }
368
+ }
@@ -0,0 +1,58 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { AGENTS } from "./agents.mjs";
4
+ import { modelConfig, supportsAcp } from "./acp.mjs";
5
+
6
+ // Lo que devuelve claude-agent-acp en `session/new` (recortado).
7
+ const claudeSession = {
8
+ sessionId: "s-1",
9
+ configOptions: [
10
+ { id: "mode", name: "Mode", category: "mode", type: "select", currentValue: "default", options: [{ value: "default", name: "Default" }] },
11
+ {
12
+ id: "model",
13
+ name: "Model",
14
+ category: "model",
15
+ type: "select",
16
+ currentValue: "default",
17
+ options: [
18
+ { value: "default", name: "Default (recommended)" },
19
+ { value: "opus", name: "Opus" },
20
+ { value: "haiku", name: "Haiku" },
21
+ ],
22
+ },
23
+ ],
24
+ };
25
+
26
+ test("modelConfig lee la opción de modelo de session/new", () => {
27
+ assert.deepEqual(modelConfig(claudeSession), {
28
+ configId: "model",
29
+ current: "default",
30
+ options: [
31
+ { value: "default", name: "Default (recommended)" },
32
+ { value: "opus", name: "Opus" },
33
+ { value: "haiku", name: "Haiku" },
34
+ ],
35
+ });
36
+ });
37
+
38
+ test("modelConfig aplana grupos de opciones y tolera agentes sin modelo", () => {
39
+ const grouped = {
40
+ configOptions: [
41
+ { id: "m", category: "model", type: "select", currentValue: "a", options: [{ group: "g", name: "G", options: [{ value: "a", name: "A" }] }] },
42
+ ],
43
+ };
44
+ assert.deepEqual(modelConfig(grouped)?.options, [{ value: "a", name: "A" }]);
45
+ assert.equal(modelConfig({ configOptions: [] }), null);
46
+ assert.equal(modelConfig({}), null);
47
+ });
48
+
49
+ test("supportsAcp: Claude Code tiene modo ACP", () => {
50
+ const claude = AGENTS.find((a) => a.id === "claude");
51
+ assert.equal(supportsAcp({ def: claude, path: "claude" }), true);
52
+ assert.equal(supportsAcp({ def: { ...claude, acp: undefined }, path: "claude" }), false);
53
+ });
54
+
55
+ test("modelConfig no repite modelos con el mismo valor", () => {
56
+ const dup = { configOptions: [{ id: "model", type: "select", currentValue: "auto", options: [{ value: "auto", name: "Auto" }, { value: "auto", name: "Auto" }, { value: "x", name: "X" }] }] };
57
+ assert.deepEqual(modelConfig(dup)?.options.map((o) => o.value), ["auto", "x"]);
58
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Login de agentes ACP: mensajes claros + abrir una terminal interactiva cuando se pueda.
3
+ */
4
+ import { spawn } from "node:child_process";
5
+
6
+ /**
7
+ * @typedef {{ bin: string, args: string[], display: string }} LoginCommand
8
+ */
9
+
10
+ /**
11
+ * Comando de login interactivo por agente (null = no hay CLI de login, p. ej. Cursor app).
12
+ * @param {string} agentId
13
+ * @returns {LoginCommand | null}
14
+ */
15
+ export function loginCommand(agentId) {
16
+ switch (agentId) {
17
+ case "claude":
18
+ return { bin: "claude", args: ["/login"], display: "claude /login" };
19
+ case "copilot":
20
+ return { bin: "gh", args: ["auth", "login"], display: "gh auth login" };
21
+ case "codex":
22
+ return { bin: "codex", args: ["login"], display: "codex login" };
23
+ case "gemini":
24
+ return { bin: "gemini", args: [], display: "gemini" }; // el CLI guía el auth al arrancar
25
+ default:
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /** @param {string} text */
31
+ export function looksLikeAuthFailure(text) {
32
+ const s = String(text || "").toLowerCase();
33
+ return /(not\s+)?auth|login|unauthor|not logged|sign[\s-]?in|credentials|token.*(invalid|expired|missing)|please\s+run/.test(
34
+ s
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Mensaje en español si el agente murió / falló por falta de login.
40
+ * @param {string} agentId
41
+ * @param {string} agentLabel
42
+ * @param {string} detail
43
+ * @returns {string | null}
44
+ */
45
+ export function authFailureMessage(agentId, agentLabel, detail) {
46
+ if (!looksLikeAuthFailure(detail)) return null;
47
+ const cmd = loginCommand(agentId);
48
+ if (agentId === "cursor") {
49
+ return `${agentLabel} no está autenticado. Iniciá sesión en la app de Cursor y reintentá.`;
50
+ }
51
+ if (cmd) {
52
+ return `${agentLabel} no está autenticado. Corré \`${cmd.display}\` en una terminal y reintentá.`;
53
+ }
54
+ return `${agentLabel} no está autenticado. Revisá el login del agente y reintentá.`;
55
+ }
56
+
57
+ /**
58
+ * Abre una terminal nueva con el login del agente (Windows / macOS / Linux).
59
+ * @param {string} agentId
60
+ * @returns {{ display: string }}
61
+ */
62
+ export function openLoginTerminal(agentId) {
63
+ const cmd = loginCommand(agentId);
64
+ if (!cmd) {
65
+ throw new Error(
66
+ agentId === "cursor"
67
+ ? "Cursor se autentica desde la app de escritorio, no desde una terminal."
68
+ : "Este agente no tiene un comando de login conocido."
69
+ );
70
+ }
71
+
72
+ const platform = process.platform;
73
+ if (platform === "win32") {
74
+ // `start "title" cmd /k …` — el primer quoted es el título de la ventana.
75
+ spawn("cmd.exe", ["/c", "start", `${cmd.bin} login`, "cmd.exe", "/k", cmd.bin, ...cmd.args], {
76
+ detached: true,
77
+ stdio: "ignore",
78
+ windowsHide: false,
79
+ }).unref();
80
+ } else if (platform === "darwin") {
81
+ const script = `tell application "Terminal" to do script ${JSON.stringify(`${cmd.bin} ${cmd.args.join(" ")}`.trim())}`;
82
+ spawn("osascript", ["-e", script], { detached: true, stdio: "ignore" }).unref();
83
+ } else {
84
+ const line = `${cmd.bin} ${cmd.args.join(" ")}`.trim();
85
+ const tried = [
86
+ ["x-terminal-emulator", ["-e", "bash", "-lc", `${line}; exec bash`]],
87
+ ["gnome-terminal", ["--", "bash", "-lc", `${line}; exec bash`]],
88
+ ["konsole", ["-e", "bash", "-lc", `${line}; exec bash`]],
89
+ ["xterm", ["-e", "bash", "-lc", `${line}; exec bash`]],
90
+ ];
91
+ let ok = false;
92
+ for (const [bin, args] of tried) {
93
+ try {
94
+ spawn(bin, args, { detached: true, stdio: "ignore" }).unref();
95
+ ok = true;
96
+ break;
97
+ } catch {
98
+ /* siguiente */
99
+ }
100
+ }
101
+ if (!ok) throw new Error(`No pude abrir una terminal. Corré a mano: ${cmd.display}`);
102
+ }
103
+
104
+ return { display: cmd.display };
105
+ }