@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.
Files changed (159) hide show
  1. package/README.md +38 -15
  2. package/bin/diff-review.mjs +151 -34
  3. package/lib/acp-policy.mjs +223 -0
  4. package/lib/acp-policy.test.mjs +136 -0
  5. package/lib/acp-shell.mjs +409 -0
  6. package/lib/acp-shell.test.mjs +158 -0
  7. package/lib/acp.mjs +368 -0
  8. package/lib/acp.test.mjs +58 -0
  9. package/lib/agent-login.mjs +105 -0
  10. package/lib/agents.mjs +87 -14
  11. package/lib/agents.test.mjs +4 -6
  12. package/lib/check-report.mjs +30 -11
  13. package/lib/check-report.test.mjs +1 -0
  14. package/lib/extract-cache.test.mjs +162 -0
  15. package/lib/extract.mjs +197 -1
  16. package/lib/model-label.mjs +263 -0
  17. package/lib/model-label.test.mjs +77 -0
  18. package/lib/models-cache.mjs +75 -0
  19. package/lib/models-cache.test.mjs +50 -0
  20. package/lib/output-name.mjs +8 -0
  21. package/lib/prompt.mjs +168 -22
  22. package/lib/review-dir.mjs +108 -0
  23. package/lib/review-meta.mjs +5 -3
  24. package/lib/review-meta.test.mjs +11 -0
  25. package/lib/review-prep.mjs +63 -0
  26. package/lib/validate.mjs +2 -2
  27. package/lib/wait-output.mjs +46 -23
  28. package/lib/wait-output.test.mjs +24 -1
  29. package/package.json +3 -1
  30. package/web/build/client/_app/immutable/assets/{0.CLQiq3Ed.css → 0.BKRG1f36.css} +1 -1
  31. package/web/build/client/_app/immutable/assets/0.BKRG1f36.css.br +0 -0
  32. package/web/build/client/_app/immutable/assets/{0.CLQiq3Ed.css.gz → 0.BKRG1f36.css.gz} +0 -0
  33. package/web/build/client/_app/immutable/assets/2.tqrMRTM0.css +1 -0
  34. package/web/build/client/_app/immutable/assets/2.tqrMRTM0.css.br +0 -0
  35. package/web/build/client/_app/immutable/assets/2.tqrMRTM0.css.gz +0 -0
  36. package/web/build/client/_app/immutable/assets/3.BT54aVKb.css +1 -0
  37. package/web/build/client/_app/immutable/assets/3.BT54aVKb.css.br +0 -0
  38. package/web/build/client/_app/immutable/assets/3.BT54aVKb.css.gz +0 -0
  39. package/web/build/client/_app/immutable/assets/storage.DqYH0w6R.css +1 -0
  40. package/web/build/client/_app/immutable/assets/storage.DqYH0w6R.css.br +0 -0
  41. package/web/build/client/_app/immutable/assets/storage.DqYH0w6R.css.gz +0 -0
  42. package/web/build/client/_app/immutable/chunks/BXUWMim4.js +213 -0
  43. package/web/build/client/_app/immutable/chunks/BXUWMim4.js.br +0 -0
  44. package/web/build/client/_app/immutable/chunks/BXUWMim4.js.gz +0 -0
  45. package/web/build/client/_app/immutable/chunks/Bu1mm6Oz.js +1 -0
  46. package/web/build/client/_app/immutable/chunks/Bu1mm6Oz.js.br +0 -0
  47. package/web/build/client/_app/immutable/chunks/Bu1mm6Oz.js.gz +0 -0
  48. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js +3 -0
  49. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js.br +0 -0
  50. package/web/build/client/_app/immutable/chunks/D8WOD_eS.js.gz +0 -0
  51. package/web/build/client/_app/immutable/chunks/{BBWAo_yW.js → DPfXzQnm.js} +2 -2
  52. package/web/build/client/_app/immutable/chunks/DPfXzQnm.js.br +0 -0
  53. package/web/build/client/_app/immutable/chunks/DPfXzQnm.js.gz +0 -0
  54. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js +1 -0
  55. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js.br +0 -0
  56. package/web/build/client/_app/immutable/chunks/lUYXRDtZ.js.gz +0 -0
  57. package/web/build/client/_app/immutable/entry/app.CTgiuiU1.js +2 -0
  58. package/web/build/client/_app/immutable/entry/app.CTgiuiU1.js.br +0 -0
  59. package/web/build/client/_app/immutable/entry/app.CTgiuiU1.js.gz +0 -0
  60. package/web/build/client/_app/immutable/entry/start.CFSl0kht.js +1 -0
  61. package/web/build/client/_app/immutable/entry/start.CFSl0kht.js.br +0 -0
  62. package/web/build/client/_app/immutable/entry/start.CFSl0kht.js.gz +0 -0
  63. package/web/build/client/_app/immutable/nodes/0.Dj97JW46.js +1 -0
  64. package/web/build/client/_app/immutable/nodes/0.Dj97JW46.js.br +0 -0
  65. package/web/build/client/_app/immutable/nodes/0.Dj97JW46.js.gz +0 -0
  66. package/web/build/client/_app/immutable/nodes/1.0PQZpSbJ.js +1 -0
  67. package/web/build/client/_app/immutable/nodes/1.0PQZpSbJ.js.br +2 -0
  68. package/web/build/client/_app/immutable/nodes/1.0PQZpSbJ.js.gz +0 -0
  69. package/web/build/client/_app/immutable/nodes/2.DQaoQoy4.js +10 -0
  70. package/web/build/client/_app/immutable/nodes/2.DQaoQoy4.js.br +0 -0
  71. package/web/build/client/_app/immutable/nodes/2.DQaoQoy4.js.gz +0 -0
  72. package/web/build/client/_app/immutable/nodes/3.AciugOtL.js +171 -0
  73. package/web/build/client/_app/immutable/nodes/3.AciugOtL.js.br +0 -0
  74. package/web/build/client/_app/immutable/nodes/3.AciugOtL.js.gz +0 -0
  75. package/web/build/client/_app/version.json +1 -1
  76. package/web/build/client/_app/version.json.br +0 -0
  77. package/web/build/client/_app/version.json.gz +0 -0
  78. package/web/build/handler.js +3 -3
  79. package/web/build/index.js +3 -3
  80. package/web/build/server/chunks/0-DUStDOsr.js +11 -0
  81. package/web/build/server/chunks/{0-SseDy__O.js.map → 0-DUStDOsr.js.map} +1 -1
  82. package/web/build/server/chunks/1-C2qlInYk.js +7 -0
  83. package/web/build/server/chunks/1-C2qlInYk.js.map +1 -0
  84. package/web/build/server/chunks/2-DyE177MK.js +7 -0
  85. package/web/build/server/chunks/2-DyE177MK.js.map +1 -0
  86. package/web/build/server/chunks/3-SO3atuWO.js +7 -0
  87. package/web/build/server/chunks/3-SO3atuWO.js.map +1 -0
  88. package/web/build/server/chunks/_server.ts-CRNhzL2a.js +62 -0
  89. package/web/build/server/chunks/_server.ts-CRNhzL2a.js.map +1 -0
  90. package/web/build/server/chunks/agent-sessions-kcUaLhK5.js +2819 -0
  91. package/web/build/server/chunks/agent-sessions-kcUaLhK5.js.map +1 -0
  92. package/web/build/server/chunks/{remote-1g4ayag-B67QMR_2.js → git-review-CXKcjYgZ.js} +70 -704
  93. package/web/build/server/chunks/git-review-CXKcjYgZ.js.map +1 -0
  94. package/web/build/server/chunks/{handler-B5P65tLk.js → handler-_E0NucKN.js} +3 -3
  95. package/web/build/server/chunks/{handler-B5P65tLk.js.map → handler-_E0NucKN.js.map} +1 -1
  96. package/web/build/server/chunks/hooks.server-D7yPODoc.js +42 -0
  97. package/web/build/server/chunks/hooks.server-D7yPODoc.js.map +1 -0
  98. package/web/build/server/chunks/{index.js-CL95e0Gl.js → index.js-lMtDM8ZQ.js} +3 -5
  99. package/web/build/server/chunks/index.js-lMtDM8ZQ.js.map +1 -0
  100. package/web/build/server/chunks/manifest.js-BIm6jot5.js +58 -0
  101. package/web/build/server/chunks/manifest.js-BIm6jot5.js.map +1 -0
  102. package/web/build/server/chunks/remote-1g4ayag-DmqxwUIz.js +144 -0
  103. package/web/build/server/chunks/remote-1g4ayag-DmqxwUIz.js.map +1 -0
  104. package/web/build/server/chunks/remote-wh6t6j-DZk0W9Pp.js +162 -0
  105. package/web/build/server/chunks/remote-wh6t6j-DZk0W9Pp.js.map +1 -0
  106. package/web/build/server/chunks/validate-BsTKyhsq.js +1073 -0
  107. package/web/build/server/chunks/validate-BsTKyhsq.js.map +1 -0
  108. package/web/build/client/_app/immutable/assets/0.CLQiq3Ed.css.br +0 -0
  109. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css +0 -1
  110. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.br +0 -0
  111. package/web/build/client/_app/immutable/assets/2.CNFDEnjx.css.gz +0 -0
  112. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css +0 -1
  113. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css.br +0 -0
  114. package/web/build/client/_app/immutable/assets/3.PHTPLlZ7.css.gz +0 -0
  115. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css +0 -1
  116. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css.br +0 -0
  117. package/web/build/client/_app/immutable/assets/ThemeToggle.DcT79vPK.css.gz +0 -0
  118. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js.br +0 -0
  119. package/web/build/client/_app/immutable/chunks/BBWAo_yW.js.gz +0 -0
  120. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js +0 -3
  121. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js.br +0 -0
  122. package/web/build/client/_app/immutable/chunks/Dur-vdUf.js.gz +0 -0
  123. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js +0 -176
  124. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js.br +0 -0
  125. package/web/build/client/_app/immutable/chunks/QdZF3nkf.js.gz +0 -0
  126. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js +0 -1
  127. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js.br +0 -0
  128. package/web/build/client/_app/immutable/chunks/tYeNsyIK.js.gz +0 -0
  129. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js +0 -2
  130. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.br +0 -0
  131. package/web/build/client/_app/immutable/entry/app.Dce6uJU3.js.gz +0 -0
  132. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js +0 -1
  133. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js.br +0 -0
  134. package/web/build/client/_app/immutable/entry/start.DnBSC38l.js.gz +0 -0
  135. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js +0 -1
  136. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js.br +0 -1
  137. package/web/build/client/_app/immutable/nodes/0.s5xmjXoS.js.gz +0 -0
  138. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js +0 -1
  139. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.br +0 -0
  140. package/web/build/client/_app/immutable/nodes/1.5Iv4ssDT.js.gz +0 -0
  141. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js +0 -66
  142. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.br +0 -0
  143. package/web/build/client/_app/immutable/nodes/2.BomzYVBF.js.gz +0 -0
  144. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js +0 -170
  145. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.br +0 -0
  146. package/web/build/client/_app/immutable/nodes/3.CeUs4lBX.js.gz +0 -0
  147. package/web/build/server/chunks/0-SseDy__O.js +0 -11
  148. package/web/build/server/chunks/1-2OO2bb7H.js +0 -7
  149. package/web/build/server/chunks/1-2OO2bb7H.js.map +0 -1
  150. package/web/build/server/chunks/2-BJvuqKN6.js +0 -7
  151. package/web/build/server/chunks/2-BJvuqKN6.js.map +0 -1
  152. package/web/build/server/chunks/3-ZIl1rHef.js +0 -7
  153. package/web/build/server/chunks/3-ZIl1rHef.js.map +0 -1
  154. package/web/build/server/chunks/hooks.server-DERFlOe1.js +0 -14
  155. package/web/build/server/chunks/hooks.server-DERFlOe1.js.map +0 -1
  156. package/web/build/server/chunks/index.js-CL95e0Gl.js.map +0 -1
  157. package/web/build/server/chunks/manifest.js-uD_a0079.js +0 -50
  158. package/web/build/server/chunks/manifest.js-uD_a0079.js.map +0 -1
  159. package/web/build/server/chunks/remote-1g4ayag-B67QMR_2.js.map +0 -1
