codeep 3.3.3 → 3.4.1

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 (79) hide show
  1. package/dist/acp/commands.d.ts +50 -1
  2. package/dist/acp/commands.js +545 -109
  3. package/dist/acp/protocol.d.ts +14 -5
  4. package/dist/acp/server.d.ts +36 -1
  5. package/dist/acp/server.js +581 -155
  6. package/dist/acp/serverHandlers.d.ts +2 -1
  7. package/dist/acp/serverHandlers.js +3 -0
  8. package/dist/acp/session.d.ts +28 -2
  9. package/dist/acp/session.js +25 -6
  10. package/dist/acp/transport.d.ts +40 -4
  11. package/dist/acp/transport.js +218 -25
  12. package/dist/acp/turns.d.ts +20 -0
  13. package/dist/acp/turns.js +30 -0
  14. package/dist/api/index.js +2 -0
  15. package/dist/api/ollamaNative.d.ts +3 -0
  16. package/dist/api/ollamaNative.js +35 -3
  17. package/dist/config/index.d.ts +21 -4
  18. package/dist/config/index.js +178 -123
  19. package/dist/renderer/agentExecution.d.ts +30 -2
  20. package/dist/renderer/agentExecution.js +248 -92
  21. package/dist/renderer/commands/helpers.d.ts +18 -2
  22. package/dist/renderer/commands/helpers.js +28 -5
  23. package/dist/renderer/commands.d.ts +2 -0
  24. package/dist/renderer/commands.js +180 -64
  25. package/dist/renderer/main.d.ts +41 -0
  26. package/dist/renderer/main.js +181 -80
  27. package/dist/utils/agent.d.ts +69 -4
  28. package/dist/utils/agent.js +416 -248
  29. package/dist/utils/agentChat.js +82 -10
  30. package/dist/utils/agents.d.ts +2 -1
  31. package/dist/utils/agents.js +100 -29
  32. package/dist/utils/auditLog.d.ts +4 -3
  33. package/dist/utils/auditLog.js +92 -9
  34. package/dist/utils/checkpoints.js +11 -6
  35. package/dist/utils/codeReview.js +28 -23
  36. package/dist/utils/codeepCloud.d.ts +14 -2
  37. package/dist/utils/codeepCloud.js +56 -20
  38. package/dist/utils/customCommands.js +7 -2
  39. package/dist/utils/git.d.ts +262 -4
  40. package/dist/utils/git.js +1928 -61
  41. package/dist/utils/gitHookInstaller.d.ts +32 -1
  42. package/dist/utils/gitHookInstaller.js +76 -8
  43. package/dist/utils/gitignore.d.ts +8 -0
  44. package/dist/utils/gitignore.js +41 -10
  45. package/dist/utils/headlessReview.d.ts +11 -0
  46. package/dist/utils/headlessReview.js +33 -5
  47. package/dist/utils/history.d.ts +22 -6
  48. package/dist/utils/history.js +140 -26
  49. package/dist/utils/logger.js +6 -7
  50. package/dist/utils/mcpConfig.d.ts +24 -0
  51. package/dist/utils/mcpConfig.js +36 -5
  52. package/dist/utils/mentions.d.ts +28 -5
  53. package/dist/utils/mentions.js +253 -45
  54. package/dist/utils/personalities.js +16 -6
  55. package/dist/utils/planMode.d.ts +13 -7
  56. package/dist/utils/planMode.js +32 -12
  57. package/dist/utils/projectIntelligence.d.ts +2 -0
  58. package/dist/utils/projectIntelligence.js +27 -8
  59. package/dist/utils/projectPaths.d.ts +53 -0
  60. package/dist/utils/projectPaths.js +146 -0
  61. package/dist/utils/shell.d.ts +119 -0
  62. package/dist/utils/shell.js +417 -45
  63. package/dist/utils/skillBundles.js +17 -7
  64. package/dist/utils/skillBundlesCloud.js +20 -3
  65. package/dist/utils/skills.d.ts +24 -2
  66. package/dist/utils/skills.js +235 -43
  67. package/dist/utils/smartContext.js +97 -23
  68. package/dist/utils/telegramApproval.d.ts +10 -2
  69. package/dist/utils/telegramApproval.js +22 -4
  70. package/dist/utils/toolExecution.d.ts +50 -2
  71. package/dist/utils/toolExecution.js +418 -16
  72. package/dist/utils/toolParsing.d.ts +7 -1
  73. package/dist/utils/toolParsing.js +12 -3
  74. package/dist/utils/userProfile.js +58 -16
  75. package/dist/utils/verify.d.ts +25 -4
  76. package/dist/utils/verify.js +259 -74
  77. package/dist/version.d.ts +1 -1
  78. package/dist/version.js +1 -1
  79. package/package.json +1 -1
@@ -5,11 +5,12 @@
5
5
  * executeTool() dispatches to individual tool handlers.
