@jpkme/diff-review 0.1.10 → 0.1.13
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 +151 -34
- package/lib/acp-policy.mjs +223 -0
- package/lib/acp-policy.test.mjs +136 -0
- package/lib/acp-shell.mjs +409 -0
- package/lib/acp-shell.test.mjs +158 -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 +87 -14
- package/lib/agents.test.mjs +4 -6
- package/lib/check-report.mjs +30 -11
- 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 +168 -22
- package/lib/review-dir.mjs +108 -0
- package/lib/review-meta.mjs +5 -3
- package/lib/review-meta.test.mjs +11 -0
- package/lib/review-prep.mjs +63 -0
- package/lib/validate.mjs +2 -2
- package/lib/wait-output.mjs +46 -23
- package/lib/wait-output.test.mjs +24 -1
- package/package.json +3 -1
- package/web/build/client/_app/immutable/assets/{0.CLQiq3Ed.css → 0.BKRG1f36.css} +1 -1
- package/web/build/client/_app/immutable/assets/0.BKRG1f36.css.br +0 -0
- package/web/build/client/_app/immutable/assets/{0.CLQiq3Ed.css.gz → 0.BKRG1f36.css.gz} +0 -0
- package/web/build/client/_app/immutable/assets/2.tqrMRTM0.css +1 -0
- package/web/build/client/_app/immutable/assets/2.tqrMRTM0.css.br +0 -0
- package/web/build/client/_app/immutable/assets/2.tqrMRTM0.css.gz +0 -0
- package/web/build/client/_app/immutable/assets/3.BT54aVKb.css +1 -0
- package/web/build/client/_app/immutable/assets/3.BT54aVKb.css.br +0 -0
- package/web/build/client/_app/immutable/assets/3.BT54aVKb.css.gz +0 -0
- package/web/build/client/_app/immutable/assets/storage.DqYH0w6R.css +1 -0
- package/web/build/client/_app/immutable/assets/storage.DqYH0w6R.css.br +0 -0
- package/web/build/client/_app/immutable/assets/storage.DqYH0w6R.css.gz +0 -0
- package/web/build/client/_app/immutable/chunks/BXUWMim4.js +213 -0
- package/web/build/client/_app/immutable/chunks/BXUWMim4.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/BXUWMim4.js.gz +0 -0
- package/web/build/client/_app/immutable/chunks/Bu1mm6Oz.js +1 -0
- package/web/build/client/_app/immutable/chunks/Bu1mm6Oz.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/Bu1mm6Oz.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/{BBWAo_yW.js → DPfXzQnm.js} +2 -2
- package/web/build/client/_app/immutable/chunks/DPfXzQnm.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/DPfXzQnm.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.CTgiuiU1.js +2 -0
- package/web/build/client/_app/immutable/entry/app.CTgiuiU1.js.br +0 -0
- package/web/build/client/_app/immutable/entry/app.CTgiuiU1.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/start.CFSl0kht.js +1 -0
- package/web/build/client/_app/immutable/entry/start.CFSl0kht.js.br +0 -0
- package/web/build/client/_app/immutable/entry/start.CFSl0kht.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/0.Dj97JW46.js +1 -0
- package/web/build/client/_app/immutable/nodes/0.Dj97JW46.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/0.Dj97JW46.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/1.0PQZpSbJ.js +1 -0
- package/web/build/client/_app/immutable/nodes/1.0PQZpSbJ.js.br +2 -0
- package/web/build/client/_app/immutable/nodes/1.0PQZpSbJ.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/2.DQaoQoy4.js +10 -0
- package/web/build/client/_app/immutable/nodes/2.DQaoQoy4.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/2.DQaoQoy4.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/3.AciugOtL.js +171 -0
- package/web/build/client/_app/immutable/nodes/3.AciugOtL.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/3.AciugOtL.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-DUStDOsr.js +11 -0
- package/web/build/server/chunks/{0-SseDy__O.js.map → 0-DUStDOsr.js.map} +1 -1
- package/web/build/server/chunks/1-C2qlInYk.js +7 -0
- package/web/build/server/chunks/1-C2qlInYk.js.map +1 -0
- package/web/build/server/chunks/2-DyE177MK.js +7 -0
- package/web/build/server/chunks/2-DyE177MK.js.map +1 -0
- package/web/build/server/chunks/3-SO3atuWO.js +7 -0
- package/web/build/server/chunks/3-SO3atuWO.js.map +1 -0
- package/web/build/server/chunks/_server.ts-CRNhzL2a.js +62 -0
- package/web/build/server/chunks/_server.ts-CRNhzL2a.js.map +1 -0
- package/web/build/server/chunks/agent-sessions-kcUaLhK5.js +2819 -0
- package/web/build/server/chunks/agent-sessions-kcUaLhK5.js.map +1 -0
- package/web/build/server/chunks/{remote-1g4ayag-B67QMR_2.js → git-review-CXKcjYgZ.js} +70 -704
- package/web/build/server/chunks/git-review-CXKcjYgZ.js.map +1 -0
- package/web/build/server/chunks/{handler-B5P65tLk.js → handler-_E0NucKN.js} +3 -3
- package/web/build/server/chunks/{handler-B5P65tLk.js.map → handler-_E0NucKN.js.map} +1 -1
- package/web/build/server/chunks/hooks.server-D7yPODoc.js +42 -0
- package/web/build/server/chunks/hooks.server-D7yPODoc.js.map +1 -0
- package/web/build/server/chunks/{index.js-CL95e0Gl.js → index.js-lMtDM8ZQ.js} +3 -5
- package/web/build/server/chunks/index.js-lMtDM8ZQ.js.map +1 -0
- package/web/build/server/chunks/manifest.js-BIm6jot5.js +58 -0
- package/web/build/server/chunks/manifest.js-BIm6jot5.js.map +1 -0
- package/web/build/server/chunks/remote-1g4ayag-DmqxwUIz.js +144 -0
- package/web/build/server/chunks/remote-1g4ayag-DmqxwUIz.js.map +1 -0
- package/web/build/server/chunks/remote-wh6t6j-DZk0W9Pp.js +162 -0
- package/web/build/server/chunks/remote-wh6t6j-DZk0W9Pp.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/0.CLQiq3Ed.css.br +0 -0
- package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css +0 -1
- package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.br +0 -0
- package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.gz +0 -0
- package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css +0 -1
- package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css.br +0 -0
- package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.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/BBWAo_yW.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/BBWAo_yW.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/QdZF3nkf.js +0 -176
- package/web/build/client/_app/immutable/chunks/QdZF3nkf.js.br +0 -0
- package/web/build/client/_app/immutable/chunks/QdZF3nkf.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.Dce6uJU3.js +0 -2
- package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.br +0 -0
- package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.gz +0 -0
- package/web/build/client/_app/immutable/entry/start.DnBSC38l.js +0 -1
- package/web/build/client/_app/immutable/entry/start.DnBSC38l.js.br +0 -0
- package/web/build/client/_app/immutable/entry/start.DnBSC38l.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.5Iv4ssDT.js +0 -1
- package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js +0 -66
- package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.gz +0 -0
- package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js +0 -170
- package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.br +0 -0
- package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.gz +0 -0
- package/web/build/server/chunks/0-SseDy__O.js +0 -11
- package/web/build/server/chunks/1-2OO2bb7H.js +0 -7
- package/web/build/server/chunks/1-2OO2bb7H.js.map +0 -1
- package/web/build/server/chunks/2-BJvuqKN6.js +0 -7
- package/web/build/server/chunks/2-BJvuqKN6.js.map +0 -1
- package/web/build/server/chunks/3-ZIl1rHef.js +0 -7
- package/web/build/server/chunks/3-ZIl1rHef.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-CL95e0Gl.js.map +0 -1
- package/web/build/server/chunks/manifest.js-uD_a0079.js +0 -50
- package/web/build/server/chunks/manifest.js-uD_a0079.js.map +0 -1
- package/web/build/server/chunks/remote-1g4ayag-B67QMR_2.js.map +0 -1
|
@@ -0,0 +1,2819 @@
|
|
|
1
|
+
import { g as getRemoteUrl, e as ensureReviewDir, w as writeDiffCache, p as readDiffCache, q as extractBranchDiff, u as loadReviewConfig, v as outputRelPath, m as makeOutputFilename, O as OUTPUT_DIR, x as writeSessionRef, h as companionPaths, d as decodePayload, t as tryResolveGitRoot, j as loadBranchDiff, y as validateAgainst, z as reviewIssues, R as REVIEW_SCHEMA, A as globMatch, C as writeDiffText } from './validate-BsTKyhsq.js';
|
|
2
|
+
import { existsSync, readFileSync, statSync, accessSync, constants, readdirSync, mkdirSync, writeFileSync, copyFileSync, rmSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { homedir, tmpdir } from 'node:os';
|
|
5
|
+
import { join, delimiter, isAbsolute, resolve, basename, dirname, normalize } from 'node:path';
|
|
6
|
+
import { Writable, Readable } from 'node:stream';
|
|
7
|
+
import * as acp from '@agentclientprotocol/sdk';
|
|
8
|
+
|
|
9
|
+
//#region ../lib/agents.mjs
|
|
10
|
+
/**
|
|
11
|
+
* Agentes de código que la CLI puede lanzar con el prompt (`--agent`).
|
|
12
|
+
*
|
|
13
|
+
* No hay un registro de agentes en ningún SO: se detectan por nombre en el PATH más algunas rutas fijas
|
|
14
|
+
* conocidas (p. ej. el Claude Code que trae la app de escritorio en Windows). El prompt viaja por stdin, así
|
|
15
|
+
* no se toca el portapapeles ni se choca con el límite de largo de la línea de comandos de los shims `.cmd`.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {{ value: string, label: string, hint?: string }} ModelOption
|
|
19
|
+
* @typedef {{ model?: string, outputFilename: string }} RunSpec
|
|
20
|
+
* @typedef {{
|
|
21
|
+
* id: string,
|
|
22
|
+
* label: string,
|
|
23
|
+
* bins: string[],
|
|
24
|
+
* extraPaths?: (ctx: DetectContext) => string[],
|
|
25
|
+
* models: ModelOption[],
|
|
26
|
+
* configuredModel?: (ctx: DetectContext) => { model: string, source: string } | null,
|
|
27
|
+
* args: (spec: RunSpec) => string[],
|
|
28
|
+
* acp?: (ctx: { path: string }) => AcpLaunch,
|
|
29
|
+
* }} AgentDef
|
|
30
|
+
* @typedef {{ file: string, args: string[], env?: Record<string, string>, sessionMeta?: Record<string, unknown> }} AcpLaunch
|
|
31
|
+
* `env` se suma al del proceso; `sessionMeta` va como `_meta` en `session/new` / `session/load`.
|
|
32
|
+
* @typedef {{ env: NodeJS.ProcessEnv, platform: NodeJS.Platform, home: string }} DetectContext
|
|
33
|
+
* @typedef {{ def: AgentDef, path: string }} DetectedAgent
|
|
34
|
+
*/
|
|
35
|
+
/** @param {string} file */
|
|
36
|
+
function readJson(file) {
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Primer `model = "..."` de nivel raíz de un TOML (antes de cualquier `[sección]`). */
|
|
44
|
+
function topLevelTomlModel(text) {
|
|
45
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
46
|
+
if (/^\s*\[/.test(line)) break;
|
|
47
|
+
const m = line.match(/^\s*model\s*=\s*["']([^"']+)["']/);
|
|
48
|
+
if (m) return m[1];
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
/** Versiones tipo `2.1.286` de mayor a menor (las que no parsean, al final). */
|
|
53
|
+
function byVersionDesc(a, b) {
|
|
54
|
+
const pa = a.split(".").map(Number);
|
|
55
|
+
const pb = b.split(".").map(Number);
|
|
56
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
57
|
+
const d = (pb[i] || 0) - (pa[i] || 0);
|
|
58
|
+
if (d) return Number.isNaN(d) ? 0 : d;
|
|
59
|
+
}
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
/** `<root>/<versión>/<hash>/<exe>` → rutas, de la versión más nueva a la más vieja. */
|
|
63
|
+
function versionedBinaries(root, exe) {
|
|
64
|
+
/** @type {string[]} */
|
|
65
|
+
const out = [];
|
|
66
|
+
let versions;
|
|
67
|
+
try {
|
|
68
|
+
versions = readdirSync(root).sort(byVersionDesc);
|
|
69
|
+
} catch {
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
for (const version of versions) {
|
|
73
|
+
let entries;
|
|
74
|
+
try {
|
|
75
|
+
entries = readdirSync(join(root, version));
|
|
76
|
+
} catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
for (const entry of entries) out.push(join(root, version, entry, exe));
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Carpeta de configuración para Cursor por ACP: la del usuario sin su allowlist. Cursor aprueba solo lo que matchee
|
|
85
|
+
* `permissions.allow` mirando el nombre del comando (`Shell(echo)` deja pasar `echo x > archivo`); vaciándola,
|
|
86
|
+
* cada comando pasa por la política de diff-review. El login no vive ahí, así que se conserva. El modelo elegido
|
|
87
|
+
* por ACP se guarda en esta carpeta y no pisa el default del usuario.
|
|
88
|
+
*/
|
|
89
|
+
function cursorAcpConfigDir(ctx) {
|
|
90
|
+
const c = context(ctx);
|
|
91
|
+
const userDir = c.env.CURSOR_CONFIG_DIR?.trim() || (c.env.XDG_CONFIG_HOME?.trim() ? join(c.env.XDG_CONFIG_HOME, "cursor") : join(c.home, ".cursor"));
|
|
92
|
+
const dir = join(tmpdir(), "diff-review", "cursor-config");
|
|
93
|
+
mkdirSync(dir, { recursive: true });
|
|
94
|
+
const config = readJson(join(userDir, "cli-config.json")) ?? {};
|
|
95
|
+
writeFileSync(join(dir, "cli-config.json"), JSON.stringify({
|
|
96
|
+
...config,
|
|
97
|
+
permissions: {
|
|
98
|
+
allow: [],
|
|
99
|
+
deny: config.permissions?.deny ?? []
|
|
100
|
+
},
|
|
101
|
+
approvalMode: "allowlist"
|
|
102
|
+
}, null, 2));
|
|
103
|
+
const userAcp = join(userDir, "acp-config.json");
|
|
104
|
+
if (existsSync(userAcp)) copyFileSync(userAcp, join(dir, "acp-config.json"));
|
|
105
|
+
else rmSync(join(dir, "acp-config.json"), { force: true });
|
|
106
|
+
return dir;
|
|
107
|
+
}
|
|
108
|
+
/** Adaptador ACP de Claude Code (antes `@zed-industries/claude-agent-acp`). */
|
|
109
|
+
var CLAUDE_ACP_PACKAGE = "@agentclientprotocol/claude-agent-acp@^0.91.0";
|
|
110
|
+
/** @type {AgentDef[]} */
|
|
111
|
+
var AGENTS = [
|
|
112
|
+
{
|
|
113
|
+
id: "claude",
|
|
114
|
+
label: "Claude Code",
|
|
115
|
+
bins: ["claude"],
|
|
116
|
+
extraPaths: ({ env, platform, home }) => {
|
|
117
|
+
if (platform === "win32") return versionedBinaries(join(env.APPDATA || join(home, "AppData", "Roaming"), "Claude", "claude-code"), "claude.exe");
|
|
118
|
+
const paths = [join(home, ".local", "bin", "claude"), join(home, ".claude", "local", "claude")];
|
|
119
|
+
if (platform === "darwin") paths.push(...versionedBinaries(join(home, "Library", "Application Support", "Claude", "claude-code"), "claude"));
|
|
120
|
+
return paths;
|
|
121
|
+
},
|
|
122
|
+
models: [
|
|
123
|
+
{
|
|
124
|
+
value: "opus",
|
|
125
|
+
label: "opus",
|
|
126
|
+
hint: "el más capaz"
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
value: "sonnet",
|
|
130
|
+
label: "sonnet",
|
|
131
|
+
hint: "equilibrado"
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
value: "haiku",
|
|
135
|
+
label: "haiku",
|
|
136
|
+
hint: "el más rápido"
|
|
137
|
+
}
|
|
138
|
+
],
|
|
139
|
+
configuredModel: ({ env, home }) => {
|
|
140
|
+
if (env.ANTHROPIC_MODEL) return {
|
|
141
|
+
model: env.ANTHROPIC_MODEL,
|
|
142
|
+
source: "ANTHROPIC_MODEL"
|
|
143
|
+
};
|
|
144
|
+
const model = readJson(join(home, ".claude", "settings.json"))?.model;
|
|
145
|
+
return typeof model === "string" && model ? {
|
|
146
|
+
model,
|
|
147
|
+
source: "~/.claude/settings.json"
|
|
148
|
+
} : null;
|
|
149
|
+
},
|
|
150
|
+
args: ({ model, outputFilename }) => [
|
|
151
|
+
"-p",
|
|
152
|
+
...model ? ["--model", model] : [],
|
|
153
|
+
"--allowedTools",
|
|
154
|
+
"Read",
|
|
155
|
+
"Grep",
|
|
156
|
+
"Glob",
|
|
157
|
+
"Bash(git:*)",
|
|
158
|
+
"Bash(npx --yes @jpkme/diff-review validate:*)",
|
|
159
|
+
`Edit(./${outputRelPath(outputFilename)})`
|
|
160
|
+
],
|
|
161
|
+
acp: () => {
|
|
162
|
+
const local = findOnPath("claude-agent-acp", context());
|
|
163
|
+
return {
|
|
164
|
+
...local ? {
|
|
165
|
+
file: local,
|
|
166
|
+
args: []
|
|
167
|
+
} : {
|
|
168
|
+
file: "npx",
|
|
169
|
+
args: ["--yes", CLAUDE_ACP_PACKAGE]
|
|
170
|
+
},
|
|
171
|
+
sessionMeta: { claudeCode: { options: {
|
|
172
|
+
tools: [
|
|
173
|
+
"Read",
|
|
174
|
+
"Grep",
|
|
175
|
+
"Glob",
|
|
176
|
+
"Bash",
|
|
177
|
+
"Edit",
|
|
178
|
+
"Write"
|
|
179
|
+
],
|
|
180
|
+
settingSources: []
|
|
181
|
+
} } }
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
id: "codex",
|
|
187
|
+
label: "Codex",
|
|
188
|
+
bins: ["codex"],
|
|
189
|
+
models: [],
|
|
190
|
+
configuredModel: ({ home }) => {
|
|
191
|
+
try {
|
|
192
|
+
const model = topLevelTomlModel(readFileSync(join(home, ".codex", "config.toml"), "utf8"));
|
|
193
|
+
return model ? {
|
|
194
|
+
model,
|
|
195
|
+
source: "~/.codex/config.toml"
|
|
196
|
+
} : null;
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
args: ({ model }) => [
|
|
202
|
+
"exec",
|
|
203
|
+
"--sandbox",
|
|
204
|
+
"workspace-write",
|
|
205
|
+
...model ? ["-m", model] : [],
|
|
206
|
+
"-"
|
|
207
|
+
]
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: "cursor",
|
|
211
|
+
label: "Cursor CLI",
|
|
212
|
+
bins: ["cursor-agent"],
|
|
213
|
+
extraPaths: ({ env, platform, home }) => platform === "win32" ? [join(env.LOCALAPPDATA || join(home, "AppData", "Local"), "cursor-agent", "cursor-agent.cmd")] : [join(home, ".local", "bin", "cursor-agent")],
|
|
214
|
+
models: [],
|
|
215
|
+
configuredModel: ({ home }) => {
|
|
216
|
+
const config = readJson(join(home, ".cursor", "cli-config.json"));
|
|
217
|
+
const id = config?.selectedModel?.modelId ?? config?.model?.modelId;
|
|
218
|
+
if (typeof id !== "string" || !id) return null;
|
|
219
|
+
return {
|
|
220
|
+
model: id === "default" ? "auto" : id,
|
|
221
|
+
source: "~/.cursor/cli-config.json"
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
args: ({ model }) => [
|
|
225
|
+
"-p",
|
|
226
|
+
"--output-format",
|
|
227
|
+
"text",
|
|
228
|
+
"--trust",
|
|
229
|
+
"--force",
|
|
230
|
+
...model ? ["--model", model] : []
|
|
231
|
+
],
|
|
232
|
+
acp: ({ path }) => ({
|
|
233
|
+
file: path,
|
|
234
|
+
args: ["acp"],
|
|
235
|
+
env: { CURSOR_CONFIG_DIR: cursorAcpConfigDir() }
|
|
236
|
+
})
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
id: "copilot",
|
|
240
|
+
label: "GitHub Copilot CLI",
|
|
241
|
+
bins: ["copilot"],
|
|
242
|
+
models: [{
|
|
243
|
+
value: "auto",
|
|
244
|
+
label: "auto",
|
|
245
|
+
hint: "Copilot elige"
|
|
246
|
+
}],
|
|
247
|
+
configuredModel: ({ env, home }) => {
|
|
248
|
+
const model = readJson(join(env.COPILOT_HOME || join(home, ".copilot"), "settings.json"))?.model;
|
|
249
|
+
const source = env.COPILOT_HOME ? "$COPILOT_HOME/settings.json" : "~/.copilot/settings.json";
|
|
250
|
+
return typeof model === "string" && model ? {
|
|
251
|
+
model,
|
|
252
|
+
source
|
|
253
|
+
} : null;
|
|
254
|
+
},
|
|
255
|
+
args: ({ model, outputFilename }) => [
|
|
256
|
+
"-s",
|
|
257
|
+
...model ? ["--model", model] : [],
|
|
258
|
+
"--allow-tool",
|
|
259
|
+
"shell(git:*)",
|
|
260
|
+
"--allow-tool",
|
|
261
|
+
"shell(npx:*)",
|
|
262
|
+
"--allow-tool",
|
|
263
|
+
`write(${outputRelPath(outputFilename)})`
|
|
264
|
+
],
|
|
265
|
+
acp: ({ path }) => ({
|
|
266
|
+
file: path,
|
|
267
|
+
args: ["--acp"]
|
|
268
|
+
})
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
id: "gemini",
|
|
272
|
+
label: "Gemini CLI",
|
|
273
|
+
bins: ["gemini"],
|
|
274
|
+
models: [],
|
|
275
|
+
configuredModel: ({ home }) => {
|
|
276
|
+
const model = readJson(join(home, ".gemini", "settings.json"))?.model;
|
|
277
|
+
const name = typeof model === "string" ? model : model?.name;
|
|
278
|
+
return typeof name === "string" && name ? {
|
|
279
|
+
model: name,
|
|
280
|
+
source: "~/.gemini/settings.json"
|
|
281
|
+
} : null;
|
|
282
|
+
},
|
|
283
|
+
args: ({ model }) => [...model ? ["-m", model] : [], "--yolo"]
|
|
284
|
+
}
|
|
285
|
+
];
|
|
286
|
+
/** @param {string} file @param {NodeJS.Platform} platform */
|
|
287
|
+
function isExecutable(file, platform) {
|
|
288
|
+
try {
|
|
289
|
+
if (!statSync(file).isFile()) return false;
|
|
290
|
+
if (platform !== "win32") accessSync(file, constants.X_OK);
|
|
291
|
+
return true;
|
|
292
|
+
} catch {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** Busca `name` en el PATH (con las extensiones de PATHEXT en Windows). */
|
|
297
|
+
function findOnPath(name, { env, platform }) {
|
|
298
|
+
const dirs = String(env.PATH || env.Path || "").split(platform === "win32" ? ";" : delimiter).filter(Boolean);
|
|
299
|
+
const exts = platform === "win32" ? String(env.PATHEXT || ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""];
|
|
300
|
+
for (const dir of dirs) for (const ext of exts) {
|
|
301
|
+
const candidate = join(dir, name + ext.toLowerCase());
|
|
302
|
+
if (isExecutable(candidate, platform)) return candidate;
|
|
303
|
+
}
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
/** @param {Partial<DetectContext>} [ctx] */
|
|
307
|
+
function context(ctx = {}) {
|
|
308
|
+
return {
|
|
309
|
+
env: ctx.env ?? process.env,
|
|
310
|
+
platform: ctx.platform ?? process.platform,
|
|
311
|
+
home: ctx.home ?? homedir()
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Agentes instalados, en el orden de `AGENTS`. Primero el PATH, después las rutas fijas.
|
|
316
|
+
* @param {Partial<DetectContext>} [ctx]
|
|
317
|
+
* @returns {DetectedAgent[]}
|
|
318
|
+
*/
|
|
319
|
+
function detectAgents(ctx) {
|
|
320
|
+
const c = context(ctx);
|
|
321
|
+
/** @type {DetectedAgent[]} */
|
|
322
|
+
const found = [];
|
|
323
|
+
for (const def of AGENTS) {
|
|
324
|
+
let path = null;
|
|
325
|
+
for (const bin of def.bins) {
|
|
326
|
+
path = findOnPath(bin, c);
|
|
327
|
+
if (path) break;
|
|
328
|
+
}
|
|
329
|
+
if (!path) path = (def.extraPaths?.(c) ?? []).find((p) => existsSync(p) && isExecutable(p, c.platform)) ?? null;
|
|
330
|
+
if (path) found.push({
|
|
331
|
+
def,
|
|
332
|
+
path
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
return found;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Opciones del select de modelo: primero el default del agente (marcado como inicial), después los conocidos.
|
|
339
|
+
* Si el default configurado coincide con uno conocido, no se repite.
|
|
340
|
+
* @param {AgentDef} def
|
|
341
|
+
* @param {Partial<DetectContext>} [ctx]
|
|
342
|
+
* @returns {{ options: ModelOption[], initialValue: string }}
|
|
343
|
+
*/
|
|
344
|
+
function modelChoices(def, ctx) {
|
|
345
|
+
const configured = def.configuredModel?.(context(ctx)) ?? null;
|
|
346
|
+
return {
|
|
347
|
+
options: [configured ? {
|
|
348
|
+
value: "",
|
|
349
|
+
label: `Por defecto (${configured.model})`,
|
|
350
|
+
hint: configured.source
|
|
351
|
+
} : {
|
|
352
|
+
value: "",
|
|
353
|
+
label: "Por defecto del agente",
|
|
354
|
+
hint: "no se pasa --model"
|
|
355
|
+
}, ...def.models.filter((m) => m.value !== configured?.model)],
|
|
356
|
+
initialValue: ""
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
/** Comillas para cmd.exe (solo hace falta con los shims .cmd/.bat de npm). */
|
|
360
|
+
function quoteForCmd(arg) {
|
|
361
|
+
return /^[\w./:@=-]+$/.test(arg) ? arg : `"${arg.replace(/"/g, "\"\"")}"`;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* `spawn` que también lanza los shims `.cmd`/`.bat` de npm en Windows (y `npx` a secas, que es uno de ellos).
|
|
365
|
+
* @param {string} file
|
|
366
|
+
* @param {string[]} args
|
|
367
|
+
* @param {import('node:child_process').SpawnOptions} options
|
|
368
|
+
*/
|
|
369
|
+
function spawnCommand(file, args, options) {
|
|
370
|
+
const resolved = process.platform === "win32" && !/[\\/]/.test(file) ? findOnPath(file, context()) ?? file : file;
|
|
371
|
+
return process.platform === "win32" && /\.(cmd|bat)$/i.test(resolved) ? spawn([resolved, ...args].map(quoteForCmd).join(" "), {
|
|
372
|
+
...options,
|
|
373
|
+
shell: true
|
|
374
|
+
}) : spawn(resolved, args, options);
|
|
375
|
+
}
|
|
376
|
+
//#endregion
|
|
377
|
+
//#region ../lib/acp-shell.mjs
|
|
378
|
+
/**
|
|
379
|
+
* Qué comandos de terminal puede correr el agente por ACP: git de solo lectura, comandos que solo leen (cat, grep,
|
|
380
|
+
* find, sed -n …, y sus equivalentes de PowerShell) y el validate del paquete. Los paths tienen que quedar dentro
|
|
381
|
+
* del repo.
|
|
382
|
+
*
|
|
383
|
+
* El comando se tokeniza respetando comillas (un `grep "a\|b"` no es un pipe). Se aceptan `|`, `&&`, `||`, `;` y
|
|
384
|
+
* descartar salida a /dev/null; cualquier otra redirección, `$(…)`, backticks o `&` lo rechazan.
|
|
385
|
+
*/
|
|
386
|
+
/** @typedef {{ ok: true } | { ok: false, reason: string }} ShellCheck */
|
|
387
|
+
/** Subcomandos de git que solo leen. */
|
|
388
|
+
var GIT_READ = /* @__PURE__ */ new Set([
|
|
389
|
+
"diff",
|
|
390
|
+
"log",
|
|
391
|
+
"show",
|
|
392
|
+
"status",
|
|
393
|
+
"merge-base",
|
|
394
|
+
"rev-parse",
|
|
395
|
+
"rev-list",
|
|
396
|
+
"blame",
|
|
397
|
+
"ls-files",
|
|
398
|
+
"ls-tree",
|
|
399
|
+
"cat-file",
|
|
400
|
+
"describe",
|
|
401
|
+
"shortlog",
|
|
402
|
+
"name-rev",
|
|
403
|
+
"for-each-ref",
|
|
404
|
+
"grep",
|
|
405
|
+
"whatchanged"
|
|
406
|
+
]);
|
|
407
|
+
/** Flags de git que ejecutan algo o escriben archivos, en cualquier subcomando. */
|
|
408
|
+
var GIT_UNSAFE_FLAG = /^(--output|--ext-diff|-O|--open-files-in-pager)(=|$)/;
|
|
409
|
+
var BRANCH_LIST_FLAGS = /* @__PURE__ */ new Set([
|
|
410
|
+
"--show-current",
|
|
411
|
+
"--list",
|
|
412
|
+
"-a",
|
|
413
|
+
"--all",
|
|
414
|
+
"-r",
|
|
415
|
+
"--remotes",
|
|
416
|
+
"-v",
|
|
417
|
+
"-vv"
|
|
418
|
+
]);
|
|
419
|
+
var CONFIG_READ_FLAGS = /* @__PURE__ */ new Set([
|
|
420
|
+
"--get",
|
|
421
|
+
"--get-all",
|
|
422
|
+
"--get-regexp",
|
|
423
|
+
"--list",
|
|
424
|
+
"-l"
|
|
425
|
+
]);
|
|
426
|
+
/** Comandos que solo leen (o imprimen). Los de PowerShell van en minúscula: no distingue mayúsculas. */
|
|
427
|
+
var READ_COMMANDS = /* @__PURE__ */ new Set([
|
|
428
|
+
"cat",
|
|
429
|
+
"head",
|
|
430
|
+
"tail",
|
|
431
|
+
"wc",
|
|
432
|
+
"ls",
|
|
433
|
+
"nl",
|
|
434
|
+
"sort",
|
|
435
|
+
"uniq",
|
|
436
|
+
"cut",
|
|
437
|
+
"tr",
|
|
438
|
+
"grep",
|
|
439
|
+
"egrep",
|
|
440
|
+
"fgrep",
|
|
441
|
+
"rg",
|
|
442
|
+
"find",
|
|
443
|
+
"sed",
|
|
444
|
+
"file",
|
|
445
|
+
"stat",
|
|
446
|
+
"du",
|
|
447
|
+
"diff",
|
|
448
|
+
"cmp",
|
|
449
|
+
"jq",
|
|
450
|
+
"basename",
|
|
451
|
+
"dirname",
|
|
452
|
+
"realpath",
|
|
453
|
+
"tree",
|
|
454
|
+
"which",
|
|
455
|
+
"type",
|
|
456
|
+
"pwd",
|
|
457
|
+
"echo",
|
|
458
|
+
"printf",
|
|
459
|
+
"true",
|
|
460
|
+
"get-content",
|
|
461
|
+
"gc",
|
|
462
|
+
"select-string",
|
|
463
|
+
"sls",
|
|
464
|
+
"get-childitem",
|
|
465
|
+
"gci",
|
|
466
|
+
"dir",
|
|
467
|
+
"measure-object",
|
|
468
|
+
"measure",
|
|
469
|
+
"select-object",
|
|
470
|
+
"select",
|
|
471
|
+
"sort-object",
|
|
472
|
+
"get-location",
|
|
473
|
+
"test-path",
|
|
474
|
+
"resolve-path",
|
|
475
|
+
"write-output",
|
|
476
|
+
"out-string",
|
|
477
|
+
"format-list",
|
|
478
|
+
"format-table"
|
|
479
|
+
]);
|
|
480
|
+
/** Comandos cuyos argumentos son texto, no paths. */
|
|
481
|
+
var TEXT_ARGS = /* @__PURE__ */ new Set([
|
|
482
|
+
"echo",
|
|
483
|
+
"printf",
|
|
484
|
+
"tr",
|
|
485
|
+
"write-output",
|
|
486
|
+
"which",
|
|
487
|
+
"type"
|
|
488
|
+
]);
|
|
489
|
+
/** Destinos de redirección que solo descartan salida. */
|
|
490
|
+
var DISCARD = /* @__PURE__ */ new Set([
|
|
491
|
+
"/dev/null",
|
|
492
|
+
"nul",
|
|
493
|
+
"$null"
|
|
494
|
+
]);
|
|
495
|
+
/**
|
|
496
|
+
* Paths de shell Unix → forma Windows cuando la política corre en win32:
|
|
497
|
+
* - Git Bash: `/c/Users/...` → `C:/Users/...`
|
|
498
|
+
* - WSL: `/mnt/c/Users/...` → `C:/Users/...`
|
|
499
|
+
*/
|
|
500
|
+
function toNativePath(p, platform) {
|
|
501
|
+
if (platform === "win32") {
|
|
502
|
+
const wsl = /^\/mnt\/([a-zA-Z])(\/|$)(.*)$/.exec(p);
|
|
503
|
+
if (wsl) return `${wsl[1].toUpperCase()}:/${wsl[3]}`;
|
|
504
|
+
const msys = /^\/([a-zA-Z])(\/|$)(.*)$/.exec(p);
|
|
505
|
+
if (msys) return `${msys[1].toUpperCase()}:/${msys[3]}`;
|
|
506
|
+
}
|
|
507
|
+
return p;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Resuelve symlinks cuando el path (o un ancestro) existe. Sin eso, un link
|
|
511
|
+
* dentro del repo puede apuntar fuera y pasar `isInside` con solo `normalize`.
|
|
512
|
+
* @param {string} path
|
|
513
|
+
*/
|
|
514
|
+
function canonicalPath$1(path) {
|
|
515
|
+
try {
|
|
516
|
+
return realpathSync(path);
|
|
517
|
+
} catch {
|
|
518
|
+
const parts = [];
|
|
519
|
+
let cur = normalize(path);
|
|
520
|
+
for (;;) {
|
|
521
|
+
const parent = dirname(cur);
|
|
522
|
+
if (!parent || parent === cur) return normalize(path);
|
|
523
|
+
parts.unshift(basename(cur));
|
|
524
|
+
try {
|
|
525
|
+
return resolve(realpathSync(parent), ...parts);
|
|
526
|
+
} catch {
|
|
527
|
+
cur = parent;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
function isInside$1(child, root, platform) {
|
|
533
|
+
const c = normalize(canonicalPath$1(child)).replace(/[\\/]+$/, "");
|
|
534
|
+
const r = normalize(canonicalPath$1(root)).replace(/[\\/]+$/, "");
|
|
535
|
+
const [cc, rr] = platform === "win32" ? [c.toLowerCase(), r.toLowerCase()] : [c, r];
|
|
536
|
+
return cc === rr || cc.startsWith(`${rr}\\`) || cc.startsWith(`${rr}/`);
|
|
537
|
+
}
|
|
538
|
+
/** ¿El argumento parece un path que puede salir del repo? (absoluto, `~` o con `..`). */
|
|
539
|
+
function escapingPath(arg) {
|
|
540
|
+
return /^([a-zA-Z]:[\\/]|[\\/]|~)/.test(arg) || /(^|[\\/])\.\.([\\/]|$)/.test(arg);
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Parte el comando en cadenas (separadas por `&&`, `||`, `;`, saltos de línea) de pipelines (`|`) de argv.
|
|
544
|
+
* @param {string} command
|
|
545
|
+
* @returns {{ ok: true, pipelines: string[][][] } | { ok: false, reason: string }}
|
|
546
|
+
*/
|
|
547
|
+
function tokenizeShell(command) {
|
|
548
|
+
const src = String(command ?? "");
|
|
549
|
+
/** @type {string[][][]} */
|
|
550
|
+
const pipelines = [];
|
|
551
|
+
/** @type {string[][]} */
|
|
552
|
+
let pipeline = [];
|
|
553
|
+
/** @type {string[]} */
|
|
554
|
+
let words = [];
|
|
555
|
+
let word = "";
|
|
556
|
+
let inWord = false;
|
|
557
|
+
/** @type {null | "'" | '"'} */
|
|
558
|
+
let quote = null;
|
|
559
|
+
const endWord = () => {
|
|
560
|
+
if (inWord) words.push(word);
|
|
561
|
+
word = "";
|
|
562
|
+
inWord = false;
|
|
563
|
+
};
|
|
564
|
+
const endSegment = () => {
|
|
565
|
+
endWord();
|
|
566
|
+
if (words.length) pipeline.push(words);
|
|
567
|
+
words = [];
|
|
568
|
+
};
|
|
569
|
+
const endPipeline = () => {
|
|
570
|
+
endSegment();
|
|
571
|
+
if (pipeline.length) pipelines.push(pipeline);
|
|
572
|
+
pipeline = [];
|
|
573
|
+
};
|
|
574
|
+
const fail = (reason) => ({
|
|
575
|
+
ok: false,
|
|
576
|
+
reason
|
|
577
|
+
});
|
|
578
|
+
for (let i = 0; i < src.length; i++) {
|
|
579
|
+
const ch = src[i];
|
|
580
|
+
const next = src[i + 1];
|
|
581
|
+
if (quote === "'") {
|
|
582
|
+
if (ch === "'") quote = null;
|
|
583
|
+
else word += ch;
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
if (quote === "\"") {
|
|
587
|
+
if (ch === "\"") quote = null;
|
|
588
|
+
else if (ch === "`") return fail("backticks");
|
|
589
|
+
else if (ch === "$" && next === "(") return fail("sustitución $(…)");
|
|
590
|
+
else if (ch === "\\" && next !== void 0 && "\"\\$`".includes(next)) {
|
|
591
|
+
word += next;
|
|
592
|
+
i++;
|
|
593
|
+
} else word += ch;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (ch === "'" || ch === "\"") {
|
|
597
|
+
quote = ch;
|
|
598
|
+
inWord = true;
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
if (ch === "\\" && next !== void 0) {
|
|
602
|
+
word += next;
|
|
603
|
+
inWord = true;
|
|
604
|
+
i++;
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
if (ch === "`") return fail("backticks");
|
|
608
|
+
if (ch === "$" && next === "(") return fail("sustitución $(…)");
|
|
609
|
+
if (/\s/.test(ch) && ch !== "\n" && ch !== "\r") {
|
|
610
|
+
endWord();
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
if (ch === "\n" || ch === "\r" || ch === ";") {
|
|
614
|
+
endPipeline();
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
if (ch === "&" && next === "&" || ch === "|" && next === "|") {
|
|
618
|
+
endPipeline();
|
|
619
|
+
i++;
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
if (ch === "|") {
|
|
623
|
+
endSegment();
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
if (ch === ">" || ch === "&" && next === ">") {
|
|
627
|
+
if (ch === "&") i++;
|
|
628
|
+
const fd = inWord && /^[12]$/.test(word) ? word : "";
|
|
629
|
+
if (inWord && !fd) return fail("redirección");
|
|
630
|
+
word = "";
|
|
631
|
+
inWord = false;
|
|
632
|
+
let j = i + 1;
|
|
633
|
+
if (src[j] === ">") j++;
|
|
634
|
+
if (src[j] === "&" && /[12]/.test(src[j + 1] ?? "")) {
|
|
635
|
+
i = j + 1;
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
while (src[j] === " " || src[j] === " ") j++;
|
|
639
|
+
let target = "";
|
|
640
|
+
while (j < src.length && !/[\s;|&<>]/.test(src[j])) target += src[j++];
|
|
641
|
+
if (!DISCARD.has(target.toLowerCase())) return fail(`redirección a ${target || "archivo"}`);
|
|
642
|
+
i = j - 1;
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
if (ch === "<") return fail("redirección de entrada");
|
|
646
|
+
if (ch === "&") return fail("comando en segundo plano (&)");
|
|
647
|
+
word += ch;
|
|
648
|
+
inWord = true;
|
|
649
|
+
}
|
|
650
|
+
if (quote) return fail("comillas sin cerrar");
|
|
651
|
+
endPipeline();
|
|
652
|
+
return {
|
|
653
|
+
ok: true,
|
|
654
|
+
pipelines
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* @param {string} arg
|
|
659
|
+
* @param {{ repo: string, platform: NodeJS.Platform }} ctx
|
|
660
|
+
* @returns {ShellCheck}
|
|
661
|
+
*/
|
|
662
|
+
function checkPath(arg, { repo, platform }) {
|
|
663
|
+
if (DISCARD.has(arg.toLowerCase())) return { ok: true };
|
|
664
|
+
const native = toNativePath(arg.replace(/^~(?=[\\/]|$)/, "/__home__"), platform);
|
|
665
|
+
return isInside$1(isAbsolute(native) ? native : resolve(repo, native), repo, platform) ? { ok: true } : {
|
|
666
|
+
ok: false,
|
|
667
|
+
reason: `path fuera del repo: ${arg}`
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
/** Argumentos (y valores de `--opt=valor`) que parecen paths que salen del repo deben quedar adentro. */
|
|
671
|
+
function checkArgsInside(args, ctx) {
|
|
672
|
+
for (const arg of args) {
|
|
673
|
+
const value = arg.startsWith("-") ? arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : "" : arg;
|
|
674
|
+
if (value && escapingPath(value)) {
|
|
675
|
+
const res = checkPath(value, ctx);
|
|
676
|
+
if (!res.ok) return res;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return { ok: true };
|
|
680
|
+
}
|
|
681
|
+
/** @param {string[]} argv @returns {ShellCheck} */
|
|
682
|
+
function checkGit(argv, ctx) {
|
|
683
|
+
let i = 1;
|
|
684
|
+
while (argv[i]?.startsWith("-")) {
|
|
685
|
+
const flag = argv[i];
|
|
686
|
+
if (flag === "--no-pager" || flag === "-P") i++;
|
|
687
|
+
else if (flag === "-C") {
|
|
688
|
+
const res = checkPath(argv[i + 1] ?? "", ctx);
|
|
689
|
+
if (!res.ok) return res;
|
|
690
|
+
i += 2;
|
|
691
|
+
} else return {
|
|
692
|
+
ok: false,
|
|
693
|
+
reason: `opción global de git no permitida: ${flag}`
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
const sub = argv[i] ?? "";
|
|
697
|
+
const rest = argv.slice(i + 1);
|
|
698
|
+
const unsafe = rest.find((w) => GIT_UNSAFE_FLAG.test(w));
|
|
699
|
+
if (unsafe) return {
|
|
700
|
+
ok: false,
|
|
701
|
+
reason: `git ${sub} ${unsafe}`
|
|
702
|
+
};
|
|
703
|
+
if (GIT_READ.has(sub)) return { ok: true };
|
|
704
|
+
if (sub === "branch" && rest.every((w) => BRANCH_LIST_FLAGS.has(w))) return { ok: true };
|
|
705
|
+
if (sub === "remote" && (rest.length === 0 || rest.length === 1 && rest[0] === "-v" || rest[0] === "get-url")) return { ok: true };
|
|
706
|
+
if (sub === "config" && rest.some((w) => CONFIG_READ_FLAGS.has(w))) return { ok: true };
|
|
707
|
+
return {
|
|
708
|
+
ok: false,
|
|
709
|
+
reason: `git ${sub || "(sin subcomando)"} no es de solo lectura`
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
/** @param {string[]} argv @returns {ShellCheck} */
|
|
713
|
+
function checkReadCommand(name, argv, ctx) {
|
|
714
|
+
const args = argv.slice(1);
|
|
715
|
+
if (name === "sort" && args.some((a) => /^(-o|--output)/.test(a))) return {
|
|
716
|
+
ok: false,
|
|
717
|
+
reason: "sort -o escribe un archivo"
|
|
718
|
+
};
|
|
719
|
+
if (name === "uniq" && args.filter((a) => !a.startsWith("-")).length > 1) return {
|
|
720
|
+
ok: false,
|
|
721
|
+
reason: "uniq con archivo de salida"
|
|
722
|
+
};
|
|
723
|
+
if (name === "find" && args.some((a) => /^-(exec|execdir|ok|okdir|delete|fprint|fprint0|fprintf|fls)$/.test(a))) return {
|
|
724
|
+
ok: false,
|
|
725
|
+
reason: "find que ejecuta o escribe"
|
|
726
|
+
};
|
|
727
|
+
if (name === "rg" && args.some((a) => /^--pre(-glob)?(=|$)/.test(a))) return {
|
|
728
|
+
ok: false,
|
|
729
|
+
reason: "rg --pre ejecuta comandos"
|
|
730
|
+
};
|
|
731
|
+
if (name === "sed") {
|
|
732
|
+
if (!args.includes("-n") || args.some((a) => /^(-i|--in-place|-e|-f|--expression|--file)/.test(a))) return {
|
|
733
|
+
ok: false,
|
|
734
|
+
reason: "sed solo como `sed -n 'N,Mp'`"
|
|
735
|
+
};
|
|
736
|
+
const script = args.find((a) => !a.startsWith("-"));
|
|
737
|
+
if (!script || !/^\s*(\d+|\$)(\s*,\s*(\d+|\$))?\s*p\s*$/.test(script)) return {
|
|
738
|
+
ok: false,
|
|
739
|
+
reason: "sed solo como `sed -n 'N,Mp'`"
|
|
740
|
+
};
|
|
741
|
+
return checkArgsInside(args.filter((a) => a !== script), ctx);
|
|
742
|
+
}
|
|
743
|
+
if (TEXT_ARGS.has(name)) return { ok: true };
|
|
744
|
+
return checkArgsInside(args, ctx);
|
|
745
|
+
}
|
|
746
|
+
/** El validate del paquete (por npx o con el bin instalado), con el archivo como único argumento. */
|
|
747
|
+
function isValidate(argv) {
|
|
748
|
+
const line = argv.join(" ");
|
|
749
|
+
return /^(?:npx\s+(?:--yes\s+|-y\s+)?@jpkme\/diff-review(?:@[\w.^~-]+)?|diff-review)\s+validate\s+\S+$/.test(line);
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* `mkdir` / `md` / `New-Item -ItemType Directory` solo bajo `.diff-review/` del repo.
|
|
753
|
+
* @param {string[]} argv
|
|
754
|
+
* @param {{ repo: string, platform: NodeJS.Platform }} ctx
|
|
755
|
+
* @returns {ShellCheck | null} null = no es este caso
|
|
756
|
+
*/
|
|
757
|
+
function checkReviewDirMkdir(argv, ctx) {
|
|
758
|
+
const name = argv[0].toLowerCase();
|
|
759
|
+
const reviewRoot = resolve(ctx.repo, ".diff-review");
|
|
760
|
+
/** @param {string} arg */
|
|
761
|
+
const underReviewDir = (arg) => {
|
|
762
|
+
const native = toNativePath(String(arg || "").replace(/^~(?=[\\/]|$)/, "/__home__"), ctx.platform);
|
|
763
|
+
return isInside$1(isAbsolute(native) ? native : resolve(ctx.repo, native), reviewRoot, ctx.platform);
|
|
764
|
+
};
|
|
765
|
+
if (name === "mkdir" || name === "md") {
|
|
766
|
+
const paths = argv.slice(1).filter((a) => !a.startsWith("-"));
|
|
767
|
+
if (!paths.length || !paths.every(underReviewDir)) return {
|
|
768
|
+
ok: false,
|
|
769
|
+
reason: "mkdir solo permitido bajo .diff-review/"
|
|
770
|
+
};
|
|
771
|
+
return { ok: true };
|
|
772
|
+
}
|
|
773
|
+
if (name === "new-item") {
|
|
774
|
+
const joined = argv.join(" ").toLowerCase();
|
|
775
|
+
if (!/-itemtype\s+directory\b/.test(joined) && !/\b-type\s+directory\b/.test(joined)) return null;
|
|
776
|
+
const paths = argv.slice(1).filter((a) => !a.startsWith("-") && !/^(directory|file)$/i.test(a));
|
|
777
|
+
if (!paths.length || !paths.every(underReviewDir)) return {
|
|
778
|
+
ok: false,
|
|
779
|
+
reason: "New-Item Directory solo permitido bajo .diff-review/"
|
|
780
|
+
};
|
|
781
|
+
return { ok: true };
|
|
782
|
+
}
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* @param {string} command
|
|
787
|
+
* @param {{ repo?: string, platform?: NodeJS.Platform }} [opts]
|
|
788
|
+
* @returns {ShellCheck}
|
|
789
|
+
*/
|
|
790
|
+
function checkShell(command, { repo, platform = process.platform } = {}) {
|
|
791
|
+
const parsed = tokenizeShell(command);
|
|
792
|
+
if (!parsed.ok) return parsed;
|
|
793
|
+
if (!parsed.pipelines.length) return {
|
|
794
|
+
ok: false,
|
|
795
|
+
reason: "comando vacío"
|
|
796
|
+
};
|
|
797
|
+
const ctx = {
|
|
798
|
+
repo: repo || process.cwd(),
|
|
799
|
+
platform
|
|
800
|
+
};
|
|
801
|
+
for (const pipeline of parsed.pipelines) for (const argv of pipeline) {
|
|
802
|
+
const name = argv[0].toLowerCase();
|
|
803
|
+
if (argv[0].includes("=")) return {
|
|
804
|
+
ok: false,
|
|
805
|
+
reason: `variables de entorno en el comando: ${argv[0]}`
|
|
806
|
+
};
|
|
807
|
+
/** @type {ShellCheck} */
|
|
808
|
+
let res;
|
|
809
|
+
if (name === "cd" || name === "set-location") res = repo && argv[1] ? checkPath(argv[1], ctx) : {
|
|
810
|
+
ok: false,
|
|
811
|
+
reason: "cd fuera del repo"
|
|
812
|
+
};
|
|
813
|
+
else if (name === "git") res = checkGit(argv, ctx);
|
|
814
|
+
else if (name === "npx" || name === "diff-review") res = isValidate(argv) ? { ok: true } : {
|
|
815
|
+
ok: false,
|
|
816
|
+
reason: `${argv.join(" ")}`
|
|
817
|
+
};
|
|
818
|
+
else if (READ_COMMANDS.has(name)) res = checkReadCommand(name, argv, ctx);
|
|
819
|
+
else res = checkReviewDirMkdir(argv, ctx) ?? {
|
|
820
|
+
ok: false,
|
|
821
|
+
reason: `${argv[0]} no está permitido`
|
|
822
|
+
};
|
|
823
|
+
if (!res.ok) return res;
|
|
824
|
+
}
|
|
825
|
+
return { ok: true };
|
|
826
|
+
}
|
|
827
|
+
//#endregion
|
|
828
|
+
//#region ../lib/acp-policy.mjs
|
|
829
|
+
/**
|
|
830
|
+
* Qué puede hacer el agente cuando corre por ACP. Cada `session/request_permission` pasa por acá: no hay nadie
|
|
831
|
+
* mirando la terminal para aprobar, así que la política es una allowlist (leer, comandos de solo lectura y escribir
|
|
832
|
+
* el JSON de salida en `.diff-review/`) y todo lo demás se rechaza.
|
|
833
|
+
*
|
|
834
|
+
* Ojo: el agente solo pide permiso para lo que *su* configuración no aprueba sola (p. ej. Claude Code aplica la
|
|
835
|
+
* allowlist de `~/.claude/settings.json` antes de preguntar). Esto acota lo que pide, no lo que el usuario ya
|
|
836
|
+
* habilitó en su agente.
|
|
837
|
+
*/
|
|
838
|
+
/**
|
|
839
|
+
* @typedef {"review" | "ask" | "edit"} PolicyMode
|
|
840
|
+
* review = generar el JSON; ask = preguntas (no puede escribir nada); edit = modificar el JSON.
|
|
841
|
+
* @typedef {{ repo: string, outputFilename: string, mode: PolicyMode }} PolicyContext
|
|
842
|
+
* @typedef {{
|
|
843
|
+
* kind?: string | null,
|
|
844
|
+
* title?: string | null,
|
|
845
|
+
* rawInput?: unknown,
|
|
846
|
+
* locations?: { path: string }[] | null,
|
|
847
|
+
* content?: { type: string, path?: string }[] | null,
|
|
848
|
+
* }} ToolCallLike
|
|
849
|
+
* @typedef {{ allow: boolean, reason: string }} Decision
|
|
850
|
+
*/
|
|
851
|
+
/** @param {string} a @param {string} b */
|
|
852
|
+
function samePath(a, b, platform = process.platform) {
|
|
853
|
+
const na = normalize(a);
|
|
854
|
+
const nb = normalize(b);
|
|
855
|
+
return platform === "win32" ? na.toLowerCase() === nb.toLowerCase() : na === nb;
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Resuelve symlinks cuando el path (o un ancestro) existe. Sin eso, un link dentro del repo
|
|
859
|
+
* puede apuntar fuera y pasar `isInside` con solo `resolve`.
|
|
860
|
+
* @param {string} path
|
|
861
|
+
*/
|
|
862
|
+
function canonicalPath(path) {
|
|
863
|
+
try {
|
|
864
|
+
return realpathSync(path);
|
|
865
|
+
} catch {
|
|
866
|
+
const parts = [];
|
|
867
|
+
let cur = normalize(path);
|
|
868
|
+
for (;;) {
|
|
869
|
+
const parent = dirname(cur);
|
|
870
|
+
if (!parent || parent === cur) return normalize(path);
|
|
871
|
+
parts.unshift(basename(cur));
|
|
872
|
+
try {
|
|
873
|
+
return resolve(realpathSync(parent), ...parts);
|
|
874
|
+
} catch {
|
|
875
|
+
cur = parent;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
/** @param {string} child @param {string} root */
|
|
881
|
+
function isInside(child, root, platform = process.platform) {
|
|
882
|
+
const c = normalize(canonicalPath(child));
|
|
883
|
+
const r = normalize(canonicalPath(root)).replace(/[\\/]+$/, "");
|
|
884
|
+
const [cc, rr] = platform === "win32" ? [c.toLowerCase(), r.toLowerCase()] : [c, r];
|
|
885
|
+
return cc === rr || cc.startsWith(`${rr}\\`) || cc.startsWith(`${rr}/`);
|
|
886
|
+
}
|
|
887
|
+
/** Paths que toca la tool call (`locations`, diffs de `content` y los campos típicos de `rawInput`). */
|
|
888
|
+
function toolCallPaths(toolCall, repo) {
|
|
889
|
+
const raw = toolCall.rawInput && typeof toolCall.rawInput === "object" ? toolCall.rawInput : {};
|
|
890
|
+
const candidates = [
|
|
891
|
+
...(toolCall.locations ?? []).map((l) => l?.path),
|
|
892
|
+
...(toolCall.content ?? []).map((c) => c?.type === "diff" ? c.path : void 0),
|
|
893
|
+
raw.file_path,
|
|
894
|
+
raw.path,
|
|
895
|
+
raw.filePath,
|
|
896
|
+
raw.notebook_path
|
|
897
|
+
];
|
|
898
|
+
const out = [];
|
|
899
|
+
for (const c of candidates) {
|
|
900
|
+
if (typeof c !== "string" || !c.trim()) continue;
|
|
901
|
+
const abs = isAbsolute(c) ? c : resolve(repo, c);
|
|
902
|
+
if (!out.some((p) => samePath(p, abs))) out.push(abs);
|
|
903
|
+
}
|
|
904
|
+
return out;
|
|
905
|
+
}
|
|
906
|
+
/** @param {unknown} rawInput */
|
|
907
|
+
function commandOf(rawInput) {
|
|
908
|
+
if (!rawInput || typeof rawInput !== "object") return "";
|
|
909
|
+
const cmd = rawInput.command;
|
|
910
|
+
if (Array.isArray(cmd)) return cmd.map(String).join(" ");
|
|
911
|
+
return typeof cmd === "string" ? cmd : "";
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Comando de shell que la política evalúa (rawInput, backticks o título entero).
|
|
915
|
+
* Cursor a veces envuelve el script con `@…@` en el título.
|
|
916
|
+
* @param {ToolCallLike} toolCall
|
|
917
|
+
*/
|
|
918
|
+
function toolCallCommand(toolCall) {
|
|
919
|
+
const fromRaw = commandOf(toolCall.rawInput).trim();
|
|
920
|
+
if (fromRaw) return fromRaw;
|
|
921
|
+
const title = String(toolCall.title ?? "").trim();
|
|
922
|
+
const tick = /^`([\s\S]+)`$/.exec(title);
|
|
923
|
+
if (tick) return tick[1].trim();
|
|
924
|
+
const at = /^@\s*([\s\S]*?)\s*@$/.exec(title);
|
|
925
|
+
if (at) return at[1].trim();
|
|
926
|
+
return title;
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Etiqueta corta del agente si el título no es el comando mismo.
|
|
930
|
+
* @param {ToolCallLike} toolCall
|
|
931
|
+
* @param {string} [command]
|
|
932
|
+
*/
|
|
933
|
+
function toolCallLabel(toolCall, command = toolCallCommand(toolCall)) {
|
|
934
|
+
const title = String(toolCall.title ?? "").trim();
|
|
935
|
+
if (!title) return "";
|
|
936
|
+
if (/^`[\s\S]+`$/.test(title) || /^@\s*[\s\S]*?\s*@$/.test(title)) return "";
|
|
937
|
+
const norm = (s) => s.replace(/\s+/g, " ").trim();
|
|
938
|
+
if (command && norm(title) === norm(command)) return "";
|
|
939
|
+
if (command && command.length >= 24 && title.includes(command.slice(0, 24))) return "";
|
|
940
|
+
return title;
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Motivo de denegación legible en la UI de permisos.
|
|
944
|
+
* @param {string} reason
|
|
945
|
+
*/
|
|
946
|
+
function humanizePolicyReason(reason) {
|
|
947
|
+
const r = String(reason || "").trim();
|
|
948
|
+
if (!r) return "La política bloqueó este pedido.";
|
|
949
|
+
let m;
|
|
950
|
+
if (m = /^(.+?) no está permitido$/.exec(r)) return `La política bloqueó «${m[1]}» (no está en la allowlist de comandos).`;
|
|
951
|
+
if (m = /^variables de entorno en el comando:\s*(.*)$/s.exec(r)) return `La política interpretó parte del comando como variable de entorno (${String(m[1] || "?").trim().slice(0, 40)}) y lo bloqueó.`;
|
|
952
|
+
if (m = /^path fuera del repo:\s*(.*)$/s.exec(r)) return `El path queda fuera del repo: ${m[1]}`;
|
|
953
|
+
if (m = /^lectura fuera del repo:\s*(.*)$/s.exec(r)) return `Lectura fuera del repo: ${m[1]}`;
|
|
954
|
+
if (m = /^opción global de git no permitida:\s*(.*)$/s.exec(r)) return `Opción de git no permitida: ${m[1]}`;
|
|
955
|
+
if (r.startsWith("git ")) return `Comando git no permitido por la política: ${r}`;
|
|
956
|
+
if (r.startsWith("solo se puede escribir") || r.includes("sin path") || r.startsWith("modo pregunta")) return r;
|
|
957
|
+
return `La política bloqueó el pedido: ${r}`;
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* @param {ToolCallLike} toolCall
|
|
961
|
+
* @param {PolicyContext} ctx
|
|
962
|
+
* @returns {Decision}
|
|
963
|
+
*/
|
|
964
|
+
function decidePermission(toolCall, { repo, outputFilename, mode }) {
|
|
965
|
+
const kind = toolCall.kind ?? "other";
|
|
966
|
+
const paths = toolCallPaths(toolCall, repo);
|
|
967
|
+
if (kind === "read" || kind === "search") {
|
|
968
|
+
if (!paths.length) return {
|
|
969
|
+
allow: false,
|
|
970
|
+
reason: `${kind === "search" ? "búsqueda" : "lectura"} sin path`
|
|
971
|
+
};
|
|
972
|
+
const outside = paths.find((p) => !isInside(p, repo));
|
|
973
|
+
return outside ? {
|
|
974
|
+
allow: false,
|
|
975
|
+
reason: `lectura fuera del repo: ${outside}`
|
|
976
|
+
} : {
|
|
977
|
+
allow: true,
|
|
978
|
+
reason: "lectura"
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
if (kind === "think") return {
|
|
982
|
+
allow: true,
|
|
983
|
+
reason: "pensar"
|
|
984
|
+
};
|
|
985
|
+
if (kind === "execute") {
|
|
986
|
+
const check = checkShell(toolCallCommand(toolCall), { repo });
|
|
987
|
+
return check.ok ? {
|
|
988
|
+
allow: true,
|
|
989
|
+
reason: "comando de solo lectura"
|
|
990
|
+
} : {
|
|
991
|
+
allow: false,
|
|
992
|
+
reason: check.reason
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
if (kind === "edit" || kind === "delete") {
|
|
996
|
+
if (mode === "ask") return {
|
|
997
|
+
allow: false,
|
|
998
|
+
reason: "modo pregunta: no se edita nada"
|
|
999
|
+
};
|
|
1000
|
+
if (!paths.length) return {
|
|
1001
|
+
allow: false,
|
|
1002
|
+
reason: `${kind === "delete" ? "borrado" : "edición"} sin path`
|
|
1003
|
+
};
|
|
1004
|
+
const target = resolve(repo, outputRelPath(outputFilename));
|
|
1005
|
+
const outside = paths.find((p) => !samePath(canonicalPath(p), canonicalPath(target)));
|
|
1006
|
+
return outside ? {
|
|
1007
|
+
allow: false,
|
|
1008
|
+
reason: `solo se puede escribir ${outputRelPath(outputFilename)} (no ${outside})`
|
|
1009
|
+
} : {
|
|
1010
|
+
allow: true,
|
|
1011
|
+
reason: `escritura de ${outputRelPath(outputFilename)}`
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
return {
|
|
1015
|
+
allow: false,
|
|
1016
|
+
reason: `${kind} no permitido: ${toolCall.title ?? "?"}`
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Respuesta ACP para una decisión del usuario / política.
|
|
1021
|
+
* - allow + once → `allow_once` (la política se vuelve a evaluar en el próximo pedido).
|
|
1022
|
+
* - allow + always → `allow_always` si el agente la ofrece; si no, cae a `allow_once`.
|
|
1023
|
+
* @param {{ optionId: string, kind: string }[]} options
|
|
1024
|
+
* @param {boolean} allow
|
|
1025
|
+
* @param {{ always?: boolean }} [opts]
|
|
1026
|
+
*/
|
|
1027
|
+
function permissionOutcome(options, allow, opts = {}) {
|
|
1028
|
+
const always = Boolean(opts.always);
|
|
1029
|
+
const option = (allow ? always ? ["allow_always", "allow_once"] : ["allow_once"] : ["reject_once", "reject_always"]).map((kind) => options.find((o) => o.kind === kind)).find(Boolean);
|
|
1030
|
+
return option ? { outcome: {
|
|
1031
|
+
outcome: "selected",
|
|
1032
|
+
optionId: option.optionId
|
|
1033
|
+
} } : { outcome: { outcome: "cancelled" } };
|
|
1034
|
+
}
|
|
1035
|
+
//#endregion
|
|
1036
|
+
//#region ../lib/agent-login.mjs
|
|
1037
|
+
/**
|
|
1038
|
+
* Login de agentes ACP: mensajes claros + abrir una terminal interactiva cuando se pueda.
|
|
1039
|
+
*/
|
|
1040
|
+
/**
|
|
1041
|
+
* @typedef {{ bin: string, args: string[], display: string }} LoginCommand
|
|
1042
|
+
*/
|
|
1043
|
+
/**
|
|
1044
|
+
* Comando de login interactivo por agente (null = no hay CLI de login, p. ej. Cursor app).
|
|
1045
|
+
* @param {string} agentId
|
|
1046
|
+
* @returns {LoginCommand | null}
|
|
1047
|
+
*/
|
|
1048
|
+
function loginCommand(agentId) {
|
|
1049
|
+
switch (agentId) {
|
|
1050
|
+
case "claude": return {
|
|
1051
|
+
bin: "claude",
|
|
1052
|
+
args: ["/login"],
|
|
1053
|
+
display: "claude /login"
|
|
1054
|
+
};
|
|
1055
|
+
case "copilot": return {
|
|
1056
|
+
bin: "gh",
|
|
1057
|
+
args: ["auth", "login"],
|
|
1058
|
+
display: "gh auth login"
|
|
1059
|
+
};
|
|
1060
|
+
case "codex": return {
|
|
1061
|
+
bin: "codex",
|
|
1062
|
+
args: ["login"],
|
|
1063
|
+
display: "codex login"
|
|
1064
|
+
};
|
|
1065
|
+
case "gemini": return {
|
|
1066
|
+
bin: "gemini",
|
|
1067
|
+
args: [],
|
|
1068
|
+
display: "gemini"
|
|
1069
|
+
};
|
|
1070
|
+
default: return null;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
/** @param {string} text */
|
|
1074
|
+
function looksLikeAuthFailure(text) {
|
|
1075
|
+
const s = String(text || "").toLowerCase();
|
|
1076
|
+
return /(not\s+)?auth|login|unauthor|not logged|sign[\s-]?in|credentials|token.*(invalid|expired|missing)|please\s+run/.test(s);
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Mensaje en español si el agente murió / falló por falta de login.
|
|
1080
|
+
* @param {string} agentId
|
|
1081
|
+
* @param {string} agentLabel
|
|
1082
|
+
* @param {string} detail
|
|
1083
|
+
* @returns {string | null}
|
|
1084
|
+
*/
|
|
1085
|
+
function authFailureMessage$1(agentId, agentLabel, detail) {
|
|
1086
|
+
if (!looksLikeAuthFailure(detail)) return null;
|
|
1087
|
+
const cmd = loginCommand(agentId);
|
|
1088
|
+
if (agentId === "cursor") return `${agentLabel} no está autenticado. Iniciá sesión en la app de Cursor y reintentá.`;
|
|
1089
|
+
if (cmd) return `${agentLabel} no está autenticado. Corré \`${cmd.display}\` en una terminal y reintentá.`;
|
|
1090
|
+
return `${agentLabel} no está autenticado. Revisá el login del agente y reintentá.`;
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* Abre una terminal nueva con el login del agente (Windows / macOS / Linux).
|
|
1094
|
+
* @param {string} agentId
|
|
1095
|
+
* @returns {{ display: string }}
|
|
1096
|
+
*/
|
|
1097
|
+
function openLoginTerminal(agentId) {
|
|
1098
|
+
const cmd = loginCommand(agentId);
|
|
1099
|
+
if (!cmd) throw new Error(agentId === "cursor" ? "Cursor se autentica desde la app de escritorio, no desde una terminal." : "Este agente no tiene un comando de login conocido.");
|
|
1100
|
+
const platform = process.platform;
|
|
1101
|
+
if (platform === "win32") spawn("cmd.exe", [
|
|
1102
|
+
"/c",
|
|
1103
|
+
"start",
|
|
1104
|
+
`${cmd.bin} login`,
|
|
1105
|
+
"cmd.exe",
|
|
1106
|
+
"/k",
|
|
1107
|
+
cmd.bin,
|
|
1108
|
+
...cmd.args
|
|
1109
|
+
], {
|
|
1110
|
+
detached: true,
|
|
1111
|
+
stdio: "ignore",
|
|
1112
|
+
windowsHide: false
|
|
1113
|
+
}).unref();
|
|
1114
|
+
else if (platform === "darwin") spawn("osascript", ["-e", `tell application "Terminal" to do script ${JSON.stringify(`${cmd.bin} ${cmd.args.join(" ")}`.trim())}`], {
|
|
1115
|
+
detached: true,
|
|
1116
|
+
stdio: "ignore"
|
|
1117
|
+
}).unref();
|
|
1118
|
+
else {
|
|
1119
|
+
const line = `${cmd.bin} ${cmd.args.join(" ")}`.trim();
|
|
1120
|
+
const tried = [
|
|
1121
|
+
["x-terminal-emulator", [
|
|
1122
|
+
"-e",
|
|
1123
|
+
"bash",
|
|
1124
|
+
"-lc",
|
|
1125
|
+
`${line}; exec bash`
|
|
1126
|
+
]],
|
|
1127
|
+
["gnome-terminal", [
|
|
1128
|
+
"--",
|
|
1129
|
+
"bash",
|
|
1130
|
+
"-lc",
|
|
1131
|
+
`${line}; exec bash`
|
|
1132
|
+
]],
|
|
1133
|
+
["konsole", [
|
|
1134
|
+
"-e",
|
|
1135
|
+
"bash",
|
|
1136
|
+
"-lc",
|
|
1137
|
+
`${line}; exec bash`
|
|
1138
|
+
]],
|
|
1139
|
+
["xterm", [
|
|
1140
|
+
"-e",
|
|
1141
|
+
"bash",
|
|
1142
|
+
"-lc",
|
|
1143
|
+
`${line}; exec bash`
|
|
1144
|
+
]]
|
|
1145
|
+
];
|
|
1146
|
+
let ok = false;
|
|
1147
|
+
for (const [bin, args] of tried) try {
|
|
1148
|
+
spawn(bin, args, {
|
|
1149
|
+
detached: true,
|
|
1150
|
+
stdio: "ignore"
|
|
1151
|
+
}).unref();
|
|
1152
|
+
ok = true;
|
|
1153
|
+
break;
|
|
1154
|
+
} catch {}
|
|
1155
|
+
if (!ok) throw new Error(`No pude abrir una terminal. Corré a mano: ${cmd.display}`);
|
|
1156
|
+
}
|
|
1157
|
+
return { display: cmd.display };
|
|
1158
|
+
}
|
|
1159
|
+
/** @returns {string} */
|
|
1160
|
+
function modelsCachePath(home = homedir()) {
|
|
1161
|
+
return join(home, ".diff-review", "models-cache.json");
|
|
1162
|
+
}
|
|
1163
|
+
/**
|
|
1164
|
+
* @param {string} agentId
|
|
1165
|
+
* @param {{ home?: string, now?: number, ttlMs?: number }} [opts]
|
|
1166
|
+
* @returns {{ models: { value: string, name: string }[], currentModel: string, fetchedAt: string } | null}
|
|
1167
|
+
*/
|
|
1168
|
+
function readModelsCache(agentId, opts = {}) {
|
|
1169
|
+
const id = String(agentId || "").trim();
|
|
1170
|
+
if (!id) return null;
|
|
1171
|
+
const path = modelsCachePath(opts.home);
|
|
1172
|
+
let root;
|
|
1173
|
+
try {
|
|
1174
|
+
root = JSON.parse(readFileSync(path, "utf8"));
|
|
1175
|
+
} catch {
|
|
1176
|
+
return null;
|
|
1177
|
+
}
|
|
1178
|
+
const entry = root?.agents?.[id];
|
|
1179
|
+
if (!entry || !Array.isArray(entry.models)) return null;
|
|
1180
|
+
const fetchedAt = Date.parse(String(entry.fetchedAt || ""));
|
|
1181
|
+
if (!Number.isFinite(fetchedAt)) return null;
|
|
1182
|
+
const ttl = opts.ttlMs ?? 864e5;
|
|
1183
|
+
if ((opts.now ?? Date.now()) - fetchedAt > ttl) return null;
|
|
1184
|
+
return {
|
|
1185
|
+
models: entry.models.filter((m) => m && typeof m.value === "string").map((m) => ({
|
|
1186
|
+
value: m.value,
|
|
1187
|
+
name: String(m.name ?? m.value)
|
|
1188
|
+
})),
|
|
1189
|
+
currentModel: String(entry.currentModel ?? ""),
|
|
1190
|
+
fetchedAt: new Date(fetchedAt).toISOString()
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* @param {string} agentId
|
|
1195
|
+
* @param {{ models: { value: string, name: string }[], currentModel?: string }} data
|
|
1196
|
+
* @param {{ home?: string, now?: number }} [opts]
|
|
1197
|
+
*/
|
|
1198
|
+
function writeModelsCache(agentId, data, opts = {}) {
|
|
1199
|
+
const id = String(agentId || "").trim();
|
|
1200
|
+
if (!id || !Array.isArray(data?.models) || !data.models.length) return;
|
|
1201
|
+
const path = modelsCachePath(opts.home);
|
|
1202
|
+
let root = {
|
|
1203
|
+
version: 1,
|
|
1204
|
+
agents: {}
|
|
1205
|
+
};
|
|
1206
|
+
try {
|
|
1207
|
+
if (existsSync(path)) root = {
|
|
1208
|
+
...root,
|
|
1209
|
+
...JSON.parse(readFileSync(path, "utf8"))
|
|
1210
|
+
};
|
|
1211
|
+
} catch {}
|
|
1212
|
+
if (!root.agents || typeof root.agents !== "object") root.agents = {};
|
|
1213
|
+
root.agents[id] = {
|
|
1214
|
+
fetchedAt: new Date(opts.now ?? Date.now()).toISOString(),
|
|
1215
|
+
currentModel: String(data.currentModel ?? ""),
|
|
1216
|
+
models: data.models.map((m) => ({
|
|
1217
|
+
value: String(m.value),
|
|
1218
|
+
name: String(m.name ?? m.value)
|
|
1219
|
+
}))
|
|
1220
|
+
};
|
|
1221
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
1222
|
+
writeFileSync(path, `${JSON.stringify(root, null, 2)}\n`);
|
|
1223
|
+
}
|
|
1224
|
+
//#endregion
|
|
1225
|
+
//#region ../lib/acp.mjs
|
|
1226
|
+
/**
|
|
1227
|
+
* Sesión con un agente por ACP (Agent Client Protocol): JSON-RPC por stdin/stdout del proceso del agente.
|
|
1228
|
+
*
|
|
1229
|
+
* A diferencia de `runAgent` (una vuelta headless), el proceso queda vivo: la misma sesión sirve para generar la
|
|
1230
|
+
* review y después para las preguntas y los cambios que se le pidan desde la web. Cada pedido de permiso pasa por
|
|
1231
|
+
* `decide` (ver `acp-policy.mjs`): no hay nadie mirando una terminal para aprobar.
|
|
1232
|
+
*/
|
|
1233
|
+
/** El agente tiene un modo ACP declarado (no todos los agentes lo traen). */
|
|
1234
|
+
function supportsAcp(agent) {
|
|
1235
|
+
return typeof agent?.def?.acp === "function";
|
|
1236
|
+
}
|
|
1237
|
+
/**
|
|
1238
|
+
* Opción de modelo (`configOptions` con id/categoría "model") de la respuesta de `session/new` o `session/load`.
|
|
1239
|
+
* @param {{ configOptions?: any[] | null }} response
|
|
1240
|
+
*/
|
|
1241
|
+
function modelConfig(response) {
|
|
1242
|
+
const option = (response?.configOptions ?? []).find((o) => o?.category === "model" || o?.id === "model");
|
|
1243
|
+
if (!option || option.type !== "select") return null;
|
|
1244
|
+
const flat = (option.options ?? []).flatMap((o) => Array.isArray(o?.options) ? o.options : [o]);
|
|
1245
|
+
return {
|
|
1246
|
+
configId: String(option.id),
|
|
1247
|
+
current: String(option.currentValue ?? ""),
|
|
1248
|
+
options: flat.filter((o, i) => o && typeof o.value === "string" && flat.findIndex((x) => x?.value === o.value) === i).map((o) => ({
|
|
1249
|
+
value: o.value,
|
|
1250
|
+
name: String(o.name ?? o.value)
|
|
1251
|
+
}))
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
/** Tiempo máximo para initialize + session/new|load (+ set model). */
|
|
1255
|
+
var ACP_START_TIMEOUT_MS = 9e4;
|
|
1256
|
+
/**
|
|
1257
|
+
* Lanza el agente en modo ACP, negocia y abre (o retoma) una sesión.
|
|
1258
|
+
* @param {DetectedAgent} agent
|
|
1259
|
+
* @param {AcpSessionOptions} opts
|
|
1260
|
+
* @returns {Promise<AcpSession>}
|
|
1261
|
+
*/
|
|
1262
|
+
async function startAcpSession(agent, opts) {
|
|
1263
|
+
if (!supportsAcp(agent)) throw new Error(`${agent.def.label} no tiene modo ACP.`);
|
|
1264
|
+
const { file, args, env, sessionMeta } = agent.def.acp({ path: agent.path });
|
|
1265
|
+
const child = spawnCommand(file, args, {
|
|
1266
|
+
cwd: opts.cwd,
|
|
1267
|
+
stdio: [
|
|
1268
|
+
"pipe",
|
|
1269
|
+
"pipe",
|
|
1270
|
+
"pipe"
|
|
1271
|
+
],
|
|
1272
|
+
...env ? { env: {
|
|
1273
|
+
...process.env,
|
|
1274
|
+
...env
|
|
1275
|
+
} } : {}
|
|
1276
|
+
});
|
|
1277
|
+
let stderr = "";
|
|
1278
|
+
child.stderr?.on("data", (chunk) => {
|
|
1279
|
+
stderr = (stderr + String(chunk)).slice(-4e3);
|
|
1280
|
+
});
|
|
1281
|
+
child.stdin?.on("error", () => {});
|
|
1282
|
+
const conn = acp.client({ name: "diff-review" }).onRequest(acp.methods.client.session.requestPermission, async (ctx) => {
|
|
1283
|
+
const toolCall = ctx.params.toolCall;
|
|
1284
|
+
const decision = safeDecide(opts.decide, toolCall);
|
|
1285
|
+
if (decision.allow) {
|
|
1286
|
+
opts.onPermission?.(toolCall, decision);
|
|
1287
|
+
return permissionOutcome(ctx.params.options, true);
|
|
1288
|
+
}
|
|
1289
|
+
let allow = false;
|
|
1290
|
+
let always = false;
|
|
1291
|
+
if (opts.onDeniedPermission) try {
|
|
1292
|
+
const answer = await opts.onDeniedPermission(toolCall, decision);
|
|
1293
|
+
if (answer && typeof answer === "object") {
|
|
1294
|
+
allow = Boolean(answer.allow);
|
|
1295
|
+
always = Boolean(answer.always);
|
|
1296
|
+
} else allow = Boolean(answer);
|
|
1297
|
+
} catch {
|
|
1298
|
+
allow = false;
|
|
1299
|
+
always = false;
|
|
1300
|
+
}
|
|
1301
|
+
const final = {
|
|
1302
|
+
allow,
|
|
1303
|
+
reason: allow ? always ? "permitido siempre" : "permitido por el usuario" : decision.reason
|
|
1304
|
+
};
|
|
1305
|
+
opts.onPermission?.(toolCall, final);
|
|
1306
|
+
return permissionOutcome(ctx.params.options, allow, { always });
|
|
1307
|
+
}).onNotification(acp.methods.client.session.update, (ctx) => {
|
|
1308
|
+
opts.onUpdate?.(ctx.params.update);
|
|
1309
|
+
}).connect(acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout)));
|
|
1310
|
+
new Promise((resolve) => {
|
|
1311
|
+
child.once("error", (err) => {
|
|
1312
|
+
stderr += `\n${err.message}`;
|
|
1313
|
+
resolve(null);
|
|
1314
|
+
});
|
|
1315
|
+
child.once("exit", (code) => resolve(code));
|
|
1316
|
+
}).then((code) => {
|
|
1317
|
+
conn.close(/* @__PURE__ */ new Error(`${agent.def.label} terminó (código ${code ?? "?"}).`));
|
|
1318
|
+
opts.onExit?.({
|
|
1319
|
+
code,
|
|
1320
|
+
stderr
|
|
1321
|
+
});
|
|
1322
|
+
});
|
|
1323
|
+
const close = () => {
|
|
1324
|
+
conn.close();
|
|
1325
|
+
if (child.exitCode == null) child.kill();
|
|
1326
|
+
};
|
|
1327
|
+
const timeoutMs = Math.max(15e3, Number(opts.timeoutMs) || ACP_START_TIMEOUT_MS);
|
|
1328
|
+
let timedOut = false;
|
|
1329
|
+
const timer = setTimeout(() => {
|
|
1330
|
+
timedOut = true;
|
|
1331
|
+
close();
|
|
1332
|
+
}, timeoutMs);
|
|
1333
|
+
try {
|
|
1334
|
+
const init = await conn.agent.request(acp.methods.agent.initialize, {
|
|
1335
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
1336
|
+
clientCapabilities: {
|
|
1337
|
+
fs: {
|
|
1338
|
+
readTextFile: false,
|
|
1339
|
+
writeTextFile: false
|
|
1340
|
+
},
|
|
1341
|
+
terminal: false
|
|
1342
|
+
},
|
|
1343
|
+
clientInfo: {
|
|
1344
|
+
name: "diff-review",
|
|
1345
|
+
version: "0"
|
|
1346
|
+
}
|
|
1347
|
+
});
|
|
1348
|
+
/** Métodos donde el agente maneja el login por `authenticate` (no terminal interactiva). */
|
|
1349
|
+
const agentAuthMethods = (Array.isArray(init.authMethods) ? init.authMethods : []).filter((m) => m && (!m.type || m.type === "agent") && m.id);
|
|
1350
|
+
/** Si session/new falla por auth, pedimos `authenticate` una vez y reintentamos. */
|
|
1351
|
+
const tryAuthenticate = async () => {
|
|
1352
|
+
for (const method of agentAuthMethods) try {
|
|
1353
|
+
await conn.agent.request(acp.methods.agent.authenticate, { methodId: method.id });
|
|
1354
|
+
return true;
|
|
1355
|
+
} catch {}
|
|
1356
|
+
return false;
|
|
1357
|
+
};
|
|
1358
|
+
const openNewSession = async () => {
|
|
1359
|
+
try {
|
|
1360
|
+
return await conn.agent.request(acp.methods.agent.session.new, {
|
|
1361
|
+
cwd: opts.cwd,
|
|
1362
|
+
mcpServers: [],
|
|
1363
|
+
...sessionMeta ? { _meta: sessionMeta } : {}
|
|
1364
|
+
});
|
|
1365
|
+
} catch (err) {
|
|
1366
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1367
|
+
if (agentAuthMethods.length && looksLikeAuthFailure(msg) && await tryAuthenticate()) return await conn.agent.request(acp.methods.agent.session.new, {
|
|
1368
|
+
cwd: opts.cwd,
|
|
1369
|
+
mcpServers: [],
|
|
1370
|
+
...sessionMeta ? { _meta: sessionMeta } : {}
|
|
1371
|
+
});
|
|
1372
|
+
throw err;
|
|
1373
|
+
}
|
|
1374
|
+
};
|
|
1375
|
+
let sessionId = "";
|
|
1376
|
+
let response;
|
|
1377
|
+
let resumed = false;
|
|
1378
|
+
if (opts.resumeSessionId && init.agentCapabilities?.loadSession) try {
|
|
1379
|
+
response = await conn.agent.request(acp.methods.agent.session.load, {
|
|
1380
|
+
sessionId: opts.resumeSessionId,
|
|
1381
|
+
cwd: opts.cwd,
|
|
1382
|
+
mcpServers: [],
|
|
1383
|
+
...sessionMeta ? { _meta: sessionMeta } : {}
|
|
1384
|
+
});
|
|
1385
|
+
sessionId = opts.resumeSessionId;
|
|
1386
|
+
resumed = true;
|
|
1387
|
+
} catch {}
|
|
1388
|
+
if (!resumed) {
|
|
1389
|
+
response = await openNewSession();
|
|
1390
|
+
sessionId = response.sessionId;
|
|
1391
|
+
}
|
|
1392
|
+
const models = modelConfig(response);
|
|
1393
|
+
let currentModel = models?.current ?? "";
|
|
1394
|
+
/**
|
|
1395
|
+
* @param {string} model
|
|
1396
|
+
* @returns {Promise<{ currentModel: string, warning?: string }>}
|
|
1397
|
+
*/
|
|
1398
|
+
const setModel = async (model) => {
|
|
1399
|
+
const wanted = String(model || "").trim();
|
|
1400
|
+
if (!wanted || wanted === currentModel) return { currentModel };
|
|
1401
|
+
if (!models) return {
|
|
1402
|
+
currentModel,
|
|
1403
|
+
warning: `El agente no deja elegir el modelo por ACP; usa el suyo.`
|
|
1404
|
+
};
|
|
1405
|
+
if (!models.options.some((o) => o.value === wanted)) return {
|
|
1406
|
+
currentModel,
|
|
1407
|
+
warning: `El agente no ofrece el modelo "${wanted}"; sigue con "${currentModel}".`
|
|
1408
|
+
};
|
|
1409
|
+
await conn.agent.request(acp.methods.agent.session.setConfigOption, {
|
|
1410
|
+
sessionId,
|
|
1411
|
+
configId: models.configId,
|
|
1412
|
+
value: wanted
|
|
1413
|
+
});
|
|
1414
|
+
currentModel = wanted;
|
|
1415
|
+
return { currentModel };
|
|
1416
|
+
};
|
|
1417
|
+
let modelWarning;
|
|
1418
|
+
const initialWanted = String(opts.model || "").trim();
|
|
1419
|
+
if (initialWanted) {
|
|
1420
|
+
const applied = await setModel(initialWanted);
|
|
1421
|
+
if (applied.warning) modelWarning = applied.warning;
|
|
1422
|
+
}
|
|
1423
|
+
clearTimeout(timer);
|
|
1424
|
+
return {
|
|
1425
|
+
sessionId,
|
|
1426
|
+
resumed,
|
|
1427
|
+
models: models?.options ?? [],
|
|
1428
|
+
get currentModel() {
|
|
1429
|
+
return currentModel;
|
|
1430
|
+
},
|
|
1431
|
+
...modelWarning ? { modelWarning } : {},
|
|
1432
|
+
setModel,
|
|
1433
|
+
prompt: async (text) => {
|
|
1434
|
+
const res = await conn.agent.request(acp.methods.agent.session.prompt, {
|
|
1435
|
+
sessionId,
|
|
1436
|
+
prompt: [{
|
|
1437
|
+
type: "text",
|
|
1438
|
+
text
|
|
1439
|
+
}]
|
|
1440
|
+
});
|
|
1441
|
+
return { stopReason: String(res.stopReason) };
|
|
1442
|
+
},
|
|
1443
|
+
cancel: () => conn.agent.notify(acp.methods.agent.session.cancel, { sessionId }),
|
|
1444
|
+
close,
|
|
1445
|
+
closed: conn.closed,
|
|
1446
|
+
stderrTail: () => stderr
|
|
1447
|
+
};
|
|
1448
|
+
} catch (err) {
|
|
1449
|
+
clearTimeout(timer);
|
|
1450
|
+
close();
|
|
1451
|
+
const tail = stderr.trim().split(/\r?\n/).slice(-8).join("\n");
|
|
1452
|
+
if (timedOut) {
|
|
1453
|
+
const msg = `Timeout (${Math.round(timeoutMs / 1e3)}s) al lanzar ${agent.def.label}. ¿Está autenticado el CLI?`;
|
|
1454
|
+
throw new Error(tail ? `${msg}\n${tail}` : msg);
|
|
1455
|
+
}
|
|
1456
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1457
|
+
throw new Error(tail ? `${reason}\n${tail}` : reason);
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
/**
|
|
1461
|
+
* Modelos ACP con caché en `~/.diff-review/models-cache.json` (TTL 24h).
|
|
1462
|
+
* @param {DetectedAgent} agent
|
|
1463
|
+
* @param {{ cwd: string, timeoutMs?: number, force?: boolean }} opts
|
|
1464
|
+
* @returns {Promise<{ models: ModelOption[], currentModel: string, fromCache: boolean }>}
|
|
1465
|
+
*/
|
|
1466
|
+
async function listAcpModelsCached(agent, opts) {
|
|
1467
|
+
if (!Boolean(opts?.force)) {
|
|
1468
|
+
const hit = readModelsCache(agent.def.id);
|
|
1469
|
+
if (hit) return {
|
|
1470
|
+
models: hit.models,
|
|
1471
|
+
currentModel: hit.currentModel,
|
|
1472
|
+
fromCache: true
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
const result = await listAcpModels(agent, opts);
|
|
1476
|
+
if (result.models.length) writeModelsCache(agent.def.id, result);
|
|
1477
|
+
return {
|
|
1478
|
+
...result,
|
|
1479
|
+
fromCache: false
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
/**
|
|
1483
|
+
* Modelos que ofrece el agente por ACP (abre una sesión, lee las opciones y la cierra; no manda ningún prompt).
|
|
1484
|
+
* @param {DetectedAgent} agent
|
|
1485
|
+
* @param {{ cwd: string, timeoutMs?: number }} opts
|
|
1486
|
+
* @returns {Promise<{ models: ModelOption[], currentModel: string }>}
|
|
1487
|
+
*/
|
|
1488
|
+
async function listAcpModels(agent, { cwd, timeoutMs = 45e3 }) {
|
|
1489
|
+
const ms = Math.max(5e3, Number(timeoutMs) || 45e3);
|
|
1490
|
+
/** @type {AcpSession | null} */
|
|
1491
|
+
let session = null;
|
|
1492
|
+
const pending = startAcpSession(agent, {
|
|
1493
|
+
cwd,
|
|
1494
|
+
decide: () => ({
|
|
1495
|
+
allow: false,
|
|
1496
|
+
reason: "solo se listan modelos"
|
|
1497
|
+
})
|
|
1498
|
+
}).then((s) => {
|
|
1499
|
+
session = s;
|
|
1500
|
+
return s;
|
|
1501
|
+
});
|
|
1502
|
+
const timeout = new Promise((_, reject) => {
|
|
1503
|
+
setTimeout(() => {
|
|
1504
|
+
reject(/* @__PURE__ */ new Error(`Timeout (${Math.round(ms / 1e3)}s) al consultar modelos de ${agent.def.label}. Reintentá en unos segundos.`));
|
|
1505
|
+
}, ms);
|
|
1506
|
+
});
|
|
1507
|
+
try {
|
|
1508
|
+
const opened = await Promise.race([pending, timeout]);
|
|
1509
|
+
const out = {
|
|
1510
|
+
models: opened.models,
|
|
1511
|
+
currentModel: opened.currentModel
|
|
1512
|
+
};
|
|
1513
|
+
opened.close();
|
|
1514
|
+
session = null;
|
|
1515
|
+
return out;
|
|
1516
|
+
} catch (err) {
|
|
1517
|
+
pending.then((s) => s.close()).catch(() => {});
|
|
1518
|
+
try {
|
|
1519
|
+
session?.close();
|
|
1520
|
+
} catch {}
|
|
1521
|
+
throw err;
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
/** Un error en la política no puede colgar al agente esperando respuesta: se rechaza. */
|
|
1525
|
+
function safeDecide(decide, toolCall) {
|
|
1526
|
+
try {
|
|
1527
|
+
return decide(toolCall);
|
|
1528
|
+
} catch (err) {
|
|
1529
|
+
return {
|
|
1530
|
+
allow: false,
|
|
1531
|
+
reason: `error en la política: ${err instanceof Error ? err.message : err}`
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
//#endregion
|
|
1536
|
+
//#region ../lib/check-report.mjs
|
|
1537
|
+
/**
|
|
1538
|
+
* Valida un JSON de review y arma un texto accionable para el agente.
|
|
1539
|
+
*/
|
|
1540
|
+
/** Raíz del repo a partir del JSON (`.diff-review/foo.json` → parent, o la carpeta del archivo legacy). */
|
|
1541
|
+
function repoRootFromReviewFile(absJson) {
|
|
1542
|
+
const dir = dirname(absJson);
|
|
1543
|
+
return basename(dir) === ".diff-review" ? dirname(dir) : dir;
|
|
1544
|
+
}
|
|
1545
|
+
var MISSING_LIMIT = 40;
|
|
1546
|
+
/**
|
|
1547
|
+
* @param {unknown} payload
|
|
1548
|
+
* @param {string[]} [paths] rutas del diff (sin ignorados)
|
|
1549
|
+
* @returns {{ ok: boolean, errors: string[], notes: string[] }}
|
|
1550
|
+
*/
|
|
1551
|
+
function checkPayload(payload, paths = []) {
|
|
1552
|
+
const errors = [...validateAgainst(REVIEW_SCHEMA, payload), ...reviewIssues(payload)];
|
|
1553
|
+
const notes = [];
|
|
1554
|
+
const known = (paths || []).map((p) => String(p || "").replaceAll("\\", "/")).filter(Boolean);
|
|
1555
|
+
if (known.length) {
|
|
1556
|
+
const blocks = Array.isArray(payload?.blocks) ? payload.blocks : [];
|
|
1557
|
+
const findings = Array.isArray(payload?.findings) ? payload.findings : [];
|
|
1558
|
+
const skippedPatterns = (Array.isArray(payload?.skipped) ? payload.skipped : []).map((s) => String(s?.file || "")).filter(Boolean);
|
|
1559
|
+
const covers = (file) => {
|
|
1560
|
+
const norm = String(file || "").replaceAll("\\", "/");
|
|
1561
|
+
if (blocks.some((b) => String(b?.file || "").replaceAll("\\", "/") === norm)) return true;
|
|
1562
|
+
return skippedPatterns.some((pattern) => {
|
|
1563
|
+
const p = pattern.replaceAll("\\", "/");
|
|
1564
|
+
return p === norm || globMatch(norm, p);
|
|
1565
|
+
});
|
|
1566
|
+
};
|
|
1567
|
+
for (const block of blocks) {
|
|
1568
|
+
const file = String(block?.file || "").replaceAll("\\", "/");
|
|
1569
|
+
if (file && !known.includes(file)) errors.push(`el bloque ${block.id || file} apunta a un archivo que no está en el diff: ${file}`);
|
|
1570
|
+
}
|
|
1571
|
+
for (const finding of findings) {
|
|
1572
|
+
const file = String(finding?.file || "").replaceAll("\\", "/");
|
|
1573
|
+
if (file && !known.includes(file)) errors.push(`el hallazgo ${finding.id || finding.what || file} apunta a un archivo fuera del diff: ${file}`);
|
|
1574
|
+
}
|
|
1575
|
+
const missing = known.filter((file) => !covers(file));
|
|
1576
|
+
if (missing.length) {
|
|
1577
|
+
const shown = missing.slice(0, MISSING_LIMIT);
|
|
1578
|
+
const rest = missing.length - shown.length;
|
|
1579
|
+
errors.push(`quedaron ${missing.length} archivo(s) del diff sin bloque ni skipped: ${shown.join(", ")}${rest > 0 ? ` … y ${rest} más` : ""}`);
|
|
1580
|
+
}
|
|
1581
|
+
} else notes.push("No contrasté cobertura contra git (sin lista de paths del diff).");
|
|
1582
|
+
return {
|
|
1583
|
+
ok: errors.length === 0,
|
|
1584
|
+
errors,
|
|
1585
|
+
notes
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
/**
|
|
1589
|
+
* @param {string} filePath
|
|
1590
|
+
* @param {{ repo?: string, branch?: string, base?: string, cwd?: string, paths?: string[] }} [opts]
|
|
1591
|
+
* `paths`: lista ya conocida (p. ej. de la sesión); si viene, no se lee el `.diff` ni se llama a git.
|
|
1592
|
+
*/
|
|
1593
|
+
function checkReportFile(filePath, opts = {}) {
|
|
1594
|
+
const cwd = opts.cwd || process.cwd();
|
|
1595
|
+
const abs = isAbsolute(filePath) ? filePath : resolve(cwd, filePath);
|
|
1596
|
+
if (!existsSync(abs)) return {
|
|
1597
|
+
ok: false,
|
|
1598
|
+
file: abs,
|
|
1599
|
+
errors: [`No existe el archivo: ${abs}`],
|
|
1600
|
+
notes: [],
|
|
1601
|
+
usage: true
|
|
1602
|
+
};
|
|
1603
|
+
let payload;
|
|
1604
|
+
try {
|
|
1605
|
+
payload = decodePayload(readFileSync(abs, "utf8"));
|
|
1606
|
+
} catch (err) {
|
|
1607
|
+
return {
|
|
1608
|
+
ok: false,
|
|
1609
|
+
file: abs,
|
|
1610
|
+
errors: [err instanceof Error ? err.message : String(err)],
|
|
1611
|
+
notes: []
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
const meta = payload && typeof payload === "object" ? payload.meta || {} : {};
|
|
1615
|
+
const branch = String(opts.branch || meta.branch || "").trim();
|
|
1616
|
+
const base = String(opts.base || meta.base || "develop").trim();
|
|
1617
|
+
const repoHint = String(opts.repo || meta.repo || "").trim();
|
|
1618
|
+
const repo = (repoHint && !/^(https?:\/\/|git@|ssh:\/\/)/i.test(repoHint) ? tryResolveGitRoot(repoHint) || (existsSync(repoHint) ? resolve(repoHint) : null) : null) || tryResolveGitRoot(cwd) || repoRootFromReviewFile(abs);
|
|
1619
|
+
/** @type {string[]} */
|
|
1620
|
+
let paths = Array.isArray(opts.paths) ? opts.paths.map(String).filter(Boolean) : [];
|
|
1621
|
+
/** @type {string[]} */
|
|
1622
|
+
const errors = [];
|
|
1623
|
+
if (paths.length) ; else if (!branch) errors.push("Falta meta.branch (o --branch) para contrastar el diff.");
|
|
1624
|
+
else if (!repo) errors.push("No encontré un repo local (meta.repo, el directorio actual, o la carpeta del JSON). Sin eso no puedo verificar cobertura.");
|
|
1625
|
+
else try {
|
|
1626
|
+
paths = (loadBranchDiff({
|
|
1627
|
+
repo,
|
|
1628
|
+
branch,
|
|
1629
|
+
base,
|
|
1630
|
+
filename: basename(abs)
|
|
1631
|
+
}).files || []).map((f) => f.path);
|
|
1632
|
+
} catch (err) {
|
|
1633
|
+
errors.push(`No pude listar el diff (${err instanceof Error ? err.message : String(err)}). Revisá meta.repo, meta.branch y meta.base, o el companion .diff.`);
|
|
1634
|
+
}
|
|
1635
|
+
const result = checkPayload(payload, paths);
|
|
1636
|
+
const allErrors = [...result.errors, ...errors];
|
|
1637
|
+
return {
|
|
1638
|
+
ok: allErrors.length === 0,
|
|
1639
|
+
errors: allErrors,
|
|
1640
|
+
notes: result.notes,
|
|
1641
|
+
file: abs
|
|
1642
|
+
};
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
* @param {string} file
|
|
1646
|
+
* @param {string} command
|
|
1647
|
+
* @param {{ ok: boolean, errors: string[], notes: string[] }} result
|
|
1648
|
+
*/
|
|
1649
|
+
function formatCheckReport(file, command, result) {
|
|
1650
|
+
if (result.ok) return `OK: ${file}${result.notes?.length ? `\n${result.notes.map((n) => `Note: ${n}`).join("\n")}` : ""}\nSchema and references passed. You can stop.`;
|
|
1651
|
+
const problems = (result.errors || []).map((e) => `- ${e}`).join("\n");
|
|
1652
|
+
const notes = (result.notes || []).map((n) => `Note: ${n}`).join("\n");
|
|
1653
|
+
const lines = [
|
|
1654
|
+
`INVALID: ${file}`,
|
|
1655
|
+
"",
|
|
1656
|
+
"The JSON is not ready. Edit this same file until the command prints OK. Do not create a new filename.",
|
|
1657
|
+
"",
|
|
1658
|
+
"Problems:",
|
|
1659
|
+
problems || "- (unknown)"
|
|
1660
|
+
];
|
|
1661
|
+
if (notes) lines.push("", notes);
|
|
1662
|
+
lines.push("", "How to continue:", "- Fix every problem above in place. Keep existing meta/intent/groups/blocks/findings; only change what is wrong or missing.", "- Every diff path must appear in \"blocks\" or \"skipped\" (a skipped glob counts).", "- If a problem lists missing paths: ADD a block or a skipped entry for each exact path. Do not only bump meta.generatedAt, notes, or claim the file is already complete.", "- meta.agent and meta.model are required (product name and the exact model id you are running).", "- intent ≥40 chars; enums exactly as in the schema.", "- Human-readable strings in Spanish.", `- Re-run exactly: \`${command}\``, "- Stop only when that command prints OK.");
|
|
1663
|
+
return lines.join("\n");
|
|
1664
|
+
}
|
|
1665
|
+
//#endregion
|
|
1666
|
+
//#region ../lib/review-meta.mjs
|
|
1667
|
+
/**
|
|
1668
|
+
* Estampa en el JSON quién generó la review. La CLI lo sabe mejor que el modelo.
|
|
1669
|
+
*/
|
|
1670
|
+
/**
|
|
1671
|
+
* `agentSession` = la sesión ACP que generó la review, para seguir conversando con el agente desde la web.
|
|
1672
|
+
* @param {string} filePath
|
|
1673
|
+
* @param {{ agent?: string, model?: string, agentSession?: { agentId: string, sessionId: string, outputFilename: string } }} provenance
|
|
1674
|
+
* @returns {boolean}
|
|
1675
|
+
*/
|
|
1676
|
+
function stampReviewProvenance(filePath, { agent, model, agentSession } = {}) {
|
|
1677
|
+
const agentName = String(agent || "").trim();
|
|
1678
|
+
const modelId = String(model || "").trim();
|
|
1679
|
+
if (!agentName && !modelId && !agentSession) return false;
|
|
1680
|
+
let payload;
|
|
1681
|
+
try {
|
|
1682
|
+
payload = decodePayload(readFileSync(filePath, "utf8"));
|
|
1683
|
+
} catch {
|
|
1684
|
+
return false;
|
|
1685
|
+
}
|
|
1686
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
|
|
1687
|
+
const current = payload.meta;
|
|
1688
|
+
const meta = current && typeof current === "object" && !Array.isArray(current) ? { ...current } : {};
|
|
1689
|
+
if (agentName) meta.agent = agentName;
|
|
1690
|
+
if (modelId) meta.model = modelId;
|
|
1691
|
+
if (agentSession) meta.agentSession = { ...agentSession };
|
|
1692
|
+
payload.meta = meta;
|
|
1693
|
+
writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
1694
|
+
return true;
|
|
1695
|
+
}
|
|
1696
|
+
//#endregion
|
|
1697
|
+
//#region ../lib/prompt.mjs
|
|
1698
|
+
/**
|
|
1699
|
+
* Prompt de review para el agente (compartido CLI + UI).
|
|
1700
|
+
* Mantener alineado con el contrato de schema.json / validate.mjs.
|
|
1701
|
+
*/
|
|
1702
|
+
var OUTPUT_SCHEMA_BLOCK = `{
|
|
1703
|
+
"meta": {
|
|
1704
|
+
"source": "local|url",
|
|
1705
|
+
"repo": "<absolute path or git URL>",
|
|
1706
|
+
"branch": "<branch under review>",
|
|
1707
|
+
"base": "develop",
|
|
1708
|
+
"remoteUrl": "<origin URL; optional when source=url>",
|
|
1709
|
+
"generatedAt": "<ISO-8601 datetime; optional>",
|
|
1710
|
+
"agent": "<required: product name you are, e.g. Claude Code>",
|
|
1711
|
+
"model": "<required: exact model id you are running>"
|
|
1712
|
+
},
|
|
1713
|
+
"intent": "string",
|
|
1714
|
+
"groups": [
|
|
1715
|
+
{
|
|
1716
|
+
"id": "t1",
|
|
1717
|
+
"kind": "feat|fix|refactor|perf|test|chore|docs|infra",
|
|
1718
|
+
"title": "string, aim for ≤72 chars, imperative, no kind prefix",
|
|
1719
|
+
"intent": "string"
|
|
1720
|
+
}
|
|
1721
|
+
],
|
|
1722
|
+
"blocks": [
|
|
1723
|
+
{
|
|
1724
|
+
"id": "b1",
|
|
1725
|
+
"group": "t1",
|
|
1726
|
+
"file": "relative/path.ext",
|
|
1727
|
+
"lines": "L40-58",
|
|
1728
|
+
"side": "new|old",
|
|
1729
|
+
"start": 40,
|
|
1730
|
+
"end": 58,
|
|
1731
|
+
"op": "add|mod|del|rename",
|
|
1732
|
+
"what": "string, aim for ≤120 chars: concrete change + effect",
|
|
1733
|
+
"why": "string, aim for ≤120 chars",
|
|
1734
|
+
"source": "code|commit|pr|issue|inferred"
|
|
1735
|
+
}
|
|
1736
|
+
],
|
|
1737
|
+
"findings": [
|
|
1738
|
+
{
|
|
1739
|
+
"id": "f1",
|
|
1740
|
+
"class": "risk|quality",
|
|
1741
|
+
"severity": "high|med|low|nit",
|
|
1742
|
+
"blocking": false,
|
|
1743
|
+
"kind": "bug|race|auth|security|api-break|missing-test|perf|maintainability|docs|other",
|
|
1744
|
+
"file": "relative/path.ext",
|
|
1745
|
+
"line": 87,
|
|
1746
|
+
"block": "b1",
|
|
1747
|
+
"what": "string, aim for ≤120 chars: concrete cause + observable consequence (what fails / risk / cost)",
|
|
1748
|
+
"fix": "string, aim for ≤120 chars: suggested change + what the reader gains or avoids if applied"
|
|
1749
|
+
}
|
|
1750
|
+
],
|
|
1751
|
+
"skipped": [
|
|
1752
|
+
{ "file": "relative/path-or-glob", "reason": "generated|lockfile|format|vendored|binary|trivial|ignored" }
|
|
1753
|
+
],
|
|
1754
|
+
"notes": ["optional short limitations"]
|
|
1755
|
+
}`;
|
|
1756
|
+
var RULES = `- Ids: themes use \`t1\`, \`t2\`, …; findings use \`f1\`, \`f2\`, …; blocks use \`b1\`, \`b2\`, …. Reference themes from blocks via \`"group": "t1"\`.
|
|
1757
|
+
- Coverage (hard): every path in the diff MUST appear in "blocks" and/or "skipped". Prefer a skipped glob when many files share one reason.
|
|
1758
|
+
- Do NOT invent files, hunks, or line ranges that are not in the diff.
|
|
1759
|
+
- Line anchors: "lines"/"start"/"end"/"line" MUST match the chosen "side" ("new" = post-image / right side of @@; "old" = pre-image, typically deletions). One block per coherent change span, not one per line. Point findings at that block; leave "block" empty only if cross-cutting.
|
|
1760
|
+
- Do NOT report: pre-existing code outside the diff; linter/formatter noise; naming/style prefs with no real consequence; intentional branch behavior; silenced lint rules; "missing docs/coverage" without a concrete broken scenario.
|
|
1761
|
+
- Quality findings must name a concrete cost, not a vague feeling.
|
|
1762
|
+
- Finding "what": state the concrete cause and the observable consequence (what fails, the risk, or the cost) — not only a symptom.
|
|
1763
|
+
- Finding "fix": state the suggested change and what the reader gains or avoids if they apply it.
|
|
1764
|
+
- Signal over volume: prefer fewer high-signal findings. Order findings by severity (high, then nit). Use "nit" sparingly.
|
|
1765
|
+
- "blocking": true ONLY if the branch should not merge as-is. Use only with class "risk".
|
|
1766
|
+
- When several read-only lookups are independent (files, searches), batch them in the same turn if your tools allow parallel calls.
|
|
1767
|
+
- Write human-readable strings (intent, title, what, why, fix, notes) in Spanish. Keep enums/ids/paths/JSON keys exactly as in the schema.
|
|
1768
|
+
- Length is a soft target, not a check: aim for title ≤72 characters and what/why/fix ≤120 characters. Prefer clarity over shortening. Longer text is still valid. Do not rewrite the file only to shorten it.`;
|
|
1769
|
+
/** @param {{ hasDiff?: boolean }} [ctx] */
|
|
1770
|
+
function workflowLocal(base, branch, ctx = {}) {
|
|
1771
|
+
if (ctx.hasDiff) return `Workflow:
|
|
1772
|
+
1. Read the three-dot diff from the path given in "Precomputed git context" below. Do not re-run \`git diff\` unless that file is missing.
|
|
1773
|
+
2. Read enough surrounding code (types, callers, tests) to judge behavior, not only the hunk lines.
|
|
1774
|
+
3. Emit the JSON file. Do not modify the repo.
|
|
1775
|
+
4. Validate it with the command in the output section. If it prints INVALID, follow "How to continue" and re-run until it prints OK.`;
|
|
1776
|
+
return `Workflow:
|
|
1777
|
+
1. Resolve the merge-base of \`${base}\` and \`${branch}\`.
|
|
1778
|
+
2. Run exactly: \`git diff ${base}...${branch}\` (three-dot / merge-base diff). Do not use two-dot unless three-dot is impossible.
|
|
1779
|
+
3. Read enough surrounding code (types, callers, tests) to judge behavior, not only the hunk lines.
|
|
1780
|
+
4. Emit the JSON file. Do not modify the repo.
|
|
1781
|
+
5. Validate it with the command in the output section. If it prints INVALID, follow "How to continue" and re-run until it prints OK.`;
|
|
1782
|
+
}
|
|
1783
|
+
/** Comando que el agente debe correr (sobre `.diff-review/<nombre>`); npx por si el bin no está en PATH. */
|
|
1784
|
+
function validateCliCommand(filename) {
|
|
1785
|
+
return `npx --yes @jpkme/diff-review validate ${outputRelPath(String(filename || "diff-rev_<branchTip>_<baseTip>.json").trim())}`;
|
|
1786
|
+
}
|
|
1787
|
+
/** Workflow sin paso de validate por CLI (sesiones ACP: valida el server). */
|
|
1788
|
+
function workflowLocalAcp(ctx = {}) {
|
|
1789
|
+
if (ctx.hasDiff) return `Workflow:
|
|
1790
|
+
1. Read the three-dot diff from the path given in "Precomputed git context" below. Do not re-run \`git diff\` unless that file is missing.
|
|
1791
|
+
2. Read enough surrounding code (types, callers, tests) to judge behavior, not only the hunk lines.
|
|
1792
|
+
3. Write the JSON with your file Write/Edit tool (never shell redirection). Do not modify other repo files. The host validates when your turn ends — do not run the validate CLI.`;
|
|
1793
|
+
return `Workflow:
|
|
1794
|
+
1. Resolve the merge-base of the base and branch named above, then run the three-dot \`git diff base...branch\`.
|
|
1795
|
+
2. Read enough surrounding code (types, callers, tests) to judge behavior, not only the hunk lines.
|
|
1796
|
+
3. Write the JSON with your file Write/Edit tool (never shell redirection). Do not modify other repo files. The host validates when your turn ends — do not run the validate CLI.`;
|
|
1797
|
+
}
|
|
1798
|
+
/**
|
|
1799
|
+
* @param {{ source?: string, repo?: string, branch?: string, base?: string, remoteUrl?: string }} meta
|
|
1800
|
+
*/
|
|
1801
|
+
function outputSchemaBlock(meta) {
|
|
1802
|
+
const source = "local";
|
|
1803
|
+
const repo = String(meta.repo || "").trim() || ("<absolute repo path>");
|
|
1804
|
+
const branch = String(meta.branch || "").trim() || "<branch under review>";
|
|
1805
|
+
const base = String(meta.base || "").trim() || "develop";
|
|
1806
|
+
const remoteUrl = String(meta.remoteUrl || "").trim() || ("");
|
|
1807
|
+
const metaLines = [
|
|
1808
|
+
` "source": ${JSON.stringify(source)},`,
|
|
1809
|
+
` "repo": ${JSON.stringify(repo)},`,
|
|
1810
|
+
` "branch": ${JSON.stringify(branch)},`,
|
|
1811
|
+
` "base": ${JSON.stringify(base)},`
|
|
1812
|
+
];
|
|
1813
|
+
if (remoteUrl) metaLines.push(` "remoteUrl": ${JSON.stringify(remoteUrl)},`);
|
|
1814
|
+
metaLines.push(` "generatedAt": "<ISO-8601 datetime>",`, ` "agent": "<required: product name you are, e.g. Claude Code>",`, ` "model": "<required: exact model id you are running>"`);
|
|
1815
|
+
const metaBlock = `"meta": {\n${metaLines.join("\n")}\n }`;
|
|
1816
|
+
return OUTPUT_SCHEMA_BLOCK.replace(/"meta": \{[\s\S]*?\n \}/, metaBlock);
|
|
1817
|
+
}
|
|
1818
|
+
/**
|
|
1819
|
+
* Bloque de contexto git ya resuelto (diff en disco, tips, lista de archivos, ignores).
|
|
1820
|
+
* @param {{
|
|
1821
|
+
* diffRelPath?: string | null,
|
|
1822
|
+
* mergeBase?: string,
|
|
1823
|
+
* branchSha?: string,
|
|
1824
|
+
* baseSha?: string,
|
|
1825
|
+
* files?: Array<{ path: string, changeType?: string }>,
|
|
1826
|
+
* ignored?: string[],
|
|
1827
|
+
* ignoreGlobs?: string[],
|
|
1828
|
+
* }} ctx
|
|
1829
|
+
*/
|
|
1830
|
+
function formatPrecomputedContext(ctx) {
|
|
1831
|
+
const lines = ["Precomputed git context (do not rediscover):"];
|
|
1832
|
+
if (ctx.mergeBase) lines.push(`- Merge-base SHA: \`${ctx.mergeBase}\``);
|
|
1833
|
+
if (ctx.branchSha) lines.push(`- Branch tip SHA: \`${ctx.branchSha}\``);
|
|
1834
|
+
if (ctx.baseSha) lines.push(`- Base tip SHA: \`${ctx.baseSha}\``);
|
|
1835
|
+
if (ctx.diffRelPath) lines.push(`- Diff file (relative to repo root): \`${ctx.diffRelPath}\` — read this file; do not re-run \`git diff\` unless it is missing.`);
|
|
1836
|
+
const files = Array.isArray(ctx.files) ? ctx.files : [];
|
|
1837
|
+
if (files.length) {
|
|
1838
|
+
lines.push(`- Files in the diff (${files.length}):`);
|
|
1839
|
+
const limit = 200;
|
|
1840
|
+
for (const f of files.slice(0, limit)) {
|
|
1841
|
+
const path = String(f?.path || "").trim();
|
|
1842
|
+
if (!path) continue;
|
|
1843
|
+
const kind = String(f?.changeType || "").trim();
|
|
1844
|
+
lines.push(kind ? ` - ${kind}: ${path}` : ` - ${path}`);
|
|
1845
|
+
}
|
|
1846
|
+
if (files.length > limit) lines.push(` - … and ${files.length - limit} more`);
|
|
1847
|
+
}
|
|
1848
|
+
const ignored = Array.isArray(ctx.ignored) ? ctx.ignored.filter(Boolean) : [];
|
|
1849
|
+
if (ignored.length) {
|
|
1850
|
+
lines.push(`- Ignored paths (excluded from the file list; if you mention them use "skipped" with reason "ignored"):`);
|
|
1851
|
+
for (const p of ignored.slice(0, 40)) lines.push(` - ${p}`);
|
|
1852
|
+
if (ignored.length > 40) lines.push(` - … and ${ignored.length - 40} more`);
|
|
1853
|
+
}
|
|
1854
|
+
const globs = Array.isArray(ctx.ignoreGlobs) ? ctx.ignoreGlobs.filter(Boolean) : [];
|
|
1855
|
+
if (globs.length) lines.push(`- Ignore globs (DEFAULT + config): ${globs.map((g) => `\`${g}\``).join(", ")}`);
|
|
1856
|
+
return lines.join("\n");
|
|
1857
|
+
}
|
|
1858
|
+
/**
|
|
1859
|
+
* @param {{ source?: string, repo?: string, branch?: string, base?: string, remoteUrl?: string }} meta
|
|
1860
|
+
* @param {{
|
|
1861
|
+
* outputFilename?: string,
|
|
1862
|
+
* acp?: boolean,
|
|
1863
|
+
* diffRelPath?: string | null,
|
|
1864
|
+
* mergeBase?: string,
|
|
1865
|
+
* branchSha?: string,
|
|
1866
|
+
* baseSha?: string,
|
|
1867
|
+
* files?: Array<{ path: string, changeType?: string }>,
|
|
1868
|
+
* ignored?: string[],
|
|
1869
|
+
* ignoreGlobs?: string[],
|
|
1870
|
+
* }} [opts]
|
|
1871
|
+
*/
|
|
1872
|
+
function buildPrompt(meta, opts = {}) {
|
|
1873
|
+
const outputFilename = opts.outputFilename || makeOutputFilename();
|
|
1874
|
+
const outputPath = outputRelPath(outputFilename);
|
|
1875
|
+
const repo = String(meta.repo || "").trim() || ("<absolute repo path>");
|
|
1876
|
+
const branch = String(meta.branch || "").trim() || "<branch under review>";
|
|
1877
|
+
const base = String(meta.base || "").trim() || "develop";
|
|
1878
|
+
const acp = Boolean(opts.acp);
|
|
1879
|
+
const hasDiff = Boolean(String(opts.diffRelPath || "").trim());
|
|
1880
|
+
const workflow = acp ? workflowLocalAcp({ hasDiff }) : workflowLocal(base, branch, { hasDiff });
|
|
1881
|
+
const precomputed = formatPrecomputedContext({
|
|
1882
|
+
diffRelPath: opts.diffRelPath,
|
|
1883
|
+
mergeBase: opts.mergeBase,
|
|
1884
|
+
branchSha: opts.branchSha,
|
|
1885
|
+
baseSha: opts.baseSha,
|
|
1886
|
+
files: opts.files,
|
|
1887
|
+
ignored: opts.ignored,
|
|
1888
|
+
ignoreGlobs: opts.ignoreGlobs
|
|
1889
|
+
});
|
|
1890
|
+
const hasPrecomputed = hasDiff || opts.mergeBase || opts.branchSha || opts.baseSha || opts.files && opts.files.length || opts.ignored && opts.ignored.length || opts.ignoreGlobs && opts.ignoreGlobs.length;
|
|
1891
|
+
const validateSection = acp ? `When done writing \`${outputPath}\`, stop. The host validates the JSON automatically and will send you any problems — do not run \`npx … validate\` yourself.` : `After writing the file, validate it (npx so it works even if \`diff-review\` is not on PATH):
|
|
1892
|
+
|
|
1893
|
+
\`${validateCliCommand(outputFilename)}\`
|
|
1894
|
+
|
|
1895
|
+
- Exit 0 and a line starting with \`OK\` means the JSON is acceptable. Then tell me that \`${outputPath}\` was written.
|
|
1896
|
+
- Exit 1 prints \`INVALID\`, the problems, and how to continue. Fix the same file and re-run that exact command until it prints OK. Do not stop before OK.`;
|
|
1897
|
+
return `You are a senior code reviewer: strict but fair.
|
|
1898
|
+
|
|
1899
|
+
Repo: ${repo}
|
|
1900
|
+
Branch under review: ${branch}
|
|
1901
|
+
Base (merge-base): ${base}
|
|
1902
|
+
|
|
1903
|
+
${workflow}
|
|
1904
|
+
${hasPrecomputed ? `\n${precomputed}\n` : ""}
|
|
1905
|
+
Rules:
|
|
1906
|
+
${RULES}
|
|
1907
|
+
|
|
1908
|
+
Write the result to \`${outputPath}\` (relative to the repo root; use forward slashes, never \`\\\`). The \`${OUTPUT_DIR}/\` folder is already created by the host — do not \`mkdir\` it.
|
|
1909
|
+
- If that file already exists: **Read it first**, then Edit/overwrite (or Write after Read). Many agents reject Write with "File has not been read yet" unless you Read that exact path first.
|
|
1910
|
+
- If it does not exist yet: Write is fine. Do NOT use shell redirection (\`>\`, \`>>\`, \`Out-File\`, \`tee\`, etc.) — those are rejected.
|
|
1911
|
+
- One JSON object only: first character \`{\`, last character \`}\`.
|
|
1912
|
+
- No prose before/after, no markdown fences.
|
|
1913
|
+
- Shape (copy "meta" source/repo/branch/base/remoteUrl as given; "agent" and "model" are required):
|
|
1914
|
+
|
|
1915
|
+
${outputSchemaBlock(meta)}
|
|
1916
|
+
|
|
1917
|
+
Include "meta" with the source/repo/branch/base above (and origin "remoteUrl" when local). "agent" and "model" are required: set "agent" to the product you are (Claude Code, Cursor, Codex, Copilot, Gemini, …) and "model" to the exact model id you are running. Do not leave placeholders or omit them; validation fails without both. "generatedAt" is optional (ISO-8601 now).
|
|
1918
|
+
|
|
1919
|
+
Do NOT include diffs or a "files" array. The tool loads the companion \`.diff\` (or computes with git if missing) when opening the report. Do not invent hunks or paste patches into the JSON.
|
|
1920
|
+
|
|
1921
|
+
${validateSection}`;
|
|
1922
|
+
}
|
|
1923
|
+
//#endregion
|
|
1924
|
+
//#region ../lib/review-prep.mjs
|
|
1925
|
+
/**
|
|
1926
|
+
* Precomputa contexto git para el prompt de review (diff cache + lista de archivos).
|
|
1927
|
+
*/
|
|
1928
|
+
/**
|
|
1929
|
+
* Cachea el three-dot diff y arma las opciones de `buildPrompt` (tips, files, ignores).
|
|
1930
|
+
* @param {{
|
|
1931
|
+
* repo: string,
|
|
1932
|
+
* branch: string,
|
|
1933
|
+
* base?: string,
|
|
1934
|
+
* outputFilename: string,
|
|
1935
|
+
* acp?: boolean,
|
|
1936
|
+
* }} input
|
|
1937
|
+
*/
|
|
1938
|
+
function prepareReviewPromptOpts(input) {
|
|
1939
|
+
const repo = String(input.repo || "").trim();
|
|
1940
|
+
const branch = String(input.branch || "").trim();
|
|
1941
|
+
const base = String(input.base || "develop").trim() || "develop";
|
|
1942
|
+
const outputFilename = String(input.outputFilename || "").trim();
|
|
1943
|
+
if (!repo || !branch || !outputFilename) throw new Error("prepareReviewPromptOpts requiere repo, branch y outputFilename.");
|
|
1944
|
+
ensureReviewDir(repo);
|
|
1945
|
+
writeDiffCache(repo, outputFilename, {
|
|
1946
|
+
branch,
|
|
1947
|
+
base
|
|
1948
|
+
});
|
|
1949
|
+
const cached = readDiffCache(repo, outputFilename);
|
|
1950
|
+
let extracted;
|
|
1951
|
+
try {
|
|
1952
|
+
extracted = extractBranchDiff({
|
|
1953
|
+
repo,
|
|
1954
|
+
branch,
|
|
1955
|
+
base,
|
|
1956
|
+
...cached ? { cachedDiff: cached } : {}
|
|
1957
|
+
});
|
|
1958
|
+
} catch {
|
|
1959
|
+
extracted = extractBranchDiff({
|
|
1960
|
+
repo,
|
|
1961
|
+
branch,
|
|
1962
|
+
base
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
const config = loadReviewConfig(repo);
|
|
1966
|
+
const diffRelPath = cached ? outputRelPath(outputFilename).replace(/\.json$/i, ".diff") : null;
|
|
1967
|
+
return {
|
|
1968
|
+
outputFilename,
|
|
1969
|
+
acp: Boolean(input.acp),
|
|
1970
|
+
diffRelPath,
|
|
1971
|
+
mergeBase: extracted.mergeBase || void 0,
|
|
1972
|
+
branchSha: extracted.branchSha || void 0,
|
|
1973
|
+
baseSha: extracted.baseSha || void 0,
|
|
1974
|
+
files: (extracted.files || []).map((f) => ({
|
|
1975
|
+
path: f.path,
|
|
1976
|
+
changeType: f.changeType
|
|
1977
|
+
})),
|
|
1978
|
+
ignored: extracted.context?.ignored || [],
|
|
1979
|
+
ignoreGlobs: config.ignore
|
|
1980
|
+
};
|
|
1981
|
+
}
|
|
1982
|
+
//#endregion
|
|
1983
|
+
//#region src/lib/report/agent-events.ts
|
|
1984
|
+
/**
|
|
1985
|
+
* Aplica un evento a la lista: un `text` con el mismo `seq` que el último es la continuación de ese chunk.
|
|
1986
|
+
*/
|
|
1987
|
+
function applyEvent(events, event) {
|
|
1988
|
+
const last = events[events.length - 1];
|
|
1989
|
+
if (last && last.seq === event.seq && last.type === "text" && event.type === "text") return [...events.slice(0, -1), {
|
|
1990
|
+
...last,
|
|
1991
|
+
text: last.text + event.text
|
|
1992
|
+
}];
|
|
1993
|
+
if (event.type === "tool") {
|
|
1994
|
+
const i = events.findIndex((e) => e.type === "tool" && e.id === event.id);
|
|
1995
|
+
if (i >= 0) {
|
|
1996
|
+
const prev = events[i];
|
|
1997
|
+
const merged = {
|
|
1998
|
+
...prev,
|
|
1999
|
+
title: event.title || prev.title,
|
|
2000
|
+
kind: event.kind || prev.kind,
|
|
2001
|
+
status: event.status || prev.status
|
|
2002
|
+
};
|
|
2003
|
+
return [
|
|
2004
|
+
...events.slice(0, i),
|
|
2005
|
+
merged,
|
|
2006
|
+
...events.slice(i + 1)
|
|
2007
|
+
];
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
if (event.type === "permission") {
|
|
2011
|
+
const i = events.findIndex((e) => e.type === "permission" && e.requestId === event.requestId);
|
|
2012
|
+
if (i >= 0) return [
|
|
2013
|
+
...events.slice(0, i),
|
|
2014
|
+
event,
|
|
2015
|
+
...events.slice(i + 1)
|
|
2016
|
+
];
|
|
2017
|
+
}
|
|
2018
|
+
return [...events, event];
|
|
2019
|
+
}
|
|
2020
|
+
//#endregion
|
|
2021
|
+
//#region src/lib/server/agent-sessions.ts
|
|
2022
|
+
/**
|
|
2023
|
+
* Sesiones con agentes por ACP, del lado del server: el server es dueño del proceso del agente y la web lo sigue
|
|
2024
|
+
* por SSE (`/api/agent/[id]/events`). Una sesión genera la review y después queda viva para preguntas y cambios.
|
|
2025
|
+
*
|
|
2026
|
+
* Los procesos viven en memoria; lo que tiene que sobrevivir va a `.diff-review/` del repo: el JSON, el diff y la
|
|
2027
|
+
* sesión del agente (`<nombre>.session.json`). Para chatear sobre un reporte se retoma esa sesión
|
|
2028
|
+
* (`session/load`); si no se puede (se borró, otro agente, otra máquina) se abre una nueva y el primer mensaje le
|
|
2029
|
+
* pasa el contexto: repo, branch, base y dónde están el diff y el JSON.
|
|
2030
|
+
*/
|
|
2031
|
+
/** Para que el agente no gaste turnos en comandos que la política va a rechazar. */
|
|
2032
|
+
var TOOL_RULES = "Tool rules for this session: read files with your file tools (read/search/glob). For the review JSON path: use forward slashes (e.g. .diff-review/foo.json). If that file already exists, Read it first, then Edit/Write — Write fails with \"File has not been read yet\" unless you Read that exact path. Never shell redirection (>, >>, Out-File, tee). The .diff-review/ folder already exists — do not mkdir it. In the terminal only read-only commands run (git diff/log/show/blame/…, cat, head, tail, grep, rg, find, ls, wc, sed -n). No redirections to files, no command substitution, no installs, no network. Anything else is rejected automatically: do not retry the same rejected command; use the file tool or another read-only way.";
|
|
2033
|
+
/**
|
|
2034
|
+
* En estas sesiones valida el server al terminar cada turno (y le devuelve los errores al agente): correr el
|
|
2035
|
+
* validate por npx es lento y puede resolver otra versión del paquete.
|
|
2036
|
+
*/
|
|
2037
|
+
var SERVER_VALIDATES = "Note: in this session diff-review validates the JSON automatically when your turn ends and sends you any problems. Do not run the validate command yourself.";
|
|
2038
|
+
/** Sin actividad por este tiempo se cierra el proceso del agente (se retoma con `session/load`). */
|
|
2039
|
+
var IDLE_CLOSE_MS = 1800 * 1e3;
|
|
2040
|
+
var MAX_EVENTS = 4e3;
|
|
2041
|
+
/** Si el usuario no responde un permiso pendiente, se rechaza para no colgar el turno. */
|
|
2042
|
+
var PERMISSION_TIMEOUT_MS = 120 * 1e3;
|
|
2043
|
+
var sessions = /* @__PURE__ */ new Map();
|
|
2044
|
+
process.once("exit", () => {
|
|
2045
|
+
for (const entry of sessions.values()) entry.acp?.close();
|
|
2046
|
+
});
|
|
2047
|
+
function listAcpAgents() {
|
|
2048
|
+
return detectAgents().filter(supportsAcp).map((a) => ({
|
|
2049
|
+
id: a.def.id,
|
|
2050
|
+
label: a.def.label,
|
|
2051
|
+
path: a.path,
|
|
2052
|
+
models: modelChoices(a.def).options
|
|
2053
|
+
}));
|
|
2054
|
+
}
|
|
2055
|
+
var modelsCache = /* @__PURE__ */ new Map();
|
|
2056
|
+
/**
|
|
2057
|
+
* Modelos que ofrece el agente por ACP (abre una sesión de prueba; tarda unos segundos).
|
|
2058
|
+
* Solo cachea listas no vacías. `force` invalida la caché. Vacío o error → se puede reintentar.
|
|
2059
|
+
*/
|
|
2060
|
+
function agentModels(agentId, cwd, opts) {
|
|
2061
|
+
if (opts?.force) modelsCache.delete(agentId);
|
|
2062
|
+
let hit = modelsCache.get(agentId);
|
|
2063
|
+
if (!hit) {
|
|
2064
|
+
const agent = findAgent(agentId);
|
|
2065
|
+
hit = listAcpModelsCached(agent, {
|
|
2066
|
+
cwd,
|
|
2067
|
+
force: Boolean(opts?.force)
|
|
2068
|
+
}).then((result) => {
|
|
2069
|
+
if (!result.models.length) modelsCache.delete(agentId);
|
|
2070
|
+
const { fromCache: _fromCache, ...rest } = result;
|
|
2071
|
+
return rest;
|
|
2072
|
+
}).catch((err) => {
|
|
2073
|
+
modelsCache.delete(agentId);
|
|
2074
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
2075
|
+
const auth = authFailureMessage(agentId, agent.def.label, raw);
|
|
2076
|
+
throw new Error(auth || raw);
|
|
2077
|
+
});
|
|
2078
|
+
modelsCache.set(agentId, hit);
|
|
2079
|
+
}
|
|
2080
|
+
return hit;
|
|
2081
|
+
}
|
|
2082
|
+
function findAgent(agentId) {
|
|
2083
|
+
const agent = detectAgents().find((a) => a.def.id === agentId);
|
|
2084
|
+
if (!agent) throw new Error(`No encontré el agente "${agentId}" instalado.`);
|
|
2085
|
+
if (!supportsAcp(agent)) throw new Error(`${agent.def.label} no tiene modo ACP.`);
|
|
2086
|
+
return agent;
|
|
2087
|
+
}
|
|
2088
|
+
function info(entry) {
|
|
2089
|
+
const { id, agentId, agentLabel, model, repo, branch, base, outputFilename, acpSessionId, status, busy, statusMessage, sessionAllowDenied } = entry;
|
|
2090
|
+
return {
|
|
2091
|
+
id,
|
|
2092
|
+
agentId,
|
|
2093
|
+
agentLabel,
|
|
2094
|
+
model,
|
|
2095
|
+
repo,
|
|
2096
|
+
branch,
|
|
2097
|
+
base,
|
|
2098
|
+
outputFilename,
|
|
2099
|
+
acpSessionId,
|
|
2100
|
+
status,
|
|
2101
|
+
busy,
|
|
2102
|
+
sessionTrust: Boolean(sessionAllowDenied),
|
|
2103
|
+
...statusMessage ? { statusMessage } : {}
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
function authFailureMessage(agentId, agentLabel, detail) {
|
|
2107
|
+
return authFailureMessage$1(agentId, agentLabel, detail);
|
|
2108
|
+
}
|
|
2109
|
+
/** Abre una terminal con el login del agente (Claude `/login`, `gh auth login`, …). */
|
|
2110
|
+
function openAgentLogin(agentId) {
|
|
2111
|
+
return openLoginTerminal(String(agentId || ""));
|
|
2112
|
+
}
|
|
2113
|
+
/** Escribe seed (JSON + .diff) en `.diff-review/` para que el agente los lea. */
|
|
2114
|
+
function materializeSeed(entry) {
|
|
2115
|
+
const seed = entry.seed;
|
|
2116
|
+
if (!seed) return;
|
|
2117
|
+
try {
|
|
2118
|
+
ensureReviewDir(entry.repo);
|
|
2119
|
+
const paths = companionPaths(entry.repo, entry.outputFilename);
|
|
2120
|
+
if (seed.payload !== void 0) writeFileSync(paths.json, `${JSON.stringify(seed.payload, null, 2)}\n`);
|
|
2121
|
+
if (typeof seed.diff === "string" && seed.diff.length) writeDiffText(entry.repo, entry.outputFilename, seed.diff);
|
|
2122
|
+
else writeDiffCache(entry.repo, entry.outputFilename, {
|
|
2123
|
+
branch: entry.branch,
|
|
2124
|
+
base: entry.base
|
|
2125
|
+
});
|
|
2126
|
+
} catch {}
|
|
2127
|
+
}
|
|
2128
|
+
/** Prefijo del primer mensaje cuando la sesión ACP es nueva. */
|
|
2129
|
+
function contextPreamble(entry) {
|
|
2130
|
+
materializeSeed(entry);
|
|
2131
|
+
const paths = companionPaths(entry.repo, entry.outputFilename);
|
|
2132
|
+
const jsonRel = outputRelPath(entry.outputFilename);
|
|
2133
|
+
const diffRel = `${outputRelPath(entry.outputFilename).replace(/\.json$/i, "")}.diff`;
|
|
2134
|
+
const hasJson = existsSync(paths.json);
|
|
2135
|
+
const hasDiff = existsSync(paths.diff);
|
|
2136
|
+
return [
|
|
2137
|
+
"Context for this chat (new agent session — read these files before answering):",
|
|
2138
|
+
`- Repo: ${entry.repo}`,
|
|
2139
|
+
`- Branch under review: ${entry.branch}`,
|
|
2140
|
+
`- Base: ${entry.base}`,
|
|
2141
|
+
hasJson ? `- Review JSON (relative to repo root): \`${jsonRel}\` — read this file.` : `- Review JSON: not available on disk (no seed/payload was provided). Say so if you need it.`,
|
|
2142
|
+
hasDiff ? `- Diff (relative to repo root): \`${diffRel}\` — read this file.` : `- Diff file: not available on disk (no seed/diff and git cache failed). Say so if you need it.`,
|
|
2143
|
+
"",
|
|
2144
|
+
"User message:"
|
|
2145
|
+
].join("\n");
|
|
2146
|
+
}
|
|
2147
|
+
function permissionReasonLabel(allow, scope, deniedReason) {
|
|
2148
|
+
if (!allow) return deniedReason;
|
|
2149
|
+
if (scope === "always") return "permitido siempre";
|
|
2150
|
+
if (scope === "session") return "permitido en esta sesión";
|
|
2151
|
+
return "permitido una vez";
|
|
2152
|
+
}
|
|
2153
|
+
function askUserPermission(entry, toolCall, decision) {
|
|
2154
|
+
if (entry.sessionAllowDenied) return Promise.resolve({
|
|
2155
|
+
allow: true,
|
|
2156
|
+
always: false
|
|
2157
|
+
});
|
|
2158
|
+
const requestId = crypto.randomUUID();
|
|
2159
|
+
const kind = toolCall.kind || "";
|
|
2160
|
+
const command = toolCallCommand(toolCall);
|
|
2161
|
+
const title = toolCallLabel(toolCall, command) || (command ? "" : toolCall.title || kind || "herramienta");
|
|
2162
|
+
const reason = humanizePolicyReason(decision.reason);
|
|
2163
|
+
const base = {
|
|
2164
|
+
requestId,
|
|
2165
|
+
title,
|
|
2166
|
+
kind,
|
|
2167
|
+
reason,
|
|
2168
|
+
command: command || void 0
|
|
2169
|
+
};
|
|
2170
|
+
emit(entry, {
|
|
2171
|
+
type: "permission",
|
|
2172
|
+
...base,
|
|
2173
|
+
allow: false,
|
|
2174
|
+
pending: true
|
|
2175
|
+
});
|
|
2176
|
+
return new Promise((resolve) => {
|
|
2177
|
+
const timer = setTimeout(() => {
|
|
2178
|
+
entry.pendingPermissions.delete(requestId);
|
|
2179
|
+
emit(entry, {
|
|
2180
|
+
type: "permission",
|
|
2181
|
+
...base,
|
|
2182
|
+
reason: "tiempo agotado",
|
|
2183
|
+
allow: false,
|
|
2184
|
+
pending: false
|
|
2185
|
+
});
|
|
2186
|
+
resolve({ allow: false });
|
|
2187
|
+
}, PERMISSION_TIMEOUT_MS);
|
|
2188
|
+
timer.unref?.();
|
|
2189
|
+
entry.pendingPermissions.set(requestId, {
|
|
2190
|
+
resolve: (allow, opts) => {
|
|
2191
|
+
clearTimeout(timer);
|
|
2192
|
+
entry.pendingPermissions.delete(requestId);
|
|
2193
|
+
const scope = opts?.scope ?? "once";
|
|
2194
|
+
if (allow && (scope === "session" || scope === "always")) entry.sessionAllowDenied = true;
|
|
2195
|
+
emit(entry, {
|
|
2196
|
+
type: "permission",
|
|
2197
|
+
...base,
|
|
2198
|
+
reason: permissionReasonLabel(allow, scope, reason),
|
|
2199
|
+
allow,
|
|
2200
|
+
pending: false,
|
|
2201
|
+
quiet: opts?.quiet
|
|
2202
|
+
});
|
|
2203
|
+
resolve({
|
|
2204
|
+
allow,
|
|
2205
|
+
always: allow && scope === "always"
|
|
2206
|
+
});
|
|
2207
|
+
},
|
|
2208
|
+
timer
|
|
2209
|
+
});
|
|
2210
|
+
});
|
|
2211
|
+
}
|
|
2212
|
+
function sessionInfo(id) {
|
|
2213
|
+
const entry = sessions.get(id);
|
|
2214
|
+
return entry ? info(entry) : null;
|
|
2215
|
+
}
|
|
2216
|
+
/**
|
|
2217
|
+
* Con la CLI mirando (`DIFF_REVIEW_MIRROR=1`), solo fallos duros a la terminal.
|
|
2218
|
+
* El progreso (tools, permisos, mensajes) vive en la web.
|
|
2219
|
+
*/
|
|
2220
|
+
function mirror(_entry, event) {
|
|
2221
|
+
if (process.env.DIFF_REVIEW_MIRROR !== "1") return;
|
|
2222
|
+
if (event.type === "status" && event.status === "error") console.log(`✗ ${event.message || "error del agente"}`);
|
|
2223
|
+
else if (event.type === "review" && event.ok) console.log(`✓ JSON válido: ${event.filename}`);
|
|
2224
|
+
}
|
|
2225
|
+
function emit(entry, event) {
|
|
2226
|
+
let seq;
|
|
2227
|
+
if (event.type === "text" && entry.textSeq?.kind === event.kind) seq = entry.textSeq.seq;
|
|
2228
|
+
else {
|
|
2229
|
+
seq = ++entry.seq;
|
|
2230
|
+
entry.textSeq = event.type === "text" ? {
|
|
2231
|
+
kind: event.kind,
|
|
2232
|
+
seq
|
|
2233
|
+
} : null;
|
|
2234
|
+
}
|
|
2235
|
+
const full = {
|
|
2236
|
+
...event,
|
|
2237
|
+
seq
|
|
2238
|
+
};
|
|
2239
|
+
mirror(entry, event);
|
|
2240
|
+
entry.events = applyEvent(entry.events, full);
|
|
2241
|
+
if (entry.events.length > MAX_EVENTS) entry.events = entry.events.slice(-4e3);
|
|
2242
|
+
for (const listener of entry.listeners) listener(full);
|
|
2243
|
+
}
|
|
2244
|
+
function setStatus(entry, status, message) {
|
|
2245
|
+
entry.status = status;
|
|
2246
|
+
if (status === "error") entry.statusMessage = message || entry.statusMessage || "Error del agente.";
|
|
2247
|
+
else if (message !== void 0) delete entry.statusMessage;
|
|
2248
|
+
emit(entry, {
|
|
2249
|
+
type: "status",
|
|
2250
|
+
status,
|
|
2251
|
+
...message || status === "error" ? { message: message || entry.statusMessage } : {}
|
|
2252
|
+
});
|
|
2253
|
+
}
|
|
2254
|
+
/** SessionUpdate de ACP → evento de la web (lo que no se muestra se descarta). */
|
|
2255
|
+
function toEvent(update) {
|
|
2256
|
+
switch (update.sessionUpdate) {
|
|
2257
|
+
case "agent_message_chunk":
|
|
2258
|
+
case "agent_thought_chunk": {
|
|
2259
|
+
const content = update.content;
|
|
2260
|
+
if (content.type !== "text" || !content.text) return null;
|
|
2261
|
+
return {
|
|
2262
|
+
type: "text",
|
|
2263
|
+
kind: update.sessionUpdate === "agent_message_chunk" ? "message" : "thought",
|
|
2264
|
+
text: content.text
|
|
2265
|
+
};
|
|
2266
|
+
}
|
|
2267
|
+
case "tool_call":
|
|
2268
|
+
case "tool_call_update": return {
|
|
2269
|
+
type: "tool",
|
|
2270
|
+
id: update.toolCallId,
|
|
2271
|
+
title: update.title ?? "",
|
|
2272
|
+
kind: update.kind ?? "",
|
|
2273
|
+
status: update.status ?? ""
|
|
2274
|
+
};
|
|
2275
|
+
case "plan": return {
|
|
2276
|
+
type: "plan",
|
|
2277
|
+
entries: update.entries.map((e) => ({
|
|
2278
|
+
content: e.content,
|
|
2279
|
+
status: e.status
|
|
2280
|
+
}))
|
|
2281
|
+
};
|
|
2282
|
+
default: return null;
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
function scheduleIdleClose(entry) {
|
|
2286
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
2287
|
+
entry.idleTimer = setTimeout(() => {
|
|
2288
|
+
if (entry.busy || !entry.acp) return;
|
|
2289
|
+
entry.acp.close();
|
|
2290
|
+
entry.acp = null;
|
|
2291
|
+
}, IDLE_CLOSE_MS);
|
|
2292
|
+
entry.idleTimer.unref?.();
|
|
2293
|
+
}
|
|
2294
|
+
/** Aplica el modelo pedido a la sesión ACP viva (o no-op si ya está). */
|
|
2295
|
+
async function applyAcpModel(entry, acp, model) {
|
|
2296
|
+
const wanted = String(model || "").trim();
|
|
2297
|
+
if (!wanted) return acp;
|
|
2298
|
+
const result = await acp.setModel(wanted);
|
|
2299
|
+
if (result.currentModel && !result.currentModel.startsWith("default")) entry.model = result.currentModel;
|
|
2300
|
+
if (result.warning) emit(entry, {
|
|
2301
|
+
type: "status",
|
|
2302
|
+
status: entry.status,
|
|
2303
|
+
message: result.warning
|
|
2304
|
+
});
|
|
2305
|
+
return acp;
|
|
2306
|
+
}
|
|
2307
|
+
/** Proceso del agente vivo para esta entrada: lo lanza, o lo relanza retomando la sesión. */
|
|
2308
|
+
async function ensureAcp(entry, model) {
|
|
2309
|
+
if (entry.acp) return applyAcpModel(entry, entry.acp, model);
|
|
2310
|
+
if (entry.starting) return applyAcpModel(entry, await entry.starting, model);
|
|
2311
|
+
entry.starting = startAcpSession(entry.agent, {
|
|
2312
|
+
cwd: entry.repo,
|
|
2313
|
+
model,
|
|
2314
|
+
resumeSessionId: entry.acpSessionId || void 0,
|
|
2315
|
+
decide: (toolCall) => decidePermission(toolCall, {
|
|
2316
|
+
repo: entry.repo,
|
|
2317
|
+
outputFilename: entry.outputFilename,
|
|
2318
|
+
mode: entry.mode
|
|
2319
|
+
}),
|
|
2320
|
+
onUpdate: (update) => {
|
|
2321
|
+
if (!entry.acp && entry.acpSessionId) return;
|
|
2322
|
+
const event = toEvent(update);
|
|
2323
|
+
if (event) emit(entry, event);
|
|
2324
|
+
},
|
|
2325
|
+
onDeniedPermission: (toolCall, decision) => askUserPermission(entry, toolCall, decision),
|
|
2326
|
+
onPermission: (toolCall, decision) => {
|
|
2327
|
+
if (decision.allow) return;
|
|
2328
|
+
entry.rejections.push(`${toolCall.title || toolCall.kind || "herramienta"} (${decision.reason})`);
|
|
2329
|
+
},
|
|
2330
|
+
onExit: ({ code, stderr }) => {
|
|
2331
|
+
const wasBusy = entry.busy;
|
|
2332
|
+
entry.acp = null;
|
|
2333
|
+
if (wasBusy) {
|
|
2334
|
+
entry.busy = false;
|
|
2335
|
+
const auth = authFailureMessage(entry.agentId, entry.agentLabel, stderr);
|
|
2336
|
+
if (auth) {
|
|
2337
|
+
setStatus(entry, "error", auth);
|
|
2338
|
+
return;
|
|
2339
|
+
}
|
|
2340
|
+
const raw = stderr.trim() || `código ${code ?? "?"}`;
|
|
2341
|
+
const authFromCode = authFailureMessage(entry.agentId, entry.agentLabel, raw);
|
|
2342
|
+
if (authFromCode) {
|
|
2343
|
+
setStatus(entry, "error", authFromCode);
|
|
2344
|
+
return;
|
|
2345
|
+
}
|
|
2346
|
+
const tail = stderr.trim().split(/\r?\n/).slice(-3).join(" · ");
|
|
2347
|
+
setStatus(entry, "error", `${entry.agentLabel} terminó (código ${code ?? "?"}). ${tail}`.trim());
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
}).then((acp) => {
|
|
2351
|
+
const wanted = entry.acpSessionId;
|
|
2352
|
+
entry.acp = acp;
|
|
2353
|
+
entry.acpSessionId = acp.sessionId;
|
|
2354
|
+
if (wanted && !acp.resumed) {
|
|
2355
|
+
entry.needsContext = true;
|
|
2356
|
+
emit(entry, {
|
|
2357
|
+
type: "status",
|
|
2358
|
+
status: entry.status,
|
|
2359
|
+
message: "No pude retomar la sesión anterior del agente: abro una nueva y le paso el contexto de la review."
|
|
2360
|
+
});
|
|
2361
|
+
}
|
|
2362
|
+
try {
|
|
2363
|
+
writeSessionRef(entry.repo, entry.outputFilename, {
|
|
2364
|
+
agentId: entry.agentId,
|
|
2365
|
+
sessionId: acp.sessionId,
|
|
2366
|
+
...entry.model ? { model: entry.model } : {}
|
|
2367
|
+
});
|
|
2368
|
+
} catch {}
|
|
2369
|
+
if (acp.currentModel && !acp.currentModel.startsWith("default")) entry.model = acp.currentModel;
|
|
2370
|
+
if (acp.modelWarning) emit(entry, {
|
|
2371
|
+
type: "status",
|
|
2372
|
+
status: entry.status,
|
|
2373
|
+
message: acp.modelWarning
|
|
2374
|
+
});
|
|
2375
|
+
return acp;
|
|
2376
|
+
}).finally(() => {
|
|
2377
|
+
entry.starting = null;
|
|
2378
|
+
});
|
|
2379
|
+
return applyAcpModel(entry, await entry.starting, model);
|
|
2380
|
+
}
|
|
2381
|
+
function mtime(path) {
|
|
2382
|
+
try {
|
|
2383
|
+
return statSync(path).mtimeMs;
|
|
2384
|
+
} catch {
|
|
2385
|
+
return 0;
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
/** Cuántas veces el server le pide solo al agente corregir/escribir el JSON antes de cortar con Reintentar. */
|
|
2389
|
+
var OUTPUT_AUTO_RETRIES = 2;
|
|
2390
|
+
/**
|
|
2391
|
+
* Después de un turno que podía escribir el JSON: validarlo, estamparle la sesión y, si quedó mal, pedirle al
|
|
2392
|
+
* agente que lo corrija (hasta `OUTPUT_AUTO_RETRIES` veces).
|
|
2393
|
+
*/
|
|
2394
|
+
function checkEntryReport(entry) {
|
|
2395
|
+
const path = companionPaths(entry.repo, entry.outputFilename).json;
|
|
2396
|
+
if (!existsSync(path)) return null;
|
|
2397
|
+
return {
|
|
2398
|
+
path,
|
|
2399
|
+
result: checkReportFile(path, {
|
|
2400
|
+
repo: entry.repo,
|
|
2401
|
+
branch: entry.branch,
|
|
2402
|
+
base: entry.base,
|
|
2403
|
+
...entry.diffPaths?.length ? { paths: entry.diffPaths } : {}
|
|
2404
|
+
})
|
|
2405
|
+
};
|
|
2406
|
+
}
|
|
2407
|
+
/** Prompt cuando el JSON existe pero no pasa validación (auto-fix o Reintentar). */
|
|
2408
|
+
function invalidJsonFixPrompt(entry, path, result) {
|
|
2409
|
+
return `${formatCheckReport(path, validateCliCommand(entry.outputFilename), result)}
|
|
2410
|
+
|
|
2411
|
+
Critical: do NOT only bump meta.generatedAt or rewrite notes. Edit \`${outputRelPath(entry.outputFilename).replace(/\\/g, "/")}\` so every problem above is fixed (add blocks or skipped for each missing path). Do not claim success until coverage is complete.
|
|
2412
|
+
|
|
2413
|
+
${TOOL_RULES}
|
|
2414
|
+
|
|
2415
|
+
${SERVER_VALIDATES}`;
|
|
2416
|
+
}
|
|
2417
|
+
function noWriteFollowUpPrompt(entry) {
|
|
2418
|
+
const rel = outputRelPath(entry.outputFilename);
|
|
2419
|
+
const rejected = entry.rejections.length ? `These commands were rejected by diff-review's policy and did not run:\n${entry.rejections.slice(-5).map((r) => `- ${r}`).join("\n")}\n\n` : "";
|
|
2420
|
+
const posixRel = rel.replace(/\\/g, "/");
|
|
2421
|
+
const existing = checkEntryReport(entry);
|
|
2422
|
+
return `${rejected}Your turn ended without writing \`${posixRel}\` in this turn (mtime must change now). The user did not stop you.
|
|
2423
|
+
${existing && !existing.result.ok ? `\nThe file on disk is also INVALID — fix these when you Write/Edit (do not only bump generatedAt):\n${(existing.result.errors || []).map((e) => `- ${e}`).join("\n")}\n` : ""}
|
|
2424
|
+
Do this now:
|
|
2425
|
+
1. Read \`${posixRel}\` if it exists (required before Write/Edit on an existing file).
|
|
2426
|
+
2. Edit or Write the full review JSON to that same path (forward slashes only).
|
|
2427
|
+
Do NOT mkdir, and do NOT use shell redirection. Do not claim success just because an older file is present — overwrite it.
|
|
2428
|
+
|
|
2429
|
+
${TOOL_RULES}
|
|
2430
|
+
|
|
2431
|
+
${SERVER_VALIDATES}`;
|
|
2432
|
+
}
|
|
2433
|
+
async function checkOutput(entry, since, retriesLeft) {
|
|
2434
|
+
const path = companionPaths(entry.repo, entry.outputFilename).json;
|
|
2435
|
+
if (!(existsSync(path) && mtime(path) >= since)) {
|
|
2436
|
+
if (retriesLeft > 0) return checkOutput(entry, await followUp(entry, noWriteFollowUpPrompt(entry), entry.rejections.length ? "Seguí: hubo comandos rechazados; escribí el JSON con la tool de archivos." : "Seguí: el JSON no se escribió en este turno."), retriesLeft - 1);
|
|
2437
|
+
if (entry.mode === "review") emit(entry, {
|
|
2438
|
+
type: "review",
|
|
2439
|
+
ok: false,
|
|
2440
|
+
filename: entry.outputFilename,
|
|
2441
|
+
errors: [`${entry.agentLabel} terminó sin escribir ${entry.outputFilename}.`],
|
|
2442
|
+
final: true
|
|
2443
|
+
});
|
|
2444
|
+
return;
|
|
2445
|
+
}
|
|
2446
|
+
const result = checkReportFile(path, {
|
|
2447
|
+
repo: entry.repo,
|
|
2448
|
+
branch: entry.branch,
|
|
2449
|
+
base: entry.base,
|
|
2450
|
+
...entry.diffPaths?.length ? { paths: entry.diffPaths } : {}
|
|
2451
|
+
});
|
|
2452
|
+
if (result.ok) {
|
|
2453
|
+
stampReviewProvenance(path, {
|
|
2454
|
+
agent: entry.agentLabel,
|
|
2455
|
+
model: entry.model,
|
|
2456
|
+
agentSession: {
|
|
2457
|
+
agentId: entry.agentId,
|
|
2458
|
+
sessionId: entry.acpSessionId,
|
|
2459
|
+
outputFilename: entry.outputFilename
|
|
2460
|
+
}
|
|
2461
|
+
});
|
|
2462
|
+
emit(entry, {
|
|
2463
|
+
type: "review",
|
|
2464
|
+
ok: true,
|
|
2465
|
+
filename: entry.outputFilename,
|
|
2466
|
+
errors: [],
|
|
2467
|
+
final: true
|
|
2468
|
+
});
|
|
2469
|
+
return;
|
|
2470
|
+
}
|
|
2471
|
+
const canRetry = retriesLeft > 0;
|
|
2472
|
+
emit(entry, {
|
|
2473
|
+
type: "review",
|
|
2474
|
+
ok: false,
|
|
2475
|
+
filename: entry.outputFilename,
|
|
2476
|
+
errors: result.errors,
|
|
2477
|
+
final: !canRetry
|
|
2478
|
+
});
|
|
2479
|
+
if (!canRetry) return;
|
|
2480
|
+
await checkOutput(entry, await followUp(entry, invalidJsonFixPrompt(entry, path, result), "Corregí los errores de validación del JSON."), retriesLeft - 1);
|
|
2481
|
+
}
|
|
2482
|
+
/** Turno extra que pide el server (corregir el JSON, seguir después de un rechazo). Devuelve cuándo empezó. */
|
|
2483
|
+
async function followUp(entry, prompt, shown) {
|
|
2484
|
+
emit(entry, {
|
|
2485
|
+
type: "user",
|
|
2486
|
+
text: shown,
|
|
2487
|
+
mode: "fix"
|
|
2488
|
+
});
|
|
2489
|
+
const acp = await ensureAcp(entry);
|
|
2490
|
+
const started = Date.now();
|
|
2491
|
+
entry.rejections = [];
|
|
2492
|
+
const { stopReason } = await acp.prompt(prompt);
|
|
2493
|
+
emit(entry, {
|
|
2494
|
+
type: "turn",
|
|
2495
|
+
stopReason
|
|
2496
|
+
});
|
|
2497
|
+
return started;
|
|
2498
|
+
}
|
|
2499
|
+
async function runTurn(entry, text, mode, opts) {
|
|
2500
|
+
if (entry.busy) throw new Error("El agente todavía está trabajando en el pedido anterior.");
|
|
2501
|
+
entry.busy = true;
|
|
2502
|
+
entry.mode = mode;
|
|
2503
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
2504
|
+
emit(entry, {
|
|
2505
|
+
type: "user",
|
|
2506
|
+
text: opts.shown,
|
|
2507
|
+
mode: mode === "review" ? "review" : mode
|
|
2508
|
+
});
|
|
2509
|
+
try {
|
|
2510
|
+
if (!entry.acp) setStatus(entry, "starting", `Lanzando ${entry.agentLabel}…`);
|
|
2511
|
+
const acp = await ensureAcp(entry, opts.model);
|
|
2512
|
+
setStatus(entry, "working", "El agente está revisando…");
|
|
2513
|
+
const since = Date.now();
|
|
2514
|
+
entry.rejections = [];
|
|
2515
|
+
const { stopReason } = await acp.prompt(text);
|
|
2516
|
+
emit(entry, {
|
|
2517
|
+
type: "turn",
|
|
2518
|
+
stopReason
|
|
2519
|
+
});
|
|
2520
|
+
if (mode !== "ask" && stopReason !== "cancelled") await checkOutput(entry, since, OUTPUT_AUTO_RETRIES);
|
|
2521
|
+
entry.busy = false;
|
|
2522
|
+
setStatus(entry, "idle");
|
|
2523
|
+
} catch (err) {
|
|
2524
|
+
entry.busy = false;
|
|
2525
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
2526
|
+
setStatus(entry, "error", authFailureMessage(entry.agentId, entry.agentLabel, raw) || raw);
|
|
2527
|
+
} finally {
|
|
2528
|
+
scheduleIdleClose(entry);
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
function newEntry(agent, fields) {
|
|
2532
|
+
const acpSessionId = fields.acpSessionId ?? "";
|
|
2533
|
+
const entry = {
|
|
2534
|
+
id: fields.id || crypto.randomUUID(),
|
|
2535
|
+
agentId: agent.def.id,
|
|
2536
|
+
agentLabel: agent.def.label,
|
|
2537
|
+
model: fields.model,
|
|
2538
|
+
repo: fields.repo,
|
|
2539
|
+
branch: fields.branch,
|
|
2540
|
+
base: fields.base,
|
|
2541
|
+
outputFilename: fields.outputFilename,
|
|
2542
|
+
acpSessionId,
|
|
2543
|
+
status: "idle",
|
|
2544
|
+
busy: false,
|
|
2545
|
+
agent,
|
|
2546
|
+
acp: null,
|
|
2547
|
+
starting: null,
|
|
2548
|
+
mode: "ask",
|
|
2549
|
+
events: [],
|
|
2550
|
+
seq: 0,
|
|
2551
|
+
textSeq: null,
|
|
2552
|
+
listeners: /* @__PURE__ */ new Set(),
|
|
2553
|
+
idleTimer: null,
|
|
2554
|
+
rejections: [],
|
|
2555
|
+
needsContext: false,
|
|
2556
|
+
seed: null,
|
|
2557
|
+
diffPaths: null,
|
|
2558
|
+
pendingPermissions: /* @__PURE__ */ new Map(),
|
|
2559
|
+
sessionAllowDenied: false,
|
|
2560
|
+
cancelled: false
|
|
2561
|
+
};
|
|
2562
|
+
sessions.set(entry.id, entry);
|
|
2563
|
+
return entry;
|
|
2564
|
+
}
|
|
2565
|
+
/** Lanza la review con el agente elegido. Devuelve el id de la sesión enseguida; el resto llega por SSE. */
|
|
2566
|
+
function startReview(input) {
|
|
2567
|
+
const entry = newEntry(findAgent(input.agentId), {
|
|
2568
|
+
...input,
|
|
2569
|
+
model: input.model || ""
|
|
2570
|
+
});
|
|
2571
|
+
emit(entry, {
|
|
2572
|
+
type: "phase",
|
|
2573
|
+
phase: "diff",
|
|
2574
|
+
detail: "Calculando el diff…"
|
|
2575
|
+
});
|
|
2576
|
+
setStatus(entry, "starting", "Calculando el diff…");
|
|
2577
|
+
continueReviewAfterDiff(entry, input);
|
|
2578
|
+
return info(entry);
|
|
2579
|
+
}
|
|
2580
|
+
async function continueReviewAfterDiff(entry, input) {
|
|
2581
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
2582
|
+
if (!sessions.has(entry.id) || entry.cancelled) return;
|
|
2583
|
+
let promptOpts = {
|
|
2584
|
+
outputFilename: input.outputFilename,
|
|
2585
|
+
acp: true,
|
|
2586
|
+
diffRelPath: null,
|
|
2587
|
+
files: [],
|
|
2588
|
+
ignored: [],
|
|
2589
|
+
ignoreGlobs: []
|
|
2590
|
+
};
|
|
2591
|
+
try {
|
|
2592
|
+
promptOpts = prepareReviewPromptOpts({
|
|
2593
|
+
repo: input.repo,
|
|
2594
|
+
branch: input.branch,
|
|
2595
|
+
base: input.base,
|
|
2596
|
+
outputFilename: input.outputFilename,
|
|
2597
|
+
acp: true
|
|
2598
|
+
});
|
|
2599
|
+
if (entry.cancelled || !sessions.has(entry.id)) return;
|
|
2600
|
+
entry.diffPaths = (promptOpts.files || []).map((f) => f.path);
|
|
2601
|
+
const n = entry.diffPaths.length;
|
|
2602
|
+
const detail = n > 0 ? `Diff listo · ${n} archivo${n === 1 ? "" : "s"}. Pasando el contexto al agente…` : "Diff listo. Pasando el contexto al agente…";
|
|
2603
|
+
emit(entry, {
|
|
2604
|
+
type: "phase",
|
|
2605
|
+
phase: "diff",
|
|
2606
|
+
detail
|
|
2607
|
+
});
|
|
2608
|
+
setStatus(entry, "starting", detail);
|
|
2609
|
+
} catch (err) {
|
|
2610
|
+
if (entry.cancelled || !sessions.has(entry.id)) return;
|
|
2611
|
+
const msg = `No pude precomputar el diff (${err instanceof Error ? err.message : String(err)}); el agente lo resolverá.`;
|
|
2612
|
+
emit(entry, {
|
|
2613
|
+
type: "phase",
|
|
2614
|
+
phase: "diff",
|
|
2615
|
+
detail: msg
|
|
2616
|
+
});
|
|
2617
|
+
emit(entry, {
|
|
2618
|
+
type: "status",
|
|
2619
|
+
status: entry.status,
|
|
2620
|
+
message: msg
|
|
2621
|
+
});
|
|
2622
|
+
}
|
|
2623
|
+
if (!sessions.has(entry.id) || entry.cancelled) return;
|
|
2624
|
+
emit(entry, {
|
|
2625
|
+
type: "phase",
|
|
2626
|
+
phase: "agent",
|
|
2627
|
+
detail: "El agente está revisando el branch…"
|
|
2628
|
+
});
|
|
2629
|
+
runTurn(entry, `${buildPrompt({
|
|
2630
|
+
repo: input.repo,
|
|
2631
|
+
branch: input.branch,
|
|
2632
|
+
base: input.base,
|
|
2633
|
+
remoteUrl: getRemoteUrl(input.repo) || void 0
|
|
2634
|
+
}, promptOpts)}\n\n${TOOL_RULES}\n\n${SERVER_VALIDATES}`, "review", {
|
|
2635
|
+
model: input.model,
|
|
2636
|
+
shown: `Review de ${input.branch} contra ${input.base}`
|
|
2637
|
+
});
|
|
2638
|
+
}
|
|
2639
|
+
/**
|
|
2640
|
+
* Sesión para chatear sobre un reporte: la que sigue viva en este server, o una nueva que retoma la del agente.
|
|
2641
|
+
* `sessionId` vacío = sesión brand-new (`session/new`) con `needsContext` desde el inicio.
|
|
2642
|
+
* Opcional `seed` (JSON + diff del reporte abierto) para materializarlos en `.diff-review/` antes del primer turno.
|
|
2643
|
+
*/
|
|
2644
|
+
async function openChat(input) {
|
|
2645
|
+
const sessionId = String(input.sessionId || "").trim();
|
|
2646
|
+
if (sessionId) {
|
|
2647
|
+
for (const entry of sessions.values()) if (entry.acpSessionId === sessionId && entry.repo === input.repo) {
|
|
2648
|
+
if (input.seed) entry.seed = input.seed;
|
|
2649
|
+
return info(entry);
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
const entry = newEntry(findAgent(input.agentId), {
|
|
2653
|
+
...input,
|
|
2654
|
+
model: input.model || "",
|
|
2655
|
+
acpSessionId: sessionId
|
|
2656
|
+
});
|
|
2657
|
+
if (input.seed) entry.seed = input.seed;
|
|
2658
|
+
if (!sessionId) entry.needsContext = true;
|
|
2659
|
+
try {
|
|
2660
|
+
if (!entry.acp) setStatus(entry, "starting", `Lanzando ${entry.agentLabel}…`);
|
|
2661
|
+
await ensureAcp(entry, input.model);
|
|
2662
|
+
if (entry.status === "starting") setStatus(entry, "idle");
|
|
2663
|
+
} catch (err) {
|
|
2664
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
2665
|
+
setStatus(entry, "error", authFailureMessage(entry.agentId, entry.agentLabel, raw) || raw);
|
|
2666
|
+
}
|
|
2667
|
+
return info(entry);
|
|
2668
|
+
}
|
|
2669
|
+
function sendMessage(id, text, mode, opts) {
|
|
2670
|
+
const entry = sessions.get(id);
|
|
2671
|
+
if (!entry) throw new Error("La sesión del agente ya no existe. Recargá la página.");
|
|
2672
|
+
const body = text.trim();
|
|
2673
|
+
if (!body) throw new Error("El mensaje está vacío.");
|
|
2674
|
+
if (entry.busy) throw new Error("El agente todavía está trabajando en el pedido anterior.");
|
|
2675
|
+
let agentText = body;
|
|
2676
|
+
if (entry.needsContext) {
|
|
2677
|
+
agentText = `${contextPreamble(entry)}\n${body}`;
|
|
2678
|
+
entry.needsContext = false;
|
|
2679
|
+
entry.seed = null;
|
|
2680
|
+
}
|
|
2681
|
+
runTurn(entry, mode === "edit" ? `${agentText}\n\nApply this by editing ${entry.outputFilename} in place (keep everything else as is). Reply briefly in Spanish with what you changed.\n\n${SERVER_VALIDATES}` : `${agentText}\n\nAnswer in Spanish. This is a question about the review: do not modify any file.`, mode, {
|
|
2682
|
+
shown: body,
|
|
2683
|
+
model: String(opts?.model || "").trim() || void 0
|
|
2684
|
+
});
|
|
2685
|
+
return info(entry);
|
|
2686
|
+
}
|
|
2687
|
+
/**
|
|
2688
|
+
* Reintento manual tras un turno de review que no dejó el JSON válido (p. ej. redirect bloqueado o JSON viejo).
|
|
2689
|
+
* Misma sesión ACP; no recalcula el diff.
|
|
2690
|
+
* Si el JSON ya existe e inválido, reenvía los errores concretos (cobertura faltante, etc.) — no un “escribí de nuevo” genérico.
|
|
2691
|
+
*/
|
|
2692
|
+
function retryReview(id) {
|
|
2693
|
+
const entry = sessions.get(String(id || ""));
|
|
2694
|
+
if (!entry) throw new Error("La sesión del agente ya no existe. Recargá la página.");
|
|
2695
|
+
if (entry.busy) throw new Error("El agente todavía está trabajando en el pedido anterior.");
|
|
2696
|
+
if (entry.status === "error") throw new Error(entry.statusMessage || "La sesión está en error; iniciá una nueva review.");
|
|
2697
|
+
const posixRel = outputRelPath(entry.outputFilename).replace(/\\/g, "/");
|
|
2698
|
+
const existing = checkEntryReport(entry);
|
|
2699
|
+
runTurn(entry, existing && !existing.result.ok ? invalidJsonFixPrompt(entry, existing.path, existing.result) : `Continue the code review. For \`${posixRel}\`: Read it first if it exists, then Edit/Write the full JSON (forward slashes only). Do not mkdir. Never use shell redirection.
|
|
2700
|
+
|
|
2701
|
+
${TOOL_RULES}
|
|
2702
|
+
|
|
2703
|
+
${SERVER_VALIDATES}`, "review", { shown: existing && !existing.result.ok ? "Reintentar: corregir el JSON inválido" : "Reintentar: escribir el JSON de la review" });
|
|
2704
|
+
return info(entry);
|
|
2705
|
+
}
|
|
2706
|
+
/**
|
|
2707
|
+
* Respuesta del usuario a permiso(s) pendiente(s).
|
|
2708
|
+
* `scope`: once (default) | session (no volver a preguntar en esta sesión) | always (ACP allow_always).
|
|
2709
|
+
* `allowAll` / `requestIds` resuelven en lote (misma scope para todos).
|
|
2710
|
+
*/
|
|
2711
|
+
function respondPermission(input) {
|
|
2712
|
+
const entry = sessions.get(String(input.id || ""));
|
|
2713
|
+
if (!entry) throw new Error("La sesión del agente ya no existe. Recargá la página.");
|
|
2714
|
+
const allow = Boolean(input.allow);
|
|
2715
|
+
const scope = input.scope === "session" || input.scope === "always" ? input.scope : "once";
|
|
2716
|
+
if (allow && (scope === "session" || scope === "always")) entry.sessionAllowDenied = true;
|
|
2717
|
+
let ids;
|
|
2718
|
+
if (input.allowAll) ids = [...entry.pendingPermissions.keys()];
|
|
2719
|
+
else if (Array.isArray(input.requestIds) && input.requestIds.length) ids = input.requestIds.map((id) => String(id || "")).filter(Boolean);
|
|
2720
|
+
else {
|
|
2721
|
+
const one = String(input.requestId || "");
|
|
2722
|
+
ids = one ? [one] : [];
|
|
2723
|
+
}
|
|
2724
|
+
if (!ids.length) throw new Error("No hay pedidos de permiso pendientes.");
|
|
2725
|
+
const batch = ids.length > 1 || Boolean(input.allowAll);
|
|
2726
|
+
let resolved = 0;
|
|
2727
|
+
for (const requestId of ids) {
|
|
2728
|
+
const pending = entry.pendingPermissions.get(requestId);
|
|
2729
|
+
if (!pending) {
|
|
2730
|
+
if (!batch) throw new Error("Ese pedido de permiso ya no está pendiente.");
|
|
2731
|
+
continue;
|
|
2732
|
+
}
|
|
2733
|
+
pending.resolve(allow, {
|
|
2734
|
+
quiet: batch,
|
|
2735
|
+
scope
|
|
2736
|
+
});
|
|
2737
|
+
resolved += 1;
|
|
2738
|
+
}
|
|
2739
|
+
if (!resolved) throw new Error("Ese pedido de permiso ya no está pendiente.");
|
|
2740
|
+
if (batch) emit(entry, {
|
|
2741
|
+
type: "permission_summary",
|
|
2742
|
+
allow,
|
|
2743
|
+
count: resolved
|
|
2744
|
+
});
|
|
2745
|
+
return info(entry);
|
|
2746
|
+
}
|
|
2747
|
+
/**
|
|
2748
|
+
* Activa/desactiva “confiar en esta sesión” (no pedir confirmación cuando la política niega).
|
|
2749
|
+
* Al activar, aprueba los pedidos pendientes actuales.
|
|
2750
|
+
*/
|
|
2751
|
+
function setPermissionTrust(id, enabled) {
|
|
2752
|
+
const entry = sessions.get(String(id || ""));
|
|
2753
|
+
if (!entry) throw new Error("La sesión del agente ya no existe. Recargá la página.");
|
|
2754
|
+
entry.sessionAllowDenied = Boolean(enabled);
|
|
2755
|
+
if (enabled) {
|
|
2756
|
+
const ids = [...entry.pendingPermissions.keys()];
|
|
2757
|
+
let resolved = 0;
|
|
2758
|
+
for (const requestId of ids) {
|
|
2759
|
+
const pending = entry.pendingPermissions.get(requestId);
|
|
2760
|
+
if (!pending) continue;
|
|
2761
|
+
pending.resolve(true, {
|
|
2762
|
+
quiet: ids.length > 1,
|
|
2763
|
+
scope: "session"
|
|
2764
|
+
});
|
|
2765
|
+
resolved += 1;
|
|
2766
|
+
}
|
|
2767
|
+
if (resolved > 1) emit(entry, {
|
|
2768
|
+
type: "permission_summary",
|
|
2769
|
+
allow: true,
|
|
2770
|
+
count: resolved
|
|
2771
|
+
});
|
|
2772
|
+
emit(entry, {
|
|
2773
|
+
type: "status",
|
|
2774
|
+
status: entry.status,
|
|
2775
|
+
message: "Confianza de sesión: no se pedirá confirmación de permisos."
|
|
2776
|
+
});
|
|
2777
|
+
} else emit(entry, {
|
|
2778
|
+
type: "status",
|
|
2779
|
+
status: entry.status,
|
|
2780
|
+
message: "Confianza de sesión desactivada: se vuelve a pedir confirmación."
|
|
2781
|
+
});
|
|
2782
|
+
return info(entry);
|
|
2783
|
+
}
|
|
2784
|
+
async function cancelTurn(id) {
|
|
2785
|
+
const entry = sessions.get(id);
|
|
2786
|
+
if (!entry) return;
|
|
2787
|
+
entry.cancelled = true;
|
|
2788
|
+
if (entry.acp && entry.busy) {
|
|
2789
|
+
await entry.acp.cancel();
|
|
2790
|
+
return;
|
|
2791
|
+
}
|
|
2792
|
+
entry.busy = false;
|
|
2793
|
+
setStatus(entry, "error", "Cancelado.");
|
|
2794
|
+
}
|
|
2795
|
+
function subscribe(id, listener) {
|
|
2796
|
+
const entry = sessions.get(id);
|
|
2797
|
+
if (!entry) return null;
|
|
2798
|
+
listener({ type: "reset" });
|
|
2799
|
+
for (const event of entry.events) listener(event);
|
|
2800
|
+
entry.listeners.add(listener);
|
|
2801
|
+
return () => entry.listeners.delete(listener);
|
|
2802
|
+
}
|
|
2803
|
+
/**
|
|
2804
|
+
* Review que pidió la CLI al lanzar el server (`DIFF_REVIEW_AGENT_RUN`, JSON). Se arranca una sola vez, al iniciar,
|
|
2805
|
+
* así corre aunque no se abra el navegador.
|
|
2806
|
+
*/
|
|
2807
|
+
function startPendingAgentRun() {
|
|
2808
|
+
const raw = String(process.env.DIFF_REVIEW_AGENT_RUN || "").trim();
|
|
2809
|
+
if (!raw) return;
|
|
2810
|
+
delete process.env.DIFF_REVIEW_AGENT_RUN;
|
|
2811
|
+
try {
|
|
2812
|
+
startReview(JSON.parse(raw));
|
|
2813
|
+
} catch (err) {
|
|
2814
|
+
console.error("[diff-review agent] no pude lanzar la review pedida por la CLI:", err instanceof Error ? err.message : err);
|
|
2815
|
+
}
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
export { sendMessage as a, setPermissionTrust as b, cancelTurn as c, retryReview as d, openAgentLogin as e, listAcpAgents as f, agentModels as g, sessionInfo as h, subscribe as i, startPendingAgentRun as j, loginCommand as l, openChat as o, respondPermission as r, startReview as s };
|
|
2819
|
+
//# sourceMappingURL=agent-sessions-kcUaLhK5.js.map
|