@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.
- package/README.md +38 -15
- package/bin/diff-review.mjs +156 -34
- package/lib/acp-policy.mjs +136 -0
- package/lib/acp-policy.test.mjs +83 -0
- package/lib/acp-shell.mjs +379 -0
- package/lib/acp-shell.test.mjs +127 -0
- package/lib/acp.mjs +368 -0
- package/lib/acp.test.mjs +58 -0
- package/lib/agent-login.mjs +105 -0
- package/lib/agents.mjs +102 -14
- package/lib/agents.test.mjs +4 -6
- package/lib/check-report.mjs +31 -12
- package/lib/check-report.test.mjs +1 -0
- package/lib/extract-cache.test.mjs +162 -0
- package/lib/extract.mjs +197 -1
- package/lib/model-label.mjs +263 -0
- package/lib/model-label.test.mjs +77 -0
- package/lib/models-cache.mjs +75 -0
- package/lib/models-cache.test.mjs +50 -0
- package/lib/output-name.mjs +8 -0
- package/lib/prompt.mjs +178 -31
- package/lib/review-dir.mjs +108 -0
- package/lib/review-meta.mjs +35 -0
- package/lib/review-meta.test.mjs +41 -0
- package/lib/review-prep.mjs +63 -0
- package/lib/review.test.mjs +1 -0
- package/lib/validate.mjs +28 -11
- package/lib/validate.test.mjs +25 -0
- package/lib/wait-output.mjs +46 -23
- package/lib/wait-output.test.mjs +25 -1
- package/package.json +3 -1
- package/schema.json +9 -10
- package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css +1 -0
- package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css.br +0 -0
- package/web/build/client/_app/immutable/assets/2.CKE12GwZ.css.gz +0 -0
- package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css +1 -0
- package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css.br +0 -0
- package/web/build/client/_app/immutable/assets/3.Dx3c_e7d.css.gz +0 -0
- package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css +1 -0
- package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css.br +0 -0
- package/web/build/client/_app/immutable/assets/storage.gDV-BhtT.css.gz +0 -0
- package/web/build/client/_app/immutable/chunks/B5ByWcPE.js +2 -0
- package/web/build/client/_app/immutable/chunks/B5ByWcPE.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/B5ByWcPE.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/CPyWH418.js +1 -0
- package/web/build/client/_app/immutable/chunks/CPyWH418.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/CPyWH418.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/D8WOD_eS.js +3 -0
- package/web/build/client/_app/immutable/chunks/D8WOD_eS.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/D8WOD_eS.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/NwkMsx0h.js +210 -0
- package/web/build/client/_app/immutable/chunks/NwkMsx0h.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/NwkMsx0h.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js +1 -0
- package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/app.BiBohpyO.js +2 -0
- package/web/build/client/_app/immutable/entry/app.BiBohpyO.js.br +0 -0
- package/web/build/client/_app/immutable/entry/app.BiBohpyO.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js +1 -0
- package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js.br +0 -0
- package/web/build/client/_app/immutable/entry/start.CjWbsiSO.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/0.Cak4ehiE.js +1 -0
- package/web/build/client/_app/immutable/nodes/0.Cak4ehiE.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/0.Cak4ehiE.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js +1 -0
- package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js.br +2 -0
- package/web/build/client/_app/immutable/nodes/1.BzPXaJJj.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js +10 -0
- package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/2.Cp6Ttd_9.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js +171 -0
- package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/3.DLiDQm__.js.gz +0 -0
- package/web/build/client/_app/version.json +1 -1
- package/web/build/client/_app/version.json.br +0 -0
- package/web/build/client/_app/version.json.gz +0 -0
- package/web/build/handler.js +3 -3
- package/web/build/index.js +3 -3
- package/web/build/server/chunks/{0-SseDy__O.js → 0-BYok4D0n.js} +2 -2
- package/web/build/server/chunks/{0-SseDy__O.js.map → 0-BYok4D0n.js.map} +1 -1
- package/web/build/server/chunks/1-C7zcB0q0.js +7 -0
- package/web/build/server/chunks/1-C7zcB0q0.js.map +1 -0
- package/web/build/server/chunks/2-v6ejI_0-.js +7 -0
- package/web/build/server/chunks/2-v6ejI_0-.js.map +1 -0
- package/web/build/server/chunks/3-DAAmG6VX.js +7 -0
- package/web/build/server/chunks/3-DAAmG6VX.js.map +1 -0
- package/web/build/server/chunks/_server.ts-qYVrMYPB.js +62 -0
- package/web/build/server/chunks/_server.ts-qYVrMYPB.js.map +1 -0
- package/web/build/server/chunks/agent-sessions-DSqGavSn.js +2688 -0
- package/web/build/server/chunks/agent-sessions-DSqGavSn.js.map +1 -0
- package/web/build/server/chunks/{remote-1g4ayag-B8KZPTkn.js → git-review-CpO_G8z8.js} +70 -704
- package/web/build/server/chunks/git-review-CpO_G8z8.js.map +1 -0
- package/web/build/server/chunks/{handler-cq3QoEzW.js → handler-Di1wJQuU.js} +3 -3
- package/web/build/server/chunks/{handler-cq3QoEzW.js.map → handler-Di1wJQuU.js.map} +1 -1
- package/web/build/server/chunks/hooks.server-BLOHobEK.js +42 -0
- package/web/build/server/chunks/hooks.server-BLOHobEK.js.map +1 -0
- package/web/build/server/chunks/{index.js-D4qkHe3r.js → index.js-yLYmrAWy.js} +3 -5
- package/web/build/server/chunks/index.js-yLYmrAWy.js.map +1 -0
- package/web/build/server/chunks/manifest.js-CXj8IIo3.js +58 -0
- package/web/build/server/chunks/manifest.js-CXj8IIo3.js.map +1 -0
- package/web/build/server/chunks/remote-1g4ayag-BiJeU0Pk.js +144 -0
- package/web/build/server/chunks/remote-1g4ayag-BiJeU0Pk.js.map +1 -0
- package/web/build/server/chunks/remote-wh6t6j-CHanXurm.js +162 -0
- package/web/build/server/chunks/remote-wh6t6j-CHanXurm.js.map +1 -0
- package/web/build/server/chunks/validate-BsTKyhsq.js +1073 -0
- package/web/build/server/chunks/validate-BsTKyhsq.js.map +1 -0
- package/web/build/client/_app/immutable/assets/2.COsJT9ly.css +0 -1
- package/web/build/client/_app/immutable/assets/2.COsJT9ly.css.br +0 -0
- package/web/build/client/_app/immutable/assets/2.COsJT9ly.css.gz +0 -0
- package/web/build/client/_app/immutable/assets/3.qhPDz1xQ.css +0 -1
- package/web/build/client/_app/immutable/assets/3.qhPDz1xQ.css.br +0 -0
- package/web/build/client/_app/immutable/assets/3.qhPDz1xQ.css.gz +0 -0
- package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css +0 -1
- package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css.br +0 -0
- package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css.gz +0 -0
- package/web/build/client/_app/immutable/chunks/C4--ziCr.js +0 -2
- package/web/build/client/_app/immutable/chunks/C4--ziCr.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/C4--ziCr.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/D8sAxZVo.js +0 -174
- package/web/build/client/_app/immutable/chunks/D8sAxZVo.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/D8sAxZVo.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/Dur-vdUf.js +0 -3
- package/web/build/client/_app/immutable/chunks/Dur-vdUf.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/Dur-vdUf.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/tYeNsyIK.js +0 -1
- package/web/build/client/_app/immutable/chunks/tYeNsyIK.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/tYeNsyIK.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/app.Dl9OTE-n.js +0 -2
- package/web/build/client/_app/immutable/entry/app.Dl9OTE-n.js.br +0 -0
- package/web/build/client/_app/immutable/entry/app.Dl9OTE-n.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js +0 -1
- package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js.br +0 -1
- package/web/build/client/_app/immutable/entry/start.CDz8Qs8Q.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js +0 -1
- package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js.br +0 -1
- package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js +0 -1
- package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/1.B1c4AH1e.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js +0 -66
- package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/2.Dx-oOpEf.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/3.C-USexyX.js +0 -170
- package/web/build/client/_app/immutable/nodes/3.C-USexyX.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/3.C-USexyX.js.gz +0 -0
- package/web/build/server/chunks/1-0X6Jp9nH.js +0 -7
- package/web/build/server/chunks/1-0X6Jp9nH.js.map +0 -1
- package/web/build/server/chunks/2-HlcVALg6.js +0 -7
- package/web/build/server/chunks/2-HlcVALg6.js.map +0 -1
- package/web/build/server/chunks/3-BN1eS7L2.js +0 -7
- package/web/build/server/chunks/3-BN1eS7L2.js.map +0 -1
- package/web/build/server/chunks/hooks.server-DERFlOe1.js +0 -14
- package/web/build/server/chunks/hooks.server-DERFlOe1.js.map +0 -1
- package/web/build/server/chunks/index.js-D4qkHe3r.js.map +0 -1
- package/web/build/server/chunks/manifest.js-DQHMKyJh.js +0 -50
- package/web/build/server/chunks/manifest.js-DQHMKyJh.js.map +0 -1
- 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
|
+
}
|
package/lib/acp.test.mjs
ADDED
|
@@ -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
|
+
}
|