6
6
  * listDirectory() and htmlToText() are private helpers.
7
7
  * createActionLog() converts a ToolCall+ToolResult into a history ActionLog.
8
+ * trustBearingWrite() names the writes that decide what runs later.
8
9
  */
9
10
  import { existsSync, readdirSync, statSync, lstatSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, rmSync, realpathSync } from 'fs';
10
- import { join, dirname, relative, resolve, isAbsolute, sep } from 'path';
11
+ import { join, dirname, basename, relative, resolve, isAbsolute, sep } from 'path';
11
12
  import { executeCommandAsync } from './shell.js';
12
- import { recordWrite, recordEdit, recordDelete, recordMkdir, recordCommand } from './history.js';
13
+ import { recordWrite, recordEdit, recordDelete, recordMkdir, recordCommand, discardAction, recordResult } from './history.js';
13
14
  import { loadIgnoreRules, isIgnored } from './gitignore.js';
14
15
  import { normalizeToolName } from './toolParsing.js';
15
16
  import { getZaiMcpConfig, getZaiVisionConfig, getMinimaxMcpConfig, callZaiMcp, callZaiVisionApi, callMinimaxApi } from './mcpIntegration.js';
@@ -17,11 +18,13 @@ import { logger } from './logger.js';
17
18
  import { runHook } from './hooks.js';
18
19
  import { checkCommandRateLimit } from './ratelimit.js';
19
20
  import { isMcpToolName, callSessionTool, isVirtualMcpToolName, callSessionVirtualTool } from './mcpRegistry.js';
21
+ import { resolveHooksDirResult } from './gitHookInstaller.js';
20
22
  // SSRF guard (isBlockedIp / assertFetchUrlAllowed) moved to ./ssrfGuard —
21
23
  // shared with shell.ts for curl/wget URL checks. Re-exported here so the
22
24
  // existing tests that import it from toolExecution keep working.
23
25
  export { isBlockedIp, assertFetchUrlAllowed } from './ssrfGuard.js';
24
26
  import { fetchUrlGuarded } from './guardedFetch.js';