@@ -0,0 +1,158 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { checkShell, tokenizeShell } from "./acp-shell.mjs";
7
+
8
+ const win = process.platform === "win32";
9
+ const repo = win ? "C:\\repos\\app" : "/repos/app";
10
+ const out = "diff-rev_75eca0c739_52eb729c8f.json";
11
+ const ok = (cmd) => checkShell(cmd, { repo }).ok;
12
+
13
+ test("tokenizeShell respeta comillas y separa cadenas y pipes", () => {
14
+ const parsed = tokenizeShell(`git show main:a.ts | grep -n "a\\|b|c" ; echo 'x; y' && ls`);
15
+ assert.equal(parsed.ok, true);
16
+ assert.deepEqual(parsed.pipelines, [
17
+ [
18
+ ["git", "show", "main:a.ts"],
19
+ ["grep", "-n", "a\\|b|c"],
20
+ ],
21
+ [["echo", "x; y"]],
22
+ [["ls"]],
23
+ ]);
24
+ });
25
+
26
+ test("tokenizeShell descarta salida a /dev/null y rechaza el resto de redirecciones", () => {
27
+ assert.equal(tokenizeShell("git diff 2>&1").ok, true);
28
+ assert.equal(tokenizeShell("git diff 2>/dev/null || true").ok, true);
29
+ assert.equal(tokenizeShell("git diff &>/dev/null").ok, true);
30
+ assert.equal(tokenizeShell("Get-Content a.txt 2>$null").ok, true);
31
+ assert.equal(tokenizeShell("git diff > x.patch").ok, false);
32
+ assert.equal(tokenizeShell("git diff >> x.patch").ok, false);
33
+ assert.equal(tokenizeShell("cat < a").ok, false);
34
+ assert.equal(tokenizeShell('echo "$(whoami)"').ok, false);
35
+ assert.equal(tokenizeShell("echo `whoami`").ok, false);
36
+ assert.equal(tokenizeShell("sleep 1 & curl x").ok, false);
37
+ assert.equal(tokenizeShell('echo "sin cerrar').ok, false);
38
+ });
39
+
40
+ test("checkShell deja git de lectura, el validate y comandos que solo leen", () => {
41
+ for (const cmd of [
42
+ "git diff main~1...main",
43
+ "git --no-pager log --oneline -5",
44
+ `git -C "${repo}" diff --stat a...b`,
45
+ "git merge-base develop feat/x && git diff --stat develop...feat/x",
46
+ "git show HEAD:lib/a.mjs | head -50",
47
+ "git branch --show-current",
48
+ "git remote get-url origin",
49
+ "git config --get remote.origin.url",
50
+ `npx --yes @jpkme/diff-review validate ${out}`,
51
+ `npx --yes @jpkme/diff-review@latest validate ${out}`,
52
+ `cd "${repo}" && git diff a...b`,
53
+ `cd "${repo}"; git diff`,
54
+ // Lo que mandó Claude en las pruebas.
55
+ `git show main:web/src/routes/+page.svelte | sed -n 560,660p; git show main:web/src/lib/report/storage.ts | sed -n 1,60p`,
56
+ `git show main:web/src/routes/+page.svelte | grep -n -A6 "function updateMeta\\|openNewReport(pendingDoc\\|const ctx"`,
57
+ `grep -rn "validateDocument(" web/src --include="*.ts"`,
58
+ "cat package.json | head -20",
59
+ "ls -la lib",
60
+ "find web/src -name '*.svelte' -type f",
61
+ "wc -l lib/*.mjs",
62
+ "rg -n 'export function' lib",
63
+ "sed -n '10,40p' lib/agents.mjs",
64
+ "git diff a...b -- lib 2>/dev/null || true",
65
+ "Get-Content lib/agents.mjs | Select-Object -First 40",
66
+ "Select-String -Path lib/*.mjs -Pattern 'export'",
67
+ ]) {
68
+ assert.equal(ok(cmd), true, cmd);
69
+ }
70
+ });
71
+
72
+ test("checkShell rechaza escrituras, ejecución y paths fuera del repo", () => {
73
+ const home = win ? "C:\\Users\\me\\.ssh\\id_rsa" : "/home/me/.ssh/id_rsa";
74
+ for (const cmd of [
75
+ "",
76
+ "git checkout main",
77
+ "git branch -D feat",
78
+ "git push",
79
+ "git -c core.pager=sh log",
80
+ "git diff --output=x.patch main",
81
+ "git diff --ext-diff",
82
+ "git grep -O foo",
83
+ "git diff; rm -rf .",
84
+ "git log | xargs rm",
85
+ "git diff > x.patch",
86
+ "GIT_PAGER=sh git log",
87
+ "npm install",
88
+ "curl https://example.com",
89
+ "node -e 'x'",
90
+ `npx --yes @jpkme/diff-review validate ${out} && rm x`,
91
+ "npx --yes other-pkg validate x.json",
92
+ "sed -i 's/a/b/' lib/a.mjs",
93
+ "sed -n 's/a/b/w out' lib/a.mjs",
94
+ "find . -name '*.js' -delete",
95
+ "find . -exec rm {} ;",
96
+ "sort -o out.txt a.txt",
97
+ "uniq a.txt b.txt",
98
+ "rg --pre sh foo",
99
+ `cat "${home}"`,
100
+ "cat ~/.ssh/id_rsa",
101
+ "cat ../otro/secreto",
102
+ `cd "${join(repo, "..")}" && git diff a...b`,
103
+ "cd && cat .bashrc",
104
+ "Remove-Item lib",
105
+ "Get-ChildItem | ForEach-Object { Remove-Item $_ }",
106
+ ]) {
107
+ assert.equal(ok(cmd), false, cmd);
108
+ }
109
+ });
110
+
111
+ test("checkShell sin repo no acepta cd", () => {
112
+ assert.equal(checkShell(`cd "${repo}" && git diff`).ok, false);
113
+ });
114
+
115
+ test("checkShell permite mkdir bajo .diff-review en el repo", () => {
116
+ assert.equal(ok("mkdir .diff-review"), true);
117
+ assert.equal(ok("mkdir -p .diff-review"), true);
118
+ assert.equal(ok("mkdir .diff-review/tmp"), true);
119
+ assert.equal(ok(`mkdir "${join(repo, ".diff-review")}"`), true);
120
+ assert.equal(ok("mkdir lib"), false);
121
+ assert.equal(ok("mkdir .diff-review other"), false);
122
+ assert.equal(ok("New-Item -ItemType Directory .diff-review"), true);
123
+ assert.equal(ok("New-Item -ItemType Directory lib"), false);
124
+ });
125
+
126
+ test("checkShell entiende paths de Git Bash y WSL en Windows", { skip: !win }, () => {
127
+ assert.equal(ok("cat /c/repos/app/lib/a.mjs"), true);
128
+ assert.equal(ok("cat /c/Users/me/.ssh/id_rsa"), false);
129
+ assert.equal(ok("cd /mnt/c/repos/app && ls -la"), true);
130
+ assert.equal(ok("cat /mnt/c/repos/app/lib/a.mjs"), true);
131
+ assert.equal(ok("cd /mnt/c/Users/me && ls"), false);
132
+ assert.equal(ok("cat /mnt/c/Users/me/.ssh/id_rsa"), false);
133
+ });
134
+
135
+ test("checkShell rechaza symlink dentro del repo que apunta afuera", () => {
136
+ const root = mkdtempSync(join(tmpdir(), "dr-shell-"));
137
+ const outside = mkdtempSync(join(tmpdir(), "dr-shell-out-"));
138
+ writeFileSync(join(outside, "secret.txt"), "secret");
139
+ mkdirSync(join(root, "lib"), { recursive: true });
140
+ writeFileSync(join(root, "lib", "ok.mjs"), "ok");
141
+ const link = join(root, "escape");
142
+ try {
143
+ symlinkSync(outside, link, process.platform === "win32" ? "junction" : "dir");
144
+ } catch (err) {
145
+ rmSync(root, { recursive: true, force: true });
146
+ rmSync(outside, { recursive: true, force: true });
147
+ if (err && (err.code === "EPERM" || err.code === "EACCES")) return;
148
+ throw err;
149
+ }
150
+ try {
151
+ assert.equal(checkShell(`cat "${join(root, "lib", "ok.mjs")}"`, { repo: root }).ok, true);
152
+ assert.equal(checkShell(`cat "${join(link, "secret.txt")}"`, { repo: root }).ok, false);
153
+ assert.equal(checkShell(`cd "${link}" && ls`, { repo: root }).ok, false);
154
+ } finally {
155
+ rmSync(root, { recursive: true, force: true });
156
+ rmSync(outside, { recursive: true, force: true });
157
+ }
158
+ });
package/lib/acp.mjs ADDED
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Sesión con un agente por ACP (Agent Client Protocol): JSON-RPC por stdin/stdout del proceso del agente.
3
+ *
4
+ * A diferencia de `runAgent` (una vuelta headless), el proceso queda vivo: la misma sesión sirve para generar la
5
+ * review y después para las preguntas y los cambios que se le pidan desde la web. Cada pedido de permiso pasa por
6
+ * `decide` (ver `acp-policy.mjs`): no hay nadie mirando una terminal para aprobar.
7
+ */
8
+ import { Readable, Writable } from "node:stream";
9
+ import * as acp from "@agentclientprotocol/sdk";
10
+ import { spawnCommand } from "./agents.mjs";
11
+ import { permissionOutcome } from "./acp-policy.mjs";
12
+ import { looksLikeAuthFailure } from "./agent-login.mjs";
13
+ import { readModelsCache, writeModelsCache } from "./models-cache.mjs";
14
+
15
+ /**
16
+ * @typedef {import('./agents.mjs').DetectedAgent} DetectedAgent
17
+ * @typedef {import('@agentclientprotocol/sdk').SessionUpdate} SessionUpdate
18
+ * @typedef {import('@agentclientprotocol/sdk').RequestPermissionRequest} PermissionRequest
19
+ * @typedef {{ allow: boolean, reason: string }} Decision
20
+ * @typedef {{ value: string, name: string }} ModelOption
21
+ * @typedef {{
22
+ * cwd: string,
23
+ * model?: string,
24
+ * resumeSessionId?: string,
25
+ * decide: (toolCall: PermissionRequest['toolCall']) => Decision,
26
+ * onUpdate?: (update: SessionUpdate) => void,
27
+ * onPermission?: (toolCall: PermissionRequest['toolCall'], decision: Decision) => void,
28
+ * // Si la política niega: pedir confirmación al usuario.
29
+ * // `true` / `{ allow: true }` = allow_once; `{ allow: true, always: true }` = allow_always (si el agente lo ofrece).
30
+ * onDeniedPermission?: (
31
+ * toolCall: PermissionRequest['toolCall'],
32
+ * decision: Decision,
33
+ * ) => Promise<boolean | { allow: boolean, always?: boolean }>,
34
+ * onExit?: (info: { code: number | null, stderr: string }) => void,
35
+ * timeoutMs?: number,
36
+ * }} AcpSessionOptions
37
+ * @typedef {{
38
+ * sessionId: string,
39
+ * resumed: boolean,
40
+ * models: ModelOption[],
41
+ * currentModel: string,
42
+ * modelWarning?: string,
43
+ * setModel: (model: string) => Promise<{ currentModel: string, warning?: string }>,
44
+ * prompt: (text: string) => Promise<{ stopReason: string }>,
45
+ * cancel: () => Promise<void>,
46
+ * close: () => void,
47
+ * closed: Promise<void>,
48
+ * stderrTail: () => string,
49
+ * }} AcpSession
50
+ */
51
+
52
+ /** Cuántos caracteres de stderr del agente se guardan para explicar un fallo. */
53
+ const STDERR_TAIL = 4000;
54
+
55
+ /** El agente tiene un modo ACP declarado (no todos los agentes lo traen). */
56
+ export function supportsAcp(agent) {
57
+ return typeof agent?.def?.acp === "function";
58
+ }
59
+
60
+ /**
61
+ * Opción de modelo (`configOptions` con id/categoría "model") de la respuesta de `session/new` o `session/load`.
62
+ * @param {{ configOptions?: any[] | null }} response
63
+ */
64
+ export function modelConfig(response) {
65
+ const option = (response?.configOptions ?? []).find((o) => o?.category === "model" || o?.id === "model");
66
+ if (!option || option.type !== "select") return null;
67
+ const flat = (option.options ?? []).flatMap((o) => (Array.isArray(o?.options) ? o.options : [o]));
68
+ return {
69
+ configId: String(option.id),
70
+ current: String(option.currentValue ?? ""),
71
+ options: flat
72
+ .filter((o, i) => o && typeof o.value === "string" && flat.findIndex((x) => x?.value === o.value) === i)
73
+ .map((o) => ({ value: o.value, name: String(o.name ?? o.value) })),
74
+ };
75
+ }
76
+
77
+ /** Tiempo máximo para initialize + session/new|load (+ set model). */
78
+ const ACP_START_TIMEOUT_MS = 90_000;
79
+
80
+ /**
81
+ * Lanza el agente en modo ACP, negocia y abre (o retoma) una sesión.
82
+ * @param {DetectedAgent} agent
83
+ * @param {AcpSessionOptions} opts
84
+ * @returns {Promise<AcpSession>}
85
+ */
86
+ export async function startAcpSession(agent, opts) {
87
+ if (!supportsAcp(agent)) throw new Error(`${agent.def.label} no tiene modo ACP.`);
88
+ const { file, args, env, sessionMeta } = agent.def.acp({ path: agent.path });
89
+ const child = spawnCommand(file, args, {
90
+ cwd: opts.cwd,
91
+ stdio: ["pipe", "pipe", "pipe"],
92
+ ...(env ? { env: { ...process.env, ...env } } : {}),
93
+ });
94
+
95
+ let stderr = "";
96
+ child.stderr?.on("data", (chunk) => {
97
+ stderr = (stderr + String(chunk)).slice(-STDERR_TAIL);
98
+ });
99
+ child.stdin?.on("error", () => {});
100
+
101
+ const conn = acp
102
+ .client({ name: "diff-review" })
103
+ .onRequest(acp.methods.client.session.requestPermission, async (ctx) => {
104
+ const toolCall = ctx.params.toolCall;
105
+ const decision = safeDecide(opts.decide, toolCall);
106
+ if (decision.allow) {
107
+ opts.onPermission?.(toolCall, decision);
108
+ return permissionOutcome(ctx.params.options, true);
109
+ }
110
+ // Política niega: no rechazar al toque — la UI puede permitir una vez / sesión / siempre.
111
+ let allow = false;
112
+ let always = false;
113
+ if (opts.onDeniedPermission) {
114
+ try {
115
+ const answer = await opts.onDeniedPermission(toolCall, decision);
116
+ if (answer && typeof answer === "object") {
117
+ allow = Boolean(answer.allow);
118
+ always = Boolean(answer.always);
119
+ } else {
120
+ allow = Boolean(answer);
121
+ }
122
+ } catch {
123
+ allow = false;
124
+ always = false;
125
+ }
126
+ }
127
+ const final = {
128
+ allow,
129
+ reason: allow ? (always ? "permitido siempre" : "permitido por el usuario") : decision.reason,
130
+ };
131
+ opts.onPermission?.(toolCall, final);
132
+ return permissionOutcome(ctx.params.options, allow, { always });
133
+ })
134
+ .onNotification(acp.methods.client.session.update, (ctx) => {
135
+ opts.onUpdate?.(ctx.params.update);
136
+ })
137
+ .connect(acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout)));
138
+
139
+ const exited = new Promise((resolve) => {
140
+ child.once("error", (err) => {
141
+ stderr += `\n${err.message}`;
142
+ resolve(null);
143
+ });
144
+ child.once("exit", (code) => resolve(code));
145
+ });
146
+ exited.then((code) => {
147
+ conn.close(new Error(`${agent.def.label} terminó (código ${code ?? "?"}).`));
148
+ opts.onExit?.({ code, stderr });
149
+ });
150
+
151
+ const close = () => {
152
+ conn.close();
153
+ if (child.exitCode == null) child.kill();
154
+ };
155
+
156
+ const timeoutMs = Math.max(15_000, Number(opts.timeoutMs) || ACP_START_TIMEOUT_MS);
157
+ let timedOut = false;
158
+ const timer = setTimeout(() => {
159
+ timedOut = true;
160
+ close();
161
+ }, timeoutMs);
162
+
163
+ try {
164
+ const init = await conn.agent.request(acp.methods.agent.initialize, {
165
+ protocolVersion: acp.PROTOCOL_VERSION,
166
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
167
+ clientInfo: { name: "diff-review", version: "0" },
168
+ });
169
+
170
+ const authMethods = Array.isArray(init.authMethods) ? init.authMethods : [];
171
+ /** Métodos donde el agente maneja el login por `authenticate` (no terminal interactiva). */
172
+ const agentAuthMethods = authMethods.filter((m) => m && (!m.type || m.type === "agent") && m.id);
173
+
174
+ /** Si session/new falla por auth, pedimos `authenticate` una vez y reintentamos. */
175
+ const tryAuthenticate = async () => {
176
+ for (const method of agentAuthMethods) {
177
+ try {
178
+ await conn.agent.request(acp.methods.agent.authenticate, { methodId: method.id });
179
+ return true;
180
+ } catch {
181
+ /* siguiente método */
182
+ }
183
+ }
184
+ return false;
185
+ };
186
+
187
+ const openNewSession = async () => {
188
+ try {
189
+ return await conn.agent.request(acp.methods.agent.session.new, {
190
+ cwd: opts.cwd,
191
+ mcpServers: [],
192
+ ...(sessionMeta ? { _meta: sessionMeta } : {}),
193
+ });
194
+ } catch (err) {
195
+ const msg = err instanceof Error ? err.message : String(err);
196
+ if (agentAuthMethods.length && looksLikeAuthFailure(msg) && (await tryAuthenticate())) {
197
+ return await conn.agent.request(acp.methods.agent.session.new, {
198
+ cwd: opts.cwd,
199
+ mcpServers: [],
200
+ ...(sessionMeta ? { _meta: sessionMeta } : {}),
201
+ });
202
+ }
203
+ throw err;
204
+ }
205
+ };
206
+
207
+ let sessionId = "";
208
+ let response;
209
+ let resumed = false;
210
+ if (opts.resumeSessionId && init.agentCapabilities?.loadSession) {
211
+ try {
212
+ response = await conn.agent.request(acp.methods.agent.session.load, {
213
+ sessionId: opts.resumeSessionId,
214
+ cwd: opts.cwd,
215
+ mcpServers: [],
216
+ ...(sessionMeta ? { _meta: sessionMeta } : {}),
217
+ });
218
+ sessionId = opts.resumeSessionId;
219
+ resumed = true;
220
+ } catch {
221
+ /* la sesión ya no existe del lado del agente: se abre una nueva */
222
+ }
223
+ }
224
+ if (!resumed) {
225
+ response = await openNewSession();
226
+ sessionId = response.sessionId;
227
+ }
228
+
229
+ const models = modelConfig(response);
230
+ let currentModel = models?.current ?? "";
231
+
232
+ /**
233
+ * @param {string} model
234
+ * @returns {Promise<{ currentModel: string, warning?: string }>}
235
+ */
236
+ const setModel = async (model) => {
237
+ const wanted = String(model || "").trim();
238
+ if (!wanted || wanted === currentModel) return { currentModel };
239
+ if (!models) {
240
+ return { currentModel, warning: `El agente no deja elegir el modelo por ACP; usa el suyo.` };
241
+ }
242
+ if (!models.options.some((o) => o.value === wanted)) {
243
+ return {
244
+ currentModel,
245
+ warning: `El agente no ofrece el modelo "${wanted}"; sigue con "${currentModel}".`,
246
+ };
247
+ }
248
+ await conn.agent.request(acp.methods.agent.session.setConfigOption, {
249
+ sessionId,
250
+ configId: models.configId,
251
+ value: wanted,
252
+ });
253
+ currentModel = wanted;
254
+ return { currentModel };
255
+ };
256
+
257
+ let modelWarning;
258
+ const initialWanted = String(opts.model || "").trim();
259
+ if (initialWanted) {
260
+ const applied = await setModel(initialWanted);
261
+ if (applied.warning) modelWarning = applied.warning;
262
+ }
263
+
264
+ clearTimeout(timer);
265
+ return {
266
+ sessionId,
267
+ resumed,
268
+ models: models?.options ?? [],
269
+ get currentModel() {
270
+ return currentModel;
271
+ },
272
+ ...(modelWarning ? { modelWarning } : {}),
273
+ setModel,
274
+ prompt: async (text) => {
275
+ const res = await conn.agent.request(acp.methods.agent.session.prompt, {
276
+ sessionId,
277
+ prompt: [{ type: "text", text }],
278
+ });
279
+ return { stopReason: String(res.stopReason) };
280
+ },
281
+ cancel: () => conn.agent.notify(acp.methods.agent.session.cancel, { sessionId }),
282
+ close,
283
+ closed: conn.closed,
284
+ stderrTail: () => stderr,
285
+ };
286
+ } catch (err) {
287
+ clearTimeout(timer);
288
+ close();
289
+ const tail = stderr.trim().split(/\r?\n/).slice(-8).join("\n");
290
+ if (timedOut) {
291
+ const msg = `Timeout (${Math.round(timeoutMs / 1000)}s) al lanzar ${agent.def.label}. ¿Está autenticado el CLI?`;
292
+ throw new Error(tail ? `${msg}\n${tail}` : msg);
293
+ }
294
+ const reason = err instanceof Error ? err.message : String(err);
295
+ throw new Error(tail ? `${reason}\n${tail}` : reason);
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Modelos ACP con caché en `~/.diff-review/models-cache.json` (TTL 24h).
301
+ * @param {DetectedAgent} agent
302
+ * @param {{ cwd: string, timeoutMs?: number, force?: boolean }} opts
303
+ * @returns {Promise<{ models: ModelOption[], currentModel: string, fromCache: boolean }>}
304
+ */
305
+ export async function listAcpModelsCached(agent, opts) {
306
+ const force = Boolean(opts?.force);
307
+ if (!force) {
308
+ const hit = readModelsCache(agent.def.id);
309
+ if (hit) {
310
+ return { models: hit.models, currentModel: hit.currentModel, fromCache: true };
311
+ }
312
+ }
313
+ const result = await listAcpModels(agent, opts);
314
+ if (result.models.length) writeModelsCache(agent.def.id, result);
315
+ return { ...result, fromCache: false };
316
+ }
317
+
318
+ /**
319
+ * Modelos que ofrece el agente por ACP (abre una sesión, lee las opciones y la cierra; no manda ningún prompt).
320
+ * @param {DetectedAgent} agent
321
+ * @param {{ cwd: string, timeoutMs?: number }} opts
322
+ * @returns {Promise<{ models: ModelOption[], currentModel: string }>}
323
+ */
324
+ export async function listAcpModels(agent, { cwd, timeoutMs = 45_000 }) {
325
+ const ms = Math.max(5_000, Number(timeoutMs) || 45_000);
326
+ /** @type {AcpSession | null} */
327
+ let session = null;
328
+ const pending = startAcpSession(agent, {
329
+ cwd,
330
+ decide: () => ({ allow: false, reason: "solo se listan modelos" }),
331
+ }).then((s) => {
332
+ session = s;
333
+ return s;
334
+ });
335
+ const timeout = new Promise((_, reject) => {
336
+ setTimeout(() => {
337
+ reject(
338
+ new Error(
339
+ `Timeout (${Math.round(ms / 1000)}s) al consultar modelos de ${agent.def.label}. Reintentá en unos segundos.`
340
+ )
341
+ );
342
+ }, ms);
343
+ });
344
+ try {
345
+ const opened = await Promise.race([pending, timeout]);
346
+ const out = { models: opened.models, currentModel: opened.currentModel };
347
+ opened.close();
348
+ session = null;
349
+ return out;
350
+ } catch (err) {
351
+ pending.then((s) => s.close()).catch(() => {});
352
+ try {
353
+ session?.close();
354
+ } catch {
355
+ /* ya cerrada */
356
+ }
357
+ throw err;
358
+ }
359
+ }
360
+
361
+ /** Un error en la política no puede colgar al agente esperando respuesta: se rechaza. */
362
+ function safeDecide(decide, toolCall) {
363
+ try {
364
+ return decide(toolCall);
365
+ } catch (err) {
366
+ return { allow: false, reason: `error en la política: ${err instanceof Error ? err.message : err}` };
367
+ }
368
+ }
@@ -0,0 +1,58 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { AGENTS } from "./agents.mjs";
4
+ import { modelConfig, supportsAcp } from "./acp.mjs";
5
+
6
+ // Lo que devuelve claude-agent-acp en `session/new` (recortado).
7
+ const claudeSession = {
8
+ sessionId: "s-1",
9
+ configOptions: [
10
+ { id: "mode", name: "Mode", category: "mode", type: "select", currentValue: "default", options: [{ value: "default", name: "Default" }] },
11
+ {
12
+ id: "model",
13
+ name: "Model",
14
+ category: "model",
15
+ type: "select",
16
+ currentValue: "default",
17
+ options: [
18
+ { value: "default", name: "Default (recommended)" },
19
+ { value: "opus", name: "Opus" },
20
+ { value: "haiku", name: "Haiku" },
21
+ ],
22
+ },
23
+ ],
24
+ };
25
+
26
+ test("modelConfig lee la opción de modelo de session/new", () => {
27
+ assert.deepEqual(modelConfig(claudeSession), {
28
+ configId: "model",
29
+ current: "default",
30
+ options: [
31
+ { value: "default", name: "Default (recommended)" },
32
+ { value: "opus", name: "Opus" },
33
+ { value: "haiku", name: "Haiku" },
34
+ ],
35
+ });
36
+ });
37
+
38
+ test("modelConfig aplana grupos de opciones y tolera agentes sin modelo", () => {
39
+ const grouped = {
40
+ configOptions: [
41
+ { id: "m", category: "model", type: "select", currentValue: "a", options: [{ group: "g", name: "G", options: [{ value: "a", name: "A" }] }] },
42
+ ],
43
+ };
44
+ assert.deepEqual(modelConfig(grouped)?.options, [{ value: "a", name: "A" }]);
45
+ assert.equal(modelConfig({ configOptions: [] }), null);
46
+ assert.equal(modelConfig({}), null);
47
+ });
48
+
49
+ test("supportsAcp: Claude Code tiene modo ACP", () => {
50
+ const claude = AGENTS.find((a) => a.id === "claude");
51
+ assert.equal(supportsAcp({ def: claude, path: "claude" }), true);
52
+ assert.equal(supportsAcp({ def: { ...claude, acp: undefined }, path: "claude" }), false);
53
+ });
54
+
55
+ test("modelConfig no repite modelos con el mismo valor", () => {
56
+ const dup = { configOptions: [{ id: "model", type: "select", currentValue: "auto", options: [{ value: "auto", name: "Auto" }, { value: "auto", name: "Auto" }, { value: "x", name: "X" }] }] };
57
+ assert.deepEqual(modelConfig(dup)?.options.map((o) => o.value), ["auto", "x"]);
58
+ });
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Login de agentes ACP: mensajes claros + abrir una terminal interactiva cuando se pueda.
3
+ */
4
+ import { spawn } from "node:child_process";
5
+
6
+ /**
7
+ * @typedef {{ bin: string, args: string[], display: string }} LoginCommand
8
+ */
9
+
10
+ /**
11
+ * Comando de login interactivo por agente (null = no hay CLI de login, p. ej. Cursor app).
12
+ * @param {string} agentId
13
+ * @returns {LoginCommand | null}
14
+ */
15
+ export function loginCommand(agentId) {
16
+ switch (agentId) {
17
+ case "claude":
18
+ return { bin: "claude", args: ["/login"], display: "claude /login" };
19
+ case "copilot":
20
+ return { bin: "gh", args: ["auth", "login"], display: "gh auth login" };
21
+ case "codex":
22
+ return { bin: "codex", args: ["login"], display: "codex login" };
23
+ case "gemini":
24
+ return { bin: "gemini", args: [], display: "gemini" }; // el CLI guía el auth al arrancar
25
+ default:
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /** @param {string} text */
31
+ export function looksLikeAuthFailure(text) {
32
+ const s = String(text || "").toLowerCase();
33
+ return /(not\s+)?auth|login|unauthor|not logged|sign[\s-]?in|credentials|token.*(invalid|expired|missing)|please\s+run/.test(
34
+ s
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Mensaje en español si el agente murió / falló por falta de login.
40
+ * @param {string} agentId
41
+ * @param {string} agentLabel
42
+ * @param {string} detail
43
+ * @returns {string | null}
44
+ */
45
+ export function authFailureMessage(agentId, agentLabel, detail) {
46
+ if (!looksLikeAuthFailure(detail)) return null;
47
+ const cmd = loginCommand(agentId);
48
+ if (agentId === "cursor") {
49
+ return `${agentLabel} no está autenticado. Iniciá sesión en la app de Cursor y reintentá.`;
50
+ }
51
+ if (cmd) {
52
+ return `${agentLabel} no está autenticado. Corré \`${cmd.display}\` en una terminal y reintentá.`;
53
+ }
54
+ return `${agentLabel} no está autenticado. Revisá el login del agente y reintentá.`;
55
+ }
56
+
57
+ /**
58
+ * Abre una terminal nueva con el login del agente (Windows / macOS / Linux).
59
+ * @param {string} agentId
60
+ * @returns {{ display: string }}
61
+ */
62
+ export function openLoginTerminal(agentId) {
63
+ const cmd = loginCommand(agentId);
64
+ if (!cmd) {
65
+ throw new Error(
66
+ agentId === "cursor"
67
+ ? "Cursor se autentica desde la app de escritorio, no desde una terminal."
68
+ : "Este agente no tiene un comando de login conocido."
69
+ );
70
+ }
71
+
72
+ const platform = process.platform;
73
+ if (platform === "win32") {
74
+ // `start "title" cmd /k …` — el primer quoted es el título de la ventana.
75
+ spawn("cmd.exe", ["/c", "start", `${cmd.bin} login`, "cmd.exe", "/k", cmd.bin, ...cmd.args], {
76
+ detached: true,
77
+ stdio: "ignore",
78
+ windowsHide: false,
79
+ }).unref();
80
+ } else if (platform === "darwin") {
81
+ const script = `tell application "Terminal" to do script ${JSON.stringify(`${cmd.bin} ${cmd.args.join(" ")}`.trim())}`;
82
+ spawn("osascript", ["-e", script], { detached: true, stdio: "ignore" }).unref();
83
+ } else {
84
+ const line = `${cmd.bin} ${cmd.args.join(" ")}`.trim();
85
+ const tried = [
86
+ ["x-terminal-emulator", ["-e", "bash", "-lc", `${line}; exec bash`]],
87
+ ["gnome-terminal", ["--", "bash", "-lc", `${line}; exec bash`]],
88
+ ["konsole", ["-e", "bash", "-lc", `${line}; exec bash`]],
89
+ ["xterm", ["-e", "bash", "-lc", `${line}; exec bash`]],
90
+ ];
91
+ let ok = false;
92
+ for (const [bin, args] of tried) {
93
+ try {
94
+ spawn(bin, args, { detached: true, stdio: "ignore" }).unref();
95
+ ok = true;
96
+ break;
97
+ } catch {
98
+ /* siguiente */
99
+ }
100
+ }
101
+ if (!ok) throw new Error(`No pude abrir una terminal. Corré a mano: ${cmd.display}`);
102
+ }
103
+
104
+ return { display: cmd.display };
105
+ }