27
+ import { AcpRequestError } from '../acp/transport.js';
25
28
  const debug = (...args) => {
26
29
  if (process.env.CODEEP_DEBUG === '1') {
27
30
  logger.debug(args.map(String).join(' '));
@@ -78,6 +81,354 @@ export function validatePath(path, projectRoot) {
78
81
  }
79
82
  return { valid: true, absolutePath };
80
83
  }
84
+ // ── Files that decide what runs later ────────────────────────────────────────
85
+ //
86
+ // Writing one of these is not an edit, it is code execution on a delay:
87
+ //
88
+ // - `.git/` — git honours `core.fsmonitor`, `core.pager`, `diff.external`,
89
+ // `core.hooksPath`, `core.sshCommand` and `credential.helper` by RUNNING
90
+ // the command they name, so the next `git status` Codeep makes for the
91
+ // status line executes whatever `.git/config` says. Scripts in
92
+ // `.git/hooks/` run on the next commit. A worktree's `.git` is a file
93
+ // pointing at the real directory, so the whole name is off limits and not
94
+ // only what sits beneath it.
95
+ // - the repository's hook directory — `.git/hooks/` by default, but
96
+ // `core.hooksPath` moves it, and `.githooks/` and husky's `.husky/` are
97
+ // exactly that convention. A hook there runs on the user's own next
98
+ // `git commit` in their own terminal, long after the agent stopped.
99
+ // - `.codeep/hooks/` — scripts Codeep itself runs around every tool call.
100
+ // - `.codeep/skills/` — a skill's steps are commands Codeep runs when the
101
+ // skill is used (see skillBundles.ts, which loads project skills).
102
+ // - `.codeep/agents/` — a sub-agent definition. Its `tools:` is the
103
+ // allowlist the nested run is checked against and REPLACES the parent's,
104
+ // so it can hand a delegated run a tool this run was restricted from; its
105
+ // `model:` decides which provider the code in that run's context is sent
106
+ // to. Both take effect the next time anything delegates to that name.
107
+ // - `.codeep/mcp_servers.json`, `.mcp.json` — every entry is a command
108
+ // Codeep spawns.
109
+ // - `.codeep/config.json` — Codeep's own settings for this project.
110
+ //
111
+ // `.codeep/commands/` is deliberately NOT here, and must stay out: a custom
112
+ // command is prompt text, expanded into a message to the model, and the model's
113
+ // tool calls then go through every gate in this file. Gating it would put a
114
+ // confirmation in front of writing a prompt. That is also the line the agents
115
+ // directory falls on the other side of: an agent file is prompt text PLUS a
116
+ // tool allowlist and a model, which is the part a prompt cannot change.
117
+ //
118
+ // WHAT THIS DOES NOT COVER, so nobody reads the list above as a boundary:
119
+ // only the tools that take a `path` are gated. `execute_command` reaches the
120
+ // same files through any program that writes one — reviewers proved it with
121
+ // git 2.54 by having the agent write an ordinary `setup.cjs` (not a name on
122
+ // this list, so not gated) and then run `node setup.cjs`, which wrote
123
+ // `.git/config`; `cp`, `tee` and `mv` do it in one step and need no file
124
+ // written first. There is no fix for that here, because a gate that asked
125
+ // about every command able to write a file would be asking about every
126
+ // command.
127
+ //
128
+ // Nor is the classification a property of the file: it is decided at the
129
+ // moment of the write, from where the repository keeps its hooks AT THAT
130
+ // MOMENT. `write_file ci/deploy.sh` in a repository with the default hook
131
+ // directory is an ordinary file and goes through unasked; a later `git config
132
+ // core.hooksPath ci` makes that same file a live `pre-commit` without any
133
+ // further write for this gate to see. The cache below is dropped before every
134
+ // command line so the NEXT write is judged against the new hook directory,
135
+ // but nothing re-judges the writes that already happened — reclassifying them
136
+ // would mean keeping every path a run has written and re-running the gate
137
+ // after each command, and there is still nothing to do about a file already
138
+ // on disk. What remains covered is the write that installs the hook itself:
139
+ // `core.hooksPath` lives in `.git/config`, which this gate confirms.
140
+ //
141
+ // What it IS worth is the case it was built for: a model that writes a hook
142
+ // or a `.git/config` as part of an ordinary-looking edit, with no shell
143
+ // involved at all, which is what a prompt injection reaches for because
144
+ // execute_command is the tool users already watch. That write now stops for a
145
+ // confirmation in every mode. Someone who has approved a shell command has
146
+ // approved a shell command.
147
+ //
148
+ // The reasons are written for the person answering the confirmation prompt:
149
+ // "a config file" tells them nothing, "this decides what git runs" does.
150
+ const GIT_REASON = 'This file controls what commands git runs — core.fsmonitor, core.pager and diff.external are commands git executes for you.';
151
+ const GIT_HOOK_REASON = 'This is a git hook — git runs it on your next commit or push, in your own terminal.';
152
+ /** The same gate, for the repository that pointed `core.hooksPath` at its own
153
+ * root. Every top-level file matches there, so the reason may not say "this
154
+ * is a git hook": it would tell someone editing `package.json` that their
155
+ * package manifest is a hook. What is true of all of them is the directory
156
+ * they sit in, so that is what this says. */
157
+ const HOOKS_AT_ROOT_REASON = "This repository has named its own top level as its git hook directory (core.hooksPath), so git runs files " +
158
+ 'from here by name — writing one can install a hook that runs on your next commit.';
159
+ const CODEEP_HOOK_REASON = 'This file runs on every tool call.';
160
+ const SKILL_REASON = 'This is a skill — its steps are commands Codeep runs whenever the skill is used.';
161
+ const MCP_SERVERS_REASON = 'This file starts MCP servers — every entry is a command Codeep spawns.';
162
+ const AGENT_REASON = 'This file defines a sub-agent — the tools it may use and the model it runs on, every time something delegates to it.';
163
+ const CODEEP_CONFIG_REASON = "This file is Codeep's own configuration for this project.";
164
+ /** The one reason that is not a constant: it carries git's own refusal with
165
+ * it, because over ACP this prompt is the only place the user is ever told
166
+ * which key made Codeep refuse and how to clear it. */
167
+ const unknownHooksReason = (why) => 'Codeep could not ask git where this repository keeps its hooks, so it cannot tell whether this write installs ' +
168
+ `one that runs on your next commit. ${why}`;
169
+ /** Directory names that are a git hook directory by convention, so a repo
170
+ * using one is covered before git is asked anything. `.githooks` is the bare
171
+ * `core.hooksPath` convention and `.husky` is husky's. */
172
+ const HOOK_DIRECTORY_NAMES = new Set(['.githooks', '.husky']);
173
+ /** Tools whose `path` parameter names a file they create, change or remove. */
174
+ const PATH_WRITING_TOOLS = new Set(['write_file', 'edit_file', 'delete_file', 'create_directory']);
175
+ /**
176
+ * A path's segments as the filesystem will match them: lowercased, because
177
+ * macOS and Windows both hand `.GIT/config` to the same file git reads, and
178
+ * without the trailing dots and spaces Windows silently drops — `.git./config`
179
+ * and `.git /config` are two literal directories on POSIX but land in the real
180
+ * `.git` on Windows, which is the whole point of writing them that way. A
181
+ * segment that is nothing but dots or spaces keeps its own spelling, so `..`
182
+ * stays `..` instead of collapsing to nothing.
183
+ */
184
+ function pathSegments(path) {
185
+ return path
186
+ .split(/[\\/]+/)
187
+ .filter(s => s && s !== '.')
188
+ .map(s => (s.replace(/[. ]+$/, '') || s).toLowerCase());
189
+ }
190
+ /**
191
+ * The answer per project, for one run.
192
+ *
193
+ * The comment that used to sit here refused a cache, and it was right about
194
+ * the risk and wrong about the cost: `core.hooksPath` really can change
195
+ * mid-run without any write this gate sees — `execute_command` running
196
+ * `git config core.hooksPath .evil` needs no path-writing tool — but paying
197
+ * for that with a fresh resolution on EVERY path-writing call meant two git
198
+ * subprocesses per tool call. Measured over 100 ordinary writes in a real
199
+ * repository: 2484ms without the cache, 4.7ms with it (24.8ms → 0.05ms per
200
+ * call), all of it on the event loop and almost all of it on the common path
201
+ * where no name matches anything.
202
+ *
203
+ * So the answer is cached and thrown away the moment a command runs, which is
204
+ * the only in-run way the answer can change. Every caller that spawns a
205
+ * command line calls forgetHooksDirectory() first: the execute_command tool
206
+ * below, the ACP path that delegates that tool to the client's terminal (see
207
+ * agent.ts), and the two skill runners. `git config core.hooksPath .evil`
208
+ * followed by `write_file .evil/pre-commit` therefore still finds a cold
209
+ * cache, which is the case the old comment was protecting.
210
+ *
211
+ * Keyed by project root because one process serves several workspaces over
212
+ * ACP, and cleared whole rather than per root because a command line can `cd`
213
+ * into any of them.
214
+ */
215
+ const hooksDirectoryCache = new Map();
216
+ /** Drop the cached hook directories. Call before spawning a command line. */
217
+ export function forgetHooksDirectory() {
218
+ hooksDirectoryCache.clear();
219
+ }
220
+ /**
221
+ * Where this repository's hooks live, as segments relative to the project
222
+ * root — see HooksDirectory.
223
+ *
224
+ * Asking git is the only way to learn where a repo actually keeps them, and
225
+ * gitHookInstaller already does it with a hardened environment: every
226
+ * command-executing key is neutralised, `core.hooksPath` alone is left under
227
+ * the repo's control, and `git rev-parse` only prints a path. So the
228
+ * resolution cannot run anything the repository chose.
229
+ *
230
+ * resolveHooksDirResult() is the variant that does not throw, and the reason
231
+ * it exists: `none` and `unknown` used to be the same null, and reading a
232
+ * refusal as "no hook directory" switched this gate off in exactly the
233
+ * repositories that earned the refusal. It is still wrapped in a try/catch,
234
+ * and ANY throw counts as unknown — the fail-closed answer must not depend on
235
+ * a promise made by another module.
236
+ */
237
+ function hooksDirectorySegments(projectRoot, realRoot) {
238
+ const cached = hooksDirectoryCache.get(projectRoot);
239
+ if (cached !== undefined)
240
+ return cached;
241
+ const answer = resolveHooksDirectory(projectRoot, realRoot);
242
+ hooksDirectoryCache.set(projectRoot, answer);
243
+ return answer;
244
+ }
245
+ function resolveHooksDirectory(projectRoot, realRoot) {
246
+ let result;
247
+ try {
248
+ result = resolveHooksDirResult(projectRoot);
249
+ }
250
+ catch (error) {
251
+ return { kind: 'unknown', reason: error instanceof Error ? error.message : String(error) };
252
+ }
253
+ if (result.kind !== 'hooks')
254
+ return result;
255
+ // Both spellings of the root, as below: git may print the hooks path
256
+ // canonicalised (an absolute `core.hooksPath`, a symlinked checkout), and on
257
+ // macOS that is `/private/var/…` where the root arrived as `/var/…`.
258
+ const rel = insideRoot(projectRoot, result.dir) ?? insideRoot(realRoot, result.dir);
259
+ // `??` and not `||`: '' is the hook directory BEING the project root, which
260
+ // is a repository this has to cover, and pathSegments('') is the empty
261
+ // prefix that says so.
262
+ return rel === null ? { kind: 'none' } : { kind: 'segments', segments: pathSegments(rel) };
263
+ }
264
+ /**
265
+ * What a project-relative path controls, or null when it controls nothing.
266
+ *
267
+ * Matched on path segments rather than on a prefix, so a nested checkout's
268
+ * `vendor/lib/.git/config` is covered the same as the top-level one.
269
+ *
270
+ * `hooksDir` is the repository's own hook directory. It costs a `git
271
+ * rev-parse` the first time it is asked in a run, so it is asked for only once
272
+ * a name has failed to answer.
273
+ */
274
+ function reasonForSegments(relativePath, hooksDir) {
275
+ const segments = pathSegments(relativePath);
276
+ if (segments.includes('.git'))
277
+ return GIT_REASON;
278
+ if (segments.some(s => HOOK_DIRECTORY_NAMES.has(s)))
279
+ return GIT_HOOK_REASON;
280
+ const last = segments[segments.length - 1];
281
+ if (last === '.mcp.json')
282
+ return MCP_SERVERS_REASON;
283
+ for (let i = 0; i < segments.length - 1; i++) {
284
+ if (segments[i] !== '.codeep')
285
+ continue;
286
+ if (segments[i + 1] === 'hooks')
287
+ return CODEEP_HOOK_REASON;
288
+ if (segments[i + 1] === 'skills')
289
+ return SKILL_REASON;
290
+ if (segments[i + 1] === 'agents')
291
+ return AGENT_REASON;
292
+ if (i + 2 !== segments.length)
293
+ continue; // `.codeep/<file>` only, not deeper
294
+ if (segments[i + 1] === 'mcp_servers.json')
295
+ return MCP_SERVERS_REASON;
296
+ if (segments[i + 1] === 'config.json')
297
+ return CODEEP_CONFIG_REASON;
298
+ }
299
+ const hooks = hooksDir();
300
+ // Git could not be asked, so "this is not a hook" is not something anyone
301
+ // knows. A repository gets that answer when its own config names a program
302
+ // git would run, which is the last repository to hand a write through
303
+ // unasked — so every write in it is confirmed until the config is fixed.
304
+ // Noisy, and that is the trade: the alternative is a `core.hooksPath` this
305
+ // cannot see, pointed anywhere, written into silently.
306
+ if (hooks.kind === 'unknown')
307
+ return unknownHooksReason(hooks.reason);
308
+ if (hooks.kind === 'none')
309
+ return null;
310
+ // `every` over the empty prefix is trivially true, which is right: a repo
311
+ // whose `core.hooksPath` IS its root runs `<root>/pre-commit` on the next
312
+ // commit (verified with git 2.54), and that file was going unasked. What
313
+ // would NOT be right is reading that empty prefix as "every path in the
314
+ // project is a hook" — it would put a confirmation in front of every write
315
+ // in the repository. git looks for hooks DIRECTLY in its hook directory, so
316
+ // at the root that is the top level and nothing under it. A hook directory
317
+ // of its own keeps the whole subtree: a hook sources its helpers from
318
+ // beside it, the way husky's `pre-commit` sources `_/husky.sh`.
319
+ const prefix = hooks.segments;
320
+ if (prefix.every((s, i) => segments[i] === s) && (prefix.length > 0 || segments.length === 1)) {
321
+ // Which reason depends on which of those two shapes matched: a hook
322
+ // directory of its own means this file IS one, the root means only that
323
+ // this is the directory git looks in — and that one reports `package.json`
324
+ // along with everything else at the top level.
325
+ return prefix.length > 0 ? GIT_HOOK_REASON : HOOKS_AT_ROOT_REASON;
326
+ }
327
+ return null;
328
+ }
329
+ /** The path with a symlinked ancestor resolved, or null if it cannot be. */
330
+ function realPathThroughLinks(absolutePath) {
331
+ let existing = absolutePath;
332
+ const rest = [];
333
+ for (;;) {
334
+ try {
335
+ lstatSync(existing); // lstat, so a symlink counts as existing
336
+ break;
337
+ }
338
+ catch {
339
+ const parent = dirname(existing);
340
+ if (parent === existing)
341
+ return null;
342
+ rest.unshift(basename(existing));
343
+ existing = parent;
344
+ }
345
+ }
346
+ try {
347
+ return join(realpathSync(existing), ...rest);
348
+ }
349
+ catch {
350
+ return null;
351
+ }
352
+ }
353
+ /**
354
+ * The path relative to `root`, or null when it is not inside it.
355
+ *
356
+ * The empty string is a RESULT, not a miss: it means the path is the root
357
+ * itself. Folding it into null cost the hook gate a real repository — one
358
+ * with `core.hooksPath` set to its own root, where git runs `<root>/pre-commit`
359
+ * on the next commit and the relative path of the hook directory is ''.
360
+ */
361
+ function insideRoot(root, absolutePath) {
362
+ const rel = relative(root, absolutePath);
363
+ return !rel.startsWith('..') && !isAbsolute(rel) ? rel : null;
364
+ }
365
+ /**
366
+ * The tail of the refusal one of these writes gets when the run has nobody to
367
+ * ask — no permission callback at all, which is how `codeep review --fix`
368
+ * runs in CI. agent.ts builds the refusal; headlessReview.ts recognises it by
369
+ * this text so it can say so in the run output rather than leave it buried in
370
+ * the agent's tool log.
371
+ *
372
+ * One constant, in the module that owns the classification rather than in
373
+ * agent.ts, for two reasons: a reworded refusal that stopped matching would
374
+ * put CI back to failing silently, and agent.js is a module the fix-run tests
375
+ * replace wholesale — a constant read from there would have been undefined in
376
+ * exactly the test that guards this.
377
+ */
378
+ export const NO_CONFIRMER_REFUSAL = 'Nobody could be asked to confirm it, so nothing was written.';
379
+ /**
380
+ * What a tool call would write that decides what runs later, or null.
381
+ *
382
+ * Callers use this for two things: to force a confirmation the mode would
383
+ * otherwise skip (see agent.ts), and to tell the person answering it what
384
+ * they are approving.
385
+ *
386
+ * A symlink inside the project can point at one of these names — `ln -s .git
387
+ * tools/cfg` makes a write to `tools/cfg/config` land in the real `.git`, and
388
+ * validatePath allows it because it never leaves the project — so the
389
+ * resolved path is classified alongside the one the model asked for.
390
+ */
391
+ export function trustBearingWrite(toolCall, projectRoot) {
392
+ if (!PATH_WRITING_TOOLS.has(normalizeToolName(toolCall.tool)))
393
+ return null;
394
+ const path = toolCall.parameters?.path;
395
+ if (typeof path !== 'string' || !path)
396
+ return null;
397
+ const absolute = isAbsolute(path) ? resolve(path) : resolve(projectRoot, path);
398
+ let realRoot = projectRoot;
399
+ try {
400
+ realRoot = realpathSync(projectRoot);
401
+ }
402
+ catch {
403
+ // Unreadable root: the given path is all there is to go on.
404
+ }
405
+ const candidates = [absolute];
406
+ const resolved = realPathThroughLinks(absolute);
407
+ if (resolved && resolved !== absolute)
408
+ candidates.push(resolved);
409
+ // Looked up at most once per call, and only if a candidate gets far enough
410
+ // to need it. The cache behind it spans the run; this spans the call, so
411
+ // the two candidates below share one lookup.
412
+ let hooksDir;
413
+ const hooksDirOnce = () => {
414
+ if (hooksDir === undefined)
415
+ hooksDir = hooksDirectorySegments(projectRoot, realRoot);
416
+ return hooksDir;
417
+ };
418
+ for (const candidate of candidates) {
419
+ // Only the part below the project root is classified. The root's own
420
+ // path is not the agent's doing, and on macOS it usually arrives
421
+ // unresolved (/var/folders/… for /private/var/folders/…), so both
422
+ // spellings get a chance to match.
423
+ const rel = insideRoot(projectRoot, candidate) ?? insideRoot(realRoot, candidate);
424
+ const reason = rel && reasonForSegments(rel, hooksDirOnce);
425
+ // The resolved candidate is the stable name for this file, so it keys the
426
+ // answer when there is one.
427
+ if (reason)
428
+ return { path, file: resolved ?? candidate, reason };
429
+ }
430
+ return null;
431
+ }
81
432
  /**
82
433
  * List directory contents, respecting .gitignore rules.
83
434
  * Tracks visited inodes to prevent infinite loops caused by circular symlinks.
@@ -180,9 +531,12 @@ function htmlToText(html) {
180
531
  * `fs` is optional — if provided and the relevant method is defined, file
181
532
  * read/write is delegated to the client. Otherwise we fall back to direct
182
533
  * disk I/O. A delegated call that throws also falls back to disk so a
183
- * single client hiccup doesn't kill the agent loop.
534
+ * single client hiccup doesn't kill the agent loop — except a write the
535
+ * client explicitly refused, which fails the tool (see isRefusedWrite).
536
+ *
537
+ * `signal` stops a running execute_command when it fires.
184
538
  */
185
- export async function executeTool(toolCall, projectRoot, fs, mcpSessionId) {
539
+ export async function executeTool(toolCall, projectRoot, fs, mcpSessionId, signal) {
186
540
  const tool = normalizeToolName(toolCall.tool);
187
541
  const parameters = toolCall.parameters;
188
542
  debug(`Executing tool: ${tool}`, parameters.path || parameters.command || '');
@@ -258,7 +612,7 @@ export async function executeTool(toolCall, projectRoot, fs, mcpSessionId) {
258
612
  }
259
613
  // Wrap the original dispatch so we can run on_error / post_edit hooks
260
614
  // around it without indenting every case.
261
- const result = await dispatchTool(tool, parameters, projectRoot, fs, toolCall);
615
+ const result = await dispatchTool(tool, parameters, projectRoot, fs, toolCall, signal);
262
616
  if (!result.success) {
263
617
  runHook({
264
618
  event: 'on_error',
@@ -283,7 +637,20 @@ export async function executeTool(toolCall, projectRoot, fs, mcpSessionId) {
283
637
  }
284
638
  return result;
285
639
  }
286
- async function dispatchTool(tool, parameters, projectRoot, fs, toolCall) {
640
+ /**
641
+ * True when the client answered fs/write_text_file with an error, i.e. it
642
+ * received the write and said no (read-only buffer, rejected by the user…).
643
+ * Writing the file to disk anyway would leave the editor and the disk
644
+ * disagreeing, and the editor's next save would silently undo the change.
645
+ * "Method not found" is the exception: the client does not implement the
646
+ * method after all, which is the same as having no delegation. A timeout
647
+ * or a broken transport is not an answer either, so both keep the disk
648
+ * fallback.
649
+ */
650
+ function isRefusedWrite(err) {
651
+ return err instanceof AcpRequestError && err.code !== -32601;
652
+ }
653
+ async function dispatchTool(tool, parameters, projectRoot, fs, toolCall, signal) {
287
654
  try {
288
655
  switch (tool) {
289
656
  case 'read_file': {
@@ -342,26 +709,41 @@ async function dispatchTool(tool, parameters, projectRoot, fs, toolCall) {
342
709
  // and lint-on-save reactions consistent. The client is responsible
343
710
  // for creating parent directories — VS Code's WorkspaceEdit does;
344
711
  // for the disk fallback below we do it ourselves.
712
+ // The undo record is taken before the write, so it holds what the
713
+ // file was, and once only: a delegation that falls through to disk
714
+ // keeps it. A write that never happens must not leave it behind.
715
+ let rec = null;
345
716
  if (fs?.writeTextFile) {
346
717
  try {
347
718
  const existed = existsSync(validation.absolutePath);
719
+ rec = recordWrite(validation.absolutePath);
348
720
  await fs.writeTextFile(validation.absolutePath, content);
349
- // Only record after the write actually succeeded — otherwise a
350
- // failed delegation that falls through to disk would double-log.
351
- recordWrite(validation.absolutePath);
721
+ recordResult(rec, content);
352
722
  return { success: true, output: `${existed ? 'Updated' : 'Created'} file: ${path}`, tool, parameters };
353
723
  }
354
724
  catch (err) {
725
+ if (isRefusedWrite(err)) {
726
+ discardAction(rec);
727
+ return { success: false, output: '', error: `The editor refused to write ${path}: ${err.message}`, tool, parameters };
728
+ }
355
729
  debug('fs/write_text_file delegation failed, falling back to disk:', err);
356
730
  // fall through to disk write
357
731
  }
358
732
  }
359
- const dir = dirname(validation.absolutePath);
360
- if (!existsSync(dir))
361
- mkdirSync(dir, { recursive: true });
362
733
  const existed = existsSync(validation.absolutePath);
363
- writeFileSync(validation.absolutePath, content, 'utf-8');
364
- recordWrite(validation.absolutePath);
734
+ if (!rec)
735
+ rec = recordWrite(validation.absolutePath);
736
+ try {
737
+ const dir = dirname(validation.absolutePath);
738
+ if (!existsSync(dir))
739
+ mkdirSync(dir, { recursive: true });
740
+ writeFileSync(validation.absolutePath, content, 'utf-8');
741
+ }
742
+ catch (err) {
743
+ discardAction(rec);
744
+ throw err;
745
+ }
746
+ recordResult(rec, content);
365
747
  return { success: true, output: `${existed ? 'Updated' : 'Created'} file: ${path}`, tool, parameters };
366
748
  }
367
749
  case 'edit_file': {
@@ -407,7 +789,8 @@ async function dispatchTool(tool, parameters, projectRoot, fs, toolCall) {
407
789
  if (matchCount > 1) {
408
790
  return { success: false, output: '', error: `old_text matches ${matchCount} locations in the file. Provide more surrounding context to make it unique (only 1 match allowed).`, tool, parameters };
409
791
  }
410
- recordEdit(validation.absolutePath);
792
+ // Recorded before the write; dropped again if the write never happens.
793
+ const rec = recordEdit(validation.absolutePath);
411
794
  // Function replacer so newText is written literally — a plain-string
412
795
  // replacement interprets $&, $1, $$ etc., which silently corrupts any
413
796
  // edit whose new_text contains `$` (shell vars, template literals, regex).
@@ -415,9 +798,14 @@ async function dispatchTool(tool, parameters, projectRoot, fs, toolCall) {
415
798
  if (fs?.writeTextFile) {
416
799
  try {
417
800
  await fs.writeTextFile(validation.absolutePath, updated);
801
+ recordResult(rec, updated);
418
802
  return { success: true, output: `Edited file: ${path}`, tool, parameters };
419
803
  }
420
804
  catch (err) {
805
+ if (isRefusedWrite(err)) {
806
+ discardAction(rec);
807
+ return { success: false, output: '', error: `The editor refused to write ${path}: ${err.message}`, tool, parameters };
808
+ }
421
809
  debug('fs/write_text_file (in edit_file) failed, falling back to disk:', err);
422
810
  // If we read through the client but write back to disk, the
423
811
  // editor's dirty buffer could be discarded next save. Log and
@@ -426,7 +814,14 @@ async function dispatchTool(tool, parameters, projectRoot, fs, toolCall) {
426
814
  debug('warning: edit_file read via client but writing to disk');
427
815
  }
428
816
  }
429
- writeFileSync(validation.absolutePath, updated, 'utf-8');
817
+ try {
818
+ writeFileSync(validation.absolutePath, updated, 'utf-8');
819
+ }
820
+ catch (err) {
821
+ discardAction(rec);
822
+ throw err;
823
+ }
824
+ recordResult(rec, updated);
430
825
  return { success: true, output: `Edited file: ${path}`, tool, parameters };
431
826
  }
432
827
  case 'delete_file': {
@@ -485,6 +880,12 @@ async function dispatchTool(tool, parameters, projectRoot, fs, toolCall) {
485
880
  const args = parameters.args || [];
486
881
  if (!command)
487
882
  return { success: false, output: '', error: 'Missing required parameter: command', tool, parameters };
883
+ // A command is the one thing in a run that can move this repository's
884
+ // hooks — `git config core.hooksPath .evil` — without any write the
885
+ // gate above sees. Drop the cached answer before it runs, so the next
886
+ // `write_file .evil/pre-commit` asks git again rather than trusting
887
+ // where the hooks were a moment ago.
888
+ forgetHooksDirectory();
488
889
  // Command throttle — guards against agent loops that spawn commands
489
890
  // every iteration (each can be up to 2 minutes of subprocess time).
490
891
  // Rate-limited *after* permission resolution: an allowed command
@@ -498,6 +899,7 @@ async function dispatchTool(tool, parameters, projectRoot, fs, toolCall) {
498
899
  cwd: projectRoot,
499
900
  projectRoot,
500
901
  timeout: 120000,
902
+ signal,
501
903
  });
502
904
  if (result.success)
503
905
  return { success: true, output: result.stdout || '(no output)', tool, parameters };
@@ -6,7 +6,13 @@
6
6
  */
7
7
  import { ToolCall } from './tools';
8
8
  /**
9
- * Normalize tool name to lowercase with underscores
9
+ * Normalize tool name to lowercase with underscores.
10
+ *
11
+ * MCP tools (`<server>__<tool>`) are returned untouched. Their names are the
12
+ * server's, not ours: `brave-search__brave_web_search` or
13
+ * `context7__resolve-library-id` must reach the registry exactly as advertised,
14
+ * or the server lookup and the server's own tool lookup both miss. No built-in
15
+ * tool name contains `__`, so the check cannot catch one of ours.
10
16
  */
11
17
  export declare function normalizeToolName(name: string): string;
12
18
  /**
@@ -11,9 +11,17 @@ const debug = (...args) => {
11
11
  }
12
12
  };
13
13
  /**
14
- * Normalize tool name to lowercase with underscores
14
+ * Normalize tool name to lowercase with underscores.
15
+ *
16
+ * MCP tools (`<server>__<tool>`) are returned untouched. Their names are the
17
+ * server's, not ours: `brave-search__brave_web_search` or
18
+ * `context7__resolve-library-id` must reach the registry exactly as advertised,
19
+ * or the server lookup and the server's own tool lookup both miss. No built-in
20
+ * tool name contains `__`, so the check cannot catch one of ours.
15
21
  */
16
22
  export function normalizeToolName(name) {
23
+ if (name.includes('__'))
24
+ return name;
17
25
  const toolNameMap = {
18
26
  'executecommand': 'execute_command',
19
27
  'execute_command': 'execute_command',
@@ -256,7 +264,8 @@ export function parseToolCalls(response) {
256
264
  // Format 2: <toolcall>toolname{...}
257
265
  const malformedRegex = /<toolcall>(\w+)[\s,]*(?:"parameters"\s*:\s*)?(\{[\s\S]*?\})/gi;
258
266
  while ((match = malformedRegex.exec(response)) !== null) {
259
- const toolName = match[1].toLowerCase();
267
+ // MCP names keep their case — see normalizeToolName.
268
+ const toolName = match[1].includes('__') ? match[1] : match[1].toLowerCase();
260
269
  const actualToolName = TEXT_TOOL_NAME_MAP[toolName] || toolName;
261
270
  try {
262
271
  const parsed = JSON.parse(match[2]);
@@ -271,7 +280,7 @@ export function parseToolCalls(response) {
271
280
  // Format 2b: loose toolname + parameters key
272
281
  const looseRegex = /<toolcall>(\w+)[,\s]+["']?parameters["']?\s*:\s*(\{[\s\S]*?\})(?:<\/toolcall>|<|$)/gi;
273
282
  while ((match = looseRegex.exec(response)) !== null) {
274
- const toolName = match[1].toLowerCase();
283
+ const toolName = match[1].includes('__') ? match[1] : match[1].toLowerCase();
275
284
  const actualToolName = TEXT_TOOL_NAME_MAP[toolName] || toolName;
276
285
  if (toolCalls.some(t => t.tool === actualToolName))
277
286
  continue;