heartcode 0.0.0-stage → 0.1.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.
package/src/tools.js ADDED
@@ -0,0 +1,865 @@
1
+ import fs from 'node:fs'
2
+ import { log } from './log.js'
3
+ import { slug } from './memory.js'
4
+ import { fillArgs } from './skills.js'
5
+ import { prompt } from './prompts.js'
6
+ import path from 'node:path'
7
+ import os from 'node:os'
8
+ import { spawn, execFileSync } from 'node:child_process'
9
+ import { applyEdit } from './edit.js'
10
+ import { readJson } from './config.js'
11
+ import { c, diffLines, renderDiff } from './ui.js'
12
+ import { webFetch, webSearch } from './web.js'
13
+ import { runHooks } from './hooks.js'
14
+ import { parsePatch, applyChunks } from './patch.js'
15
+ import { formatFile } from './format.js'
16
+ import { diagnose } from './lsp.js'
17
+
18
+ const IGNORE = new Set(['.git', 'node_modules', 'dist', 'build', '.next', 'coverage', '.venv', 'venv', '__pycache__', 'target', '.cache', '.DS_Store'])
19
+
20
+ export const clip = (s, n) => (String(s).length > n ? String(s).slice(0, n) + '…' : String(s))
21
+ export const tail = (s, n) => (s.length > n ? '…' + s.slice(-n) : s)
22
+
23
+ // Tool output too long for the conversation is saved here (one folder per chat), where read_file
24
+ // and search can read it although it's outside the project.
25
+ export const OUTPUTS = path.join(os.tmpdir(), 'heartcode-outputs')
26
+ export function saveOutput(chatId, name, text) {
27
+ try {
28
+ const dir = path.join(OUTPUTS, String(chatId ?? 'chat'))
29
+ fs.mkdirSync(dir, { recursive: true })
30
+ const file = path.join(dir, `${Date.now().toString(36)}-${String(name).replace(/[^\w-]/g, '_').slice(0, 60)}.txt`)
31
+ fs.writeFileSync(file, text)
32
+ return file
33
+ } catch (e) {
34
+ log('tool', `could not save the output: ${e.message}`)
35
+ return ''
36
+ }
37
+ }
38
+
39
+ export function resolveIn(root, p) {
40
+ const clean = String(p ?? '').trim().replace(/^["'`]+|["'`]+$/g, '')
41
+ if (!clean) throw new Error('no path given')
42
+ let abs = path.resolve(root, clean)
43
+ if (abs.startsWith(OUTPUTS + path.sep)) return abs // a saved tool output
44
+ // "num.js:" or "num.js," from a sloppy model: the file without the stray mark, if that one exists
45
+ const trimmed = path.resolve(root, clean.replace(/[:;,.]+$/, ''))
46
+ if (trimmed !== abs && !fs.existsSync(abs) && fs.existsSync(trimmed)) abs = trimmed
47
+ if (abs !== root && !abs.startsWith(root + path.sep)) throw new Error(`${clean} is outside the project; use a path relative to the project folder`)
48
+ return abs
49
+ }
50
+
51
+ // Project files a message names (by path, or by file name like "num.js"), most specific first.
52
+ export function namedFiles(text, files) {
53
+ const t = text.toLowerCase()
54
+ return files.filter((f) => {
55
+ const base = path.basename(f).toLowerCase()
56
+ return t.includes(f.toLowerCase()) || (base.includes('.') && new RegExp(`(^|[^\\w./-])${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}($|[^\\w-])`).test(t))
57
+ })
58
+ }
59
+
60
+ // git-aware file list (respects .gitignore), falling back to a plain walk.
61
+ export function listFiles(root, limit = 2000) {
62
+ try {
63
+ const out = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], {
64
+ cwd: root,
65
+ encoding: 'utf8',
66
+ stdio: ['ignore', 'pipe', 'ignore'],
67
+ maxBuffer: 64 << 20,
68
+ })
69
+ return out
70
+ .split('\0')
71
+ .filter((f) => f && fs.existsSync(path.join(root, f)))
72
+ .sort()
73
+ .slice(0, limit)
74
+ } catch {}
75
+ const files = []
76
+ const walk = (dir) => {
77
+ let entries
78
+ try {
79
+ entries = fs.readdirSync(dir, { withFileTypes: true })
80
+ } catch {
81
+ return
82
+ }
83
+ for (const e of entries) {
84
+ if (files.length >= limit) return
85
+ if (IGNORE.has(e.name)) continue
86
+ const p = path.join(dir, e.name)
87
+ if (e.isDirectory()) walk(p)
88
+ else if (e.isFile()) files.push(path.relative(root, p))
89
+ }
90
+ }
91
+ walk(root)
92
+ return files.sort()
93
+ }
94
+
95
+ const isBinary = (buf) => buf.subarray(0, 8000).includes(0)
96
+ export const numberLines = (lines, from = 1) => lines.map((l, i) => `${from + i}| ${l}`).join('\n')
97
+
98
+ export function readFileTool(root, p, start, end, maxLines = 400) {
99
+ const abs = resolveIn(root, p)
100
+ const rel = path.relative(root, abs)
101
+ if (!fs.existsSync(abs)) {
102
+ const base = path.basename(abs)
103
+ const near = listFiles(root).filter((f) => path.basename(f) === base)
104
+ return `error: ${rel} does not exist${near.length ? `. Did you mean: ${near.slice(0, 5).join(', ')}` : ''}`
105
+ }
106
+ if (fs.statSync(abs).isDirectory()) return fs.readdirSync(abs).join('\n') || '(empty directory)'
107
+ const buf = fs.readFileSync(abs)
108
+ if (isBinary(buf)) return `${rel} is a binary file (${buf.length} bytes)`
109
+ const lines = buf.toString('utf8').split('\n')
110
+ const s = Math.max(1, Number(start) || 1)
111
+ let e = Math.min(lines.length, Number(end) || lines.length, s + maxLines - 1)
112
+ // long lines: stop at a line before the result would be cut (12k characters), and say where
113
+ for (let i = s - 1, n = 0; i < e; i++) if ((n += lines[i].length + 8) > 11_000) e = Math.max(s, i)
114
+ const more = e < lines.length ? `\n… (${lines.length} lines total; read with start_line=${e + 1} for more)` : ''
115
+ return numberLines(lines.slice(s - 1, e), s) + more
116
+ }
117
+
118
+ export function searchFiles(root, query, sub = '', limit = 50) {
119
+ const ci = query === query.toLowerCase()
120
+ let re
121
+ try {
122
+ re = new RegExp(query, ci ? 'i' : '')
123
+ } catch {
124
+ re = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), ci ? 'i' : '')
125
+ }
126
+ const abs = sub ? resolveIn(root, sub) : ''
127
+ const saved = abs.startsWith(OUTPUTS + path.sep) // a tool output saved outside the project
128
+ const prefix = sub && !saved ? path.relative(root, abs) : ''
129
+ const hits = []
130
+ for (const f of saved ? [abs] : listFiles(root)) {
131
+ if (prefix && f !== prefix && !f.startsWith(prefix + '/')) continue
132
+ let buf
133
+ try {
134
+ buf = fs.readFileSync(path.resolve(root, f))
135
+ } catch {
136
+ continue
137
+ }
138
+ if (buf.length > 1 << 20 || isBinary(buf)) continue
139
+ const lines = buf.toString('utf8').split('\n')
140
+ for (let i = 0; i < lines.length; i++) {
141
+ if (!re.test(lines[i])) continue
142
+ hits.push(`${f}:${i + 1}: ${clip(lines[i].trim(), 200)}`)
143
+ if (hits.length >= limit) return hits.join('\n') + `\n… (stopped at ${limit} matches)`
144
+ }
145
+ }
146
+ return hits.join('\n') || `no matches for ${query}`
147
+ }
148
+
149
+ // Runs a shell command in its own process group. The command is over when its shell exits, even
150
+ // if something it started in the background (`server &`) still holds the output open; whatever is
151
+ // left in the group is then stopped, so nothing hangs or outlives HeartCode. Esc (signal) and the
152
+ // timeout stop the whole group too.
153
+ // stdin: text for its input; env: added variables; split: stderr returned apart (hooks).
154
+ export function runCommand(root, cmd, { signal, timeout = 120_000, stdin, env, split = false } = {}) {
155
+ return new Promise((resolve) => {
156
+ let out = ''
157
+ let err = ''
158
+ let done = false
159
+ const group = process.platform !== 'win32'
160
+ const child = spawn(cmd, {
161
+ cwd: root,
162
+ shell: true,
163
+ detached: group,
164
+ stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
165
+ env: { ...process.env, CI: '1', FORCE_COLOR: '0', NO_COLOR: '1', ...env },
166
+ })
167
+ if (stdin !== undefined) {
168
+ child.stdin.on('error', () => {}) // a command that doesn't read its input
169
+ child.stdin.end(stdin)
170
+ }
171
+ const kill = (sig) => {
172
+ try {
173
+ if (group) process.kill(-child.pid, sig)
174
+ else child.kill(sig)
175
+ return true
176
+ } catch {
177
+ return false // nothing left
178
+ }
179
+ }
180
+ const finish = (code, note = '') => {
181
+ if (done) return
182
+ done = true
183
+ clearTimeout(timer)
184
+ signal?.removeEventListener('abort', stop)
185
+ if (kill(0) && code !== 'interrupted' && code !== 'timeout') {
186
+ note += '\n(Processes this command left running in the background were stopped: run_command cannot keep a server running.)'
187
+ }
188
+ kill('SIGTERM')
189
+ setTimeout(() => kill('SIGKILL'), 2000).unref()
190
+ child.stdout.destroy()
191
+ child.stderr.destroy()
192
+ const plain = (t) => t.replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '')
193
+ resolve({ code, output: plain(out) + note, ...(split && { stderr: plain(err) }) })
194
+ }
195
+ const add = (d) => {
196
+ out += d
197
+ if (out.length > 200_000) out = out.slice(-100_000)
198
+ }
199
+ const addErr = (d) => {
200
+ err += d
201
+ if (err.length > 200_000) err = err.slice(-100_000)
202
+ }
203
+ const stop = () => finish('interrupted', '\n(stopped by the user)')
204
+ const timer = setTimeout(() => finish('timeout', `\n(stopped after ${timeout / 1000}s)`), timeout)
205
+ signal?.addEventListener('abort', stop, { once: true })
206
+ if (signal?.aborted) stop()
207
+ child.stdout.on('data', add)
208
+ child.stderr.on('data', split ? addErr : add)
209
+ child.on('error', (e) => {
210
+ add(`\n${e.message}`)
211
+ finish('error')
212
+ })
213
+ child.on('exit', (code, sig) => setTimeout(() => finish(code ?? sig), 150)) // last output, then done
214
+ child.on('close', (code, sig) => finish(code ?? sig))
215
+ })
216
+ }
217
+
218
+ export function suggestCommands(root) {
219
+ const out = []
220
+ const has = (f) => fs.existsSync(path.join(root, f))
221
+ const pkg = readJson(path.join(root, 'package.json'), null)
222
+ for (const k of Object.keys(pkg?.scripts ?? {})) out.push(k === 'test' || k === 'start' ? `npm ${k}` : `npm run ${k}`)
223
+ if (has('pyproject.toml') || has('pytest.ini') || has('setup.py')) out.push('python -m pytest')
224
+ if (has('go.mod')) out.push('go test ./...', 'go build ./...')
225
+ if (has('Cargo.toml')) out.push('cargo test', 'cargo build')
226
+ if (has('Makefile')) out.push('make')
227
+ return out.slice(0, 9)
228
+ }
229
+
230
+ const PLACEHOLDER = /(\.\.\.|…)\s*(rest|existing|remaining|unchanged|other)|rest of (the )?(code|file)|existing code (here|unchanged)/i
231
+
232
+ // Shows the diff, asks the user, writes (next = null: deletes the file). Shared by both agent modes.
233
+ export async function proposeWrite(s, rel, old, next) {
234
+ if (old === next) return { status: 'unchanged' }
235
+ if (s.ui.permission === 'plan') return { status: 'rejected', feedback: prompt('agent/plan-refused') } // both modes write through here
236
+ const ops = diffLines(old, next ?? '')
237
+ s.ui.print(renderDiff(ops, rel))
238
+ if (next !== null && PLACEHOLDER.test(next) && !PLACEHOLDER.test(old)) s.ui.warn('the new content seems to contain a placeholder like "rest of code unchanged"')
239
+ const ok = await s.ui.confirm(next === null ? `Delete ${rel}?` : old ? `Apply this change to ${rel}?` : `Create ${rel}?`)
240
+ log('write', rel, ok === true ? 'approved' : `rejected ${typeof ok === 'string' ? ok : ''}`)
241
+ if (ok !== true) return { status: 'rejected', feedback: typeof ok === 'string' ? ok : '' }
242
+ const abs = resolveIn(s.root, rel)
243
+ s.track?.(rel, fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null) // for /rewind
244
+ if (next === null) fs.rmSync(abs, { force: true })
245
+ else {
246
+ fs.mkdirSync(path.dirname(abs), { recursive: true })
247
+ fs.writeFileSync(abs, next)
248
+ }
249
+ const add = ops.filter(([t]) => t === '+').length
250
+ const del = ops.filter(([t]) => t === '-').length
251
+ const res = { status: 'applied', summary: `+${add} -${del}`, diff: renderDiff(ops, rel, false, 2, 60), ops, text: next }
252
+ if (next !== null) await afterWrite(s, rel, old, res)
253
+ return res
254
+ }
255
+
256
+ // After a write: the project's formatter (format.js), then a language server's errors (lsp.js).
257
+ // The model is told both: its next edit must match the formatted text, and errors are its to fix.
258
+ async function afterWrite(s, rel, old, res) {
259
+ const notes = []
260
+ if (!s.noFormat) {
261
+ const f = await formatFile(s.root, rel).catch((e) => (log('format', `${rel}: ${e.message}`), null))
262
+ if (f) {
263
+ res.text = f.text
264
+ res.ops = diffLines(old, f.text)
265
+ res.diff = renderDiff(res.ops, rel, false, 2, 60) // guided mode shows the model this
266
+ res.formatted = true
267
+ s.ui.result(`formatted with ${f.by}`)
268
+ notes.push(`${f.by} formatted ${rel} after the change (the excerpt below is the formatted text).`)
269
+ }
270
+ }
271
+ if (!s.noLsp) {
272
+ s.ui.spinner(`checking ${rel}`)
273
+ const problems = await diagnose(s, rel, res.text)
274
+ s.ui.stopSpin()
275
+ if (problems) {
276
+ const n = /reports (\d+) error/.exec(problems)?.[1]
277
+ s.ui.result(n ? `${n} error${n === '1' ? '' : 's'} from the language server` : 'no errors now')
278
+ notes.push(problems)
279
+ }
280
+ }
281
+ if (notes.length) res.note = notes.join('\n')
282
+ }
283
+
284
+ // A search over the whole disk or the home folder ("grep -r x /", "find ~ -name …"): minutes of
285
+ // waiting for nothing, which weak models do when they've lost track. Refused at once instead.
286
+ const SEARCHES = /^(grep|egrep|rgrep|rg|ag|ack|find|fd|locate|mdfind|du|tree)$/
287
+ export function searchesOutside(cmd, root) {
288
+ const home = process.env.HOME ?? ''
289
+ const wide = (t) => {
290
+ const p = t.replace(/^["']|["']$/g, '').replace(/\/+$/, '') || '/'
291
+ if (['/', '~', '$HOME', '${HOME}'].includes(p) || p === home) return true
292
+ return p.startsWith('/') && root.startsWith(p + '/') // a folder the project is inside
293
+ }
294
+ return String(cmd)
295
+ .split(/[;&|\n]+/)
296
+ .some((part) => {
297
+ const words = part.trim().split(/\s+/).filter((w) => !/^(sudo|nice|timeout|gtimeout|\d+[smh]?)$/.test(w))
298
+ if (!SEARCHES.test(words[0] ?? '') && !(words[0] === 'ls' && words.some((w) => /^-\w*R/.test(w)))) return false
299
+ if (words[0].includes('grep') && !words.some((w) => /^-\w*[rR]|^--(recursive|dereference-recursive)$/.test(w))) return false
300
+ return words.slice(1).some((w) => !w.startsWith('-') && wide(w))
301
+ })
302
+ }
303
+
304
+ export async function runConfirmed(s, cmd, signal) {
305
+ s.ui.step('run', cmd)
306
+ if (s.ui.permission === 'plan' && !readOnlyCommand(cmd)) {
307
+ s.ui.result('not run: plan mode')
308
+ return prompt('agent/plan-refused-command')
309
+ }
310
+ if (searchesOutside(cmd, s.root)) {
311
+ log('command', `refused (searches outside the project): ${cmd}`)
312
+ s.ui.result('not run: searches outside the project')
313
+ return prompt('agent/command-too-wide', { root: s.root })
314
+ }
315
+ const ok = s.ui.permission === 'plan' ? true : await s.ui.confirm(`Run ${c.bold(cmd)}?`, 'command') // plan mode: only read-only ones get here
316
+ if (ok !== true) return `The user declined to run this command.${typeof ok === 'string' ? ' User feedback: ' + ok : ''}`
317
+ const before = snapshot(s)
318
+ s.ui.spinner(`running ${cmd}`)
319
+ const t0 = Date.now()
320
+ const { code, output } = await runCommand(s.root, cmd, { signal })
321
+ log('command', `${cmd} → exit ${code} in ${Date.now() - t0}ms\n${tail(output, 8000)}`)
322
+ s.ui.stopSpin()
323
+ s.ui.result(`exit ${code}`)
324
+ const changed = changesSince(s, before)
325
+ const why = code === 'timeout' ? `\n${prompt('agent/command-timeout', { root: s.root })}` : ''
326
+ return `exit code ${code}\n${tail(output, 4000) || '(no output)'}${changed ? `\nFiles this command changed: ${changed}` : ''}${why}`
327
+ }
328
+
329
+ // Files a shell command changes are tracked like HeartCode's own edits: shown as diffs, kept for
330
+ // /rewind, and told to the model. Text files up to 256 KB, read only when they changed since the
331
+ // last look (the contents are cached per session).
332
+ const SNAP_MAX = 256_000
333
+ function snapshot(s) {
334
+ const cache = (s.fileCache ??= new Map())
335
+ const now = new Map()
336
+ for (const rel of listFiles(s.root)) {
337
+ let st
338
+ try {
339
+ st = fs.statSync(path.join(s.root, rel))
340
+ } catch {
341
+ continue
342
+ }
343
+ const sig = `${st.mtimeMs}:${st.size}`
344
+ now.set(rel, sig)
345
+ if (st.size > SNAP_MAX || cache.get(rel)?.sig === sig) continue
346
+ try {
347
+ const text = fs.readFileSync(path.join(s.root, rel), 'utf8')
348
+ cache.set(rel, { sig, text: text.includes('\0') ? null : text }) // null: binary
349
+ } catch {}
350
+ }
351
+ return now
352
+ }
353
+
354
+ function changesSince(s, before) {
355
+ const was = new Map([...before.keys()].map((rel) => [rel, s.fileCache.get(rel)?.text])) // before the command: text, null (binary), undefined (too big)
356
+ const after = snapshot(s) // refreshes the cache with the new contents
357
+ const notes = []
358
+ for (const rel of new Set([...before.keys(), ...after.keys()])) {
359
+ if (before.get(rel) === after.get(rel)) continue
360
+ const old = was.get(rel)
361
+ const next = after.has(rel) ? s.fileCache.get(rel)?.text : undefined
362
+ let note = !before.has(rel) ? `${rel} (new)` : !after.has(rel) ? `${rel} (deleted)` : rel
363
+ if (!before.has(rel)) s.track?.(rel, null) // /rewind removes it
364
+ else if (typeof old === 'string') s.track?.(rel, old) // /rewind puts this version back
365
+ if (typeof old === 'string' && typeof next === 'string') {
366
+ const ops = diffLines(old, next)
367
+ s.ui.print(renderDiff(ops, rel, undefined, 2, 40))
368
+ note += ` (+${ops.filter(([t]) => t === '+').length} -${ops.filter(([t]) => t === '-').length})`
369
+ } else s.ui.result(note)
370
+ notes.push(note)
371
+ }
372
+ return notes.slice(0, 30).join(', ') + (notes.length > 30 ? `, … ${notes.length - 30} more` : '')
373
+ }
374
+
375
+ export function writeResult(rel, r) {
376
+ if (r.status === 'applied') return `${r.deleted ? 'Deleted' : 'Changed'} ${rel} (${r.summary}).${r.note ? `\n${r.note}` : ''}`
377
+ if (r.status === 'unchanged') return `No change: the new content of ${rel} is identical to the old content.`
378
+ return `The user rejected the change to ${rel}.${r.feedback ? ' User feedback: ' + r.feedback : ''}`
379
+ }
380
+
381
+ // --- native tool calling ---
382
+
383
+ // Kept short: these definitions go with every request.
384
+ const str = { type: 'string' }
385
+ const int = { type: 'integer' }
386
+ const obj = (properties, required = []) => ({ type: 'object', properties, required })
387
+ export const TOOL_DEFS = [
388
+ { name: 'read_file', description: prompt('agent/tool-read_file'), parameters: obj({ path: str, start_line: int, end_line: int }, ['path']) },
389
+ { name: 'search', description: prompt('agent/tool-search'), parameters: obj({ query: str, path: str }, ['query']) },
390
+ { name: 'list_files', description: prompt('agent/tool-list_files'), parameters: obj({ filter: str }) },
391
+ // One edit per call: a list of edits per call measured 2–6 malformed calls per benchmark run vs 0.
392
+ { name: 'edit_file', description: prompt('agent/tool-edit_file'), parameters: obj({ path: str, search: str, replace: str }, ['path', 'search', 'replace']) },
393
+ { name: 'write_file', description: prompt('agent/tool-write_file'), parameters: obj({ path: str, content: str, append: { type: 'boolean' } }, ['path', 'content']) },
394
+ { name: 'run_command', description: prompt('agent/tool-run_command'), parameters: obj({ command: str }, ['command']) },
395
+ { name: 'todo', description: prompt('agent/tool-todo'), parameters: obj({ todos: { type: 'array', items: obj({ content: str, status: { type: 'string', enum: ['pending', 'in_progress', 'completed'] } }, ['content', 'status']) } }, ['todos']) },
396
+ { name: 'web_search', description: prompt('agent/tool-web_search'), parameters: obj({ query: str }, ['query']) },
397
+ { name: 'web_fetch', description: prompt('agent/tool-web_fetch'), parameters: obj({ url: str, start: int }, ['url']) },
398
+ { name: 'ask_user', description: prompt('agent/tool-ask_user'), parameters: obj({ question: str, options: { type: 'array', items: str }, multiple: { type: 'boolean' } }, ['question']) },
399
+ ]
400
+
401
+ // Plan mode (permission "plan"): looking around is fine, changing things isn't. Edits are refused,
402
+ // and a command runs only when it only reads (else it's refused, not asked about).
403
+ export const PLAN_HIDDEN = new Set(['edit_file', 'write_file', 'apply_patch'])
404
+ const READ_ONLY = /^(ls|cat|head|tail|wc|grep|egrep|rg|find|fd|tree|pwd|file|stat|du|df|which|type|echo|date|printenv|diff|cmp|sort|uniq|cut|jq|nl|basename|dirname|realpath|readlink|uname|whoami|(node|npm|python3?|go|cargo) --version|npm (ls|list|view|outdated)|git (status|log|diff|show|ls-files|blame|grep|describe|shortlog|rev-parse|config --get)|git (branch|tag|remote)( -[avlr]+| --list| -v)*$)\b/
405
+ export function readOnlyCommand(cmd) {
406
+ const c = String(cmd).trim()
407
+ if (/[>`]|\$\(|\b(sudo|rm|mv|cp|tee|chmod|chown|dd|truncate|mkdir|touch|ln)\b|-delete\b|-exec|-ok\b|sed\s+-i|\bsort\b.*\s-o/.test(c)) return false // writes, or runs something else
408
+ return c.split(/\s*(?:&&|\|\||;|\|)\s*/).every((part) => READ_ONLY.test(part))
409
+ }
410
+
411
+ // MCP servers' tools as the model sees them: each its own tool, mcp__server__tool (Claude Code's
412
+ // names), with its description and arguments, the way the model would see them in Claude Code.
413
+ // Servers whose tools aren't known yet (still starting, failed), or too many tools at once, go
414
+ // through the one mcp tool instead. Sets s.mcpDirect (name → { server, tool, def }), s.mcpMeta
415
+ // (servers behind the mcp tool) and s.mcpInstructions (what the servers say about using them).
416
+ export const MAX_MCP_TOOLS = 100
417
+ export async function loadMcpTools(s, signal) {
418
+ if (!s.mcp?.names.length) return Object.assign(s, { mcpDirect: new Map(), mcpMeta: [], mcpInstructions: '' })
419
+ const catalog = await s.mcp.catalog(signal)
420
+ // the servers with the fewest tools first, while they fit
421
+ const known = [...catalog].sort((a, b) => a[1].tools.length - b[1].tools.length)
422
+ const direct = new Map()
423
+ const notes = []
424
+ for (const [server, { tools, instructions }] of known) {
425
+ if (direct.size + tools.length > MAX_MCP_TOOLS) continue
426
+ for (const t of tools) direct.set(mcpName(server, t.name), { server, tool: t.name, def: t })
427
+ if (instructions) notes.push(`### ${server}\n${instructions}`)
428
+ }
429
+ const shown = new Set([...direct.values()].map((d) => d.server))
430
+ return Object.assign(s, { mcpDirect: direct, mcpMeta: s.mcp.names.filter((n) => !shown.has(n)), mcpInstructions: notes.join('\n\n'), mcpCatalog: catalog })
431
+ }
432
+ // Tool names may only have letters, digits, _ and - (64 at most).
433
+ export const mcpName = (server, tool) => `mcp__${server}__${tool}`.replace(/[^\w-]/g, '_').slice(0, 64)
434
+ // A schema as providers take it: an object, without the keys some refuse ($schema).
435
+ function cleanSchema(x) {
436
+ if (Array.isArray(x)) return x.map(cleanSchema)
437
+ if (!x || typeof x !== 'object') return x
438
+ return Object.fromEntries(Object.entries(x).filter(([k]) => k !== '$schema').map(([k, v]) => [k, cleanSchema(v)]))
439
+ }
440
+ function mcpDefs(s) {
441
+ return [...(s.mcpDirect?.values() ?? [])].map(({ server, tool, def }) => {
442
+ const schema = cleanSchema(def.inputSchema ?? {})
443
+ return { name: mcpName(server, tool), description: clip(String(def.description ?? `${tool} (${server})`).trim(), 2000), parameters: { ...schema, type: 'object', properties: schema.properties ?? {} } }
444
+ })
445
+ }
446
+
447
+ // The MCP servers' tools, the one tool for the rest, and the skill loader; each only when there's
448
+ // something to offer.
449
+ export function extraTools(s) {
450
+ const out = mcpDefs(s)
451
+ const meta = s.mcpMeta ?? s.mcp?.names ?? []
452
+ if (meta.length) {
453
+ // no description: the names of its tools, when known, say what it's for
454
+ const about = (n) => s.mcp.get(n).cfg.description || (s.mcpCatalog?.get(n) ? `tools: ${clip(s.mcpCatalog.get(n).tools.map((t) => t.name).join(', '), 300)}` : 'no description')
455
+ out.push({ name: 'mcp', description: prompt('agent/tool-mcp', { servers: meta.map((n) => `${n}: ${about(n)}`).join('\n') }), parameters: obj({ server: str, tool: str, arguments: { type: 'object' } }, ['server', 'tool']) })
456
+ }
457
+ if (s.memory) {
458
+ out.push({
459
+ name: 'memory',
460
+ description: prompt('agent/tool-memory'),
461
+ parameters: obj({ action: { type: 'string', enum: ['write', 'read', 'search', 'delete'] }, name: str, description: str, type: str, tags: str, scope: { type: 'string', enum: ['project', 'global'] }, content: str, query: str }, ['action']),
462
+ })
463
+ }
464
+ const forModel = [...(s.skills?.values() ?? [])].filter((k) => k.use !== 'me') // "use: me" skills cost nothing here
465
+ if (forModel.length) {
466
+ const skills = forModel.map((k) => `${k.name}: ${k.description}`).join('\n')
467
+ out.push({ name: 'skill', description: prompt('agent/tool-skill', { skills }), parameters: obj({ name: str }, ['name']) })
468
+ }
469
+ // the patch format OpenAI's models are trained on (they also write it as a shell command, below)
470
+ if (/gpt|codex|\bo\d/i.test(s.client?.model ?? '')) out.push({ name: 'apply_patch', description: prompt('agent/tool-apply_patch'), parameters: obj({ input: str }, ['input']) })
471
+ if (!s.isSub && s.subagent) out.push({ name: 'agent', description: prompt('agent/tool-agent'), parameters: obj({ description: str, prompt: str }, ['description', 'prompt']) })
472
+ return [...out, ...(s.director && !s.appendSystem ? s.director.toolDefs(s) : [])] // manage (director.js); not when a program gives the instructions (--append-system-prompt)
473
+ }
474
+
475
+ // todo: the model's checklist for this message, shown to the user each time it changes.
476
+ // Lenient: the list as JSON text, plain strings for items, "done" for completed.
477
+ const MARK = { pending: '☐', in_progress: '◐', completed: '☑' }
478
+ function todoTool(s, args) {
479
+ let list = args.todos ?? args.items ?? args
480
+ if (typeof list === 'string') {
481
+ try {
482
+ list = JSON.parse(list)
483
+ } catch {
484
+ list = list.split('\n').filter((l) => l.trim())
485
+ }
486
+ }
487
+ if (!Array.isArray(list)) return 'error: todos must be a list of {content, status}'
488
+ const todos = list
489
+ .map((t) => (typeof t === 'string' ? { content: t, status: 'pending' } : { content: String(t?.content ?? t?.task ?? t?.title ?? ''), status: String(t?.status ?? 'pending') }))
490
+ .filter((t) => t.content.trim())
491
+ .map((t) => ({ ...t, status: /done|complete/i.test(t.status) ? 'completed' : /progress|doing|active/i.test(t.status) ? 'in_progress' : 'pending' }))
492
+ s.chat.todos = todos
493
+ s.todoTouched = true
494
+ const paint = { pending: c.muted, in_progress: (x) => c.info(c.bold(x)), completed: (x) => c.faint(x) }
495
+ s.ui.print(todos.map((t) => ` ${paint[t.status](`${MARK[t.status]} ${t.content}`)}`).join('\n') || c.faint(' (todo list cleared)'))
496
+ const done = todos.filter((t) => t.status === 'completed').length
497
+ const next = todos.find((t) => t.status !== 'completed')
498
+ return `Todo list saved: ${done} of ${todos.length} done.${next ? ` Next: ${next.content}` : ''}`
499
+ }
500
+
501
+ // ask_user: a question for the user mid-task, with choices (and their own answer) or free text.
502
+ async function askUser(s, args) {
503
+ const question = String(args.question ?? '').trim()
504
+ if (!question) return 'error: ask_user needs "question"'
505
+ if (s.ui.noUser) return prompt('agent/ask-nobody')
506
+ let options = args.options ?? []
507
+ if (typeof options === 'string') {
508
+ try {
509
+ options = JSON.parse(options)
510
+ } catch {
511
+ options = options.split('\n')
512
+ }
513
+ }
514
+ const items = [options].flat().map((o) => (typeof o === 'string' ? { label: o.trim() } : { label: String(o?.label ?? o?.value ?? '').trim(), desc: o?.description })).filter((o) => o.label)
515
+ s.ui.stopSpin()
516
+ let answer
517
+ if (items.length) {
518
+ const got = [await s.ui.pick(question, [...items, { label: 'Something else', desc: 'type your own answer', own: true }], { multi: Boolean(args.multiple) })].flat().filter(Boolean)
519
+ const own = got.some((x) => x.own) ? await s.ui.ask(`${c.bold('Your answer')} ${c.dim('›')} `) : ''
520
+ answer = [...got.filter((x) => !x.own).map((x) => x.label), own?.trim()].filter(Boolean).join(', ')
521
+ } else answer = (await s.ui.ask(`${c.yellow('?')} ${c.bold(question)} `))?.trim()
522
+ log('ask', question, answer)
523
+ return answer ? `The user answered: ${answer}` : prompt('agent/ask-no-answer')
524
+ }
525
+
526
+ async function webTool(s, name, args, signal) {
527
+ const what = name === 'web_search' ? String(args.query ?? '') : String(args.url ?? '')
528
+ if (!what.trim()) return `error: ${name} needs ${name === 'web_search' ? '"query"' : '"url"'}`
529
+ const ok = await s.ui.confirm(name === 'web_search' ? `Search the web for ${c.bold(what)}?` : `Fetch ${c.bold(what)}?`, 'read')
530
+ if (ok !== true) return `The user declined this.${typeof ok === 'string' ? ' User feedback: ' + ok : ''}`
531
+ s.ui.spinner(name === 'web_search' ? `searching: ${clip(what, 60)}` : `fetching ${clip(what, 60)}`)
532
+ return name === 'web_search' ? await webSearch(what, { signal }) : await webFetch(what, { start: args.start, signal })
533
+ }
534
+
535
+ function memoryTool(s, args) {
536
+ const m = s.memory
537
+ if (!m) return 'error: there is no memory here'
538
+ const name = String(args.name ?? '')
539
+ switch (args.action) {
540
+ case 'write': {
541
+ if (!args.content && !args.description) return 'error: write needs at least content or a description'
542
+ const n = m.write(args)
543
+ s.memoryWrites = (s.memoryWrites ?? 0) + 1
544
+ return `Saved [[${n}]]${m.read(n)?.scope === 'global' ? ' (global)' : ''}.`
545
+ }
546
+ case 'read': {
547
+ const n = m.read(name)
548
+ if (!n) return `error: no note [[${slug(name)}]]. ${m.search(name).slice(0, 5).map((x) => `[[${x.name}]]`).join(', ') || 'Search the memory to find notes.'}`
549
+ return `[[${n.name}]] (${n.scope === 'global' ? 'global' : 'this project'}, ${n.type}${n.tags.length ? `; ${n.tags.join(', ')}` : ''}): ${n.description}\n\n${n.body}${n.backlinks.length ? `\n\nLinked from: ${n.backlinks.join(', ')}` : ''}`
550
+ }
551
+ case 'search': {
552
+ const hits = m.search(args.query ?? name)
553
+ return hits.length ? hits.slice(0, 20).map((n) => `[[${n.name}]] — ${n.description} (${[n.scope === 'global' && 'global', ...n.tags].filter(Boolean).join(', ')})`).join('\n') : 'no notes match'
554
+ }
555
+ case 'delete':
556
+ return m.delete(name, args.scope) ? `Deleted [[${slug(name)}]].` : `error: no note [[${slug(name)}]]`
557
+ default:
558
+ return 'error: action must be write, read, search or delete'
559
+ }
560
+ }
561
+
562
+ // Lenient about slips in names: "chrome-devtools:", "evaluate_script'", no server when there's one.
563
+ const bare = (v) => String(v ?? '').trim().replace(/^["'`]+|["'`:;,.]+$/g, '')
564
+
565
+ async function mcpTool(s, args, signal) {
566
+ const names = s.mcp?.names ?? []
567
+ // everything written into "server": 'chrome-devtools" tool="navigate_page" arguments={...}'
568
+ const crammed = /^\s*"?([\w.-]+)"?\s+tool\s*[=:]\s*"?([\w.-]+)"?(?:\s+arguments\s*[=:]\s*(\{[\s\S]*\}))?/.exec(String(args.server ?? ''))
569
+ if (crammed && names.includes(crammed[1])) {
570
+ let a = args.arguments
571
+ try {
572
+ a = crammed[3] ? JSON.parse(crammed[3]) : a
573
+ } catch {}
574
+ args = { server: crammed[1], tool: args.tool ?? crammed[2], arguments: a }
575
+ }
576
+ let a = args.arguments ?? {}
577
+ if (typeof a === 'string') {
578
+ try {
579
+ a = JSON.parse(a)
580
+ } catch {
581
+ a = {}
582
+ }
583
+ }
584
+ if (bare(args.tool) === 'search') return await searchMcp(s, String(a.query ?? args.query ?? a.name ?? ''), bare(args.server), signal)
585
+ const server = s.mcp?.get(bare(args.server) || (names.length === 1 ? names[0] : ''))
586
+ if (!server) return `error: no MCP server "${args.server}". Servers: ${names.join(', ') || 'none'}`
587
+ const tool = bare(args.tool) || 'list'
588
+ if (tool === 'list' || tool === 'describe') {
589
+ s.ui.spinner(`starting ${server.name}`)
590
+ const tools = await server.listTools(signal)
591
+ if (tool === 'list') return tools.map((t) => `${t.name}: ${String(t.description ?? '').split(/(?<=\.)\s/)[0].slice(0, 160)}`).join('\n')
592
+ const t = tools.find((x) => x.name === a.name)
593
+ return t ? `${t.name}: ${t.description ?? ''}\nArguments (JSON schema): ${JSON.stringify(t.inputSchema ?? {})}` : `error: ${server.name} has no tool "${a.name}"; tool "list" shows its tools`
594
+ }
595
+ return callMcp(s, server, tool, a, signal)
596
+ }
597
+
598
+ // One MCP tool call. Asked about like a command, unless the server marks the tool read-only.
599
+ async function callMcp(s, server, tool, a, signal) {
600
+ if (!allowed(s, mcpName(server.name, tool))) return `error: the tool ${mcpName(server.name, tool)} is not allowed here`
601
+ const def = (server.tools ?? s.mcpCatalog?.get(server.name)?.tools)?.find((t) => t.name === tool)
602
+ if (!def?.annotations?.readOnlyHint) {
603
+ const ok = await s.ui.confirm(`Use ${server.name} → ${tool}?`, 'command')
604
+ if (ok !== true) return `The user declined this call.${typeof ok === 'string' ? ' User feedback: ' + ok : ''}`
605
+ }
606
+ s.ui.spinner(`${server.name} · ${tool}`)
607
+ const r = await server.call(tool, a, signal)
608
+ const parts = r.content ?? []
609
+ const text = parts.filter((p) => p.type === 'text').map((p) => p.text).join('\n')
610
+ const images = parts.filter((p) => p.type === 'image' && p.data)
611
+ let note = ''
612
+ if (images.length && s.strat?.vision) {
613
+ ;(s.pendingImages ??= []).push(...images.map((p) => `data:${p.mimeType ?? 'image/png'};base64,${p.data}`))
614
+ note = `\n(${images.length} image(s) attached in the next message)`
615
+ } else if (images.length) note = `\n(${images.length} image(s) not shown: this model can't see images)`
616
+ return `${r.isError ? 'error: ' : ''}${text || '(no text)'}${note}`
617
+ }
618
+
619
+ // tool "search": the tools of every server (or one) whose name or description has the query's
620
+ // words, best first. Servers start for it, so the model needn't list them one by one.
621
+ async function searchMcp(s, query, only, signal) {
622
+ const words = query.toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 1)
623
+ if (!words.length) return 'error: search needs arguments {"query": "what the tool should do"}'
624
+ const all = s.mcp?.names ?? []
625
+ const names = all.includes(only) ? [only] : all // a server name that isn't one ("mcp", models do): every server
626
+ s.ui.spinner(`searching the tools of ${names.length} MCP server${names.length === 1 ? '' : 's'}`)
627
+ const lists = await Promise.allSettled(names.map((n) => s.mcp.get(n).listTools(signal)))
628
+ const hits = []
629
+ const failed = []
630
+ lists.forEach((r, i) => {
631
+ if (r.status === 'rejected') return failed.push(`${names[i]} (${clip(r.reason?.message ?? r.reason, 80)})`)
632
+ for (const t of r.value) {
633
+ const name = t.name.toLowerCase()
634
+ const text = `${name} ${String(t.description ?? '').toLowerCase()}`
635
+ const score = words.reduce((n, w) => n + (name.includes(w) ? 3 : text.includes(w) ? 1 : 0), 0)
636
+ if (score) hits.push({ score, line: `${names[i]} · ${t.name}: ${String(t.description ?? '').split(/(?<=\.)\s/)[0].slice(0, 160)}` })
637
+ }
638
+ })
639
+ hits.sort((x, y) => y.score - x.score)
640
+ const out = hits.filter((h) => h.score * 2 >= hits[0].score).slice(0, 15).map((h) => h.line).join('\n') || `no tool matches "${query}"` // weak matches (only "the", "page") left out
641
+ return `${out}${failed.length ? `\n(not searched, failed to start: ${failed.join(', ')})` : ''}\nCall one with server and tool; tool "describe" with {"name": …} shows its arguments.`
642
+ }
643
+
644
+ // After an edit: the changed lines with 2 lines around them, numbered, so the model can check its
645
+ // work without reading the whole file again.
646
+ export function excerpt(next, ops, around = 2, max = 40) {
647
+ const lines = next.split('\n')
648
+ const changed = []
649
+ let n = 0
650
+ for (const [t] of ops) {
651
+ if (t === '+') changed.push(n)
652
+ if (t !== '-') n++
653
+ else if (!changed.includes(n)) changed.push(n) // a deletion: show where it was
654
+ }
655
+ const show = new Set()
656
+ for (const i of changed) for (let k = Math.max(0, i - around); k <= Math.min(lines.length - 1, i + around); k++) show.add(k)
657
+ const rows = [...show].sort((a, b) => a - b)
658
+ const out = []
659
+ rows.slice(0, max).forEach((i, j) => {
660
+ if (j && i !== rows[j - 1] + 1) out.push('…')
661
+ out.push(`${i + 1}| ${lines[i]}`)
662
+ })
663
+ if (rows.length > max) out.push(`… (${rows.length - max} more changed lines)`)
664
+ return out.join('\n')
665
+ }
666
+
667
+ // edit_file: all edits apply together or not at all. Lenient about the shape models send: `edits`
668
+ // as a list or as JSON text, the single search/replace form, a `path` per edit. An edit without a
669
+ // path, or whose text isn't in the given file, goes to the one project file that contains its text.
670
+ async function editFiles(s, args) {
671
+ const { root } = s
672
+ let edits = args.edits
673
+ if (typeof edits === 'string') {
674
+ for (const text of [edits, `[${edits}]`]) { // a list as text, or objects without the [ ]
675
+ try {
676
+ edits = JSON.parse(text)
677
+ break
678
+ } catch {}
679
+ }
680
+ }
681
+ if (edits && typeof edits === 'object' && !Array.isArray(edits)) edits = [edits] // one edit, not in a list
682
+ if (!Array.isArray(edits)) edits = [{ search: args.search, replace: args.replace }]
683
+ const files = new Map() // path → { old, text }
684
+ const textOf = (rel) => {
685
+ if (!files.has(rel)) files.set(rel, { old: fs.readFileSync(resolveIn(root, rel), 'utf8') })
686
+ const f = files.get(rel)
687
+ return f.text ?? f.old
688
+ }
689
+ const holder = (search) => {
690
+ const hits = listFiles(root).filter((f) => {
691
+ try {
692
+ const abs = resolveIn(root, f)
693
+ return fs.statSync(abs).size < 1_000_000 && fs.readFileSync(abs, 'utf8').includes(search)
694
+ } catch {
695
+ return false
696
+ }
697
+ })
698
+ return hits.length === 1 ? hits[0] : null
699
+ }
700
+ for (const [i, e] of edits.entries()) {
701
+ const { search: s0, replace: r0, path: p0 } = normalize(e ?? {})
702
+ const search = String(s0 ?? '')
703
+ const replace = String(r0 ?? '')
704
+ const given = p0 ?? args.path
705
+ const label = edits.length > 1 ? `edit ${i + 1} of ${edits.length}: ` : ''
706
+ let rel = given && fs.existsSync(resolveIn(root, given)) ? path.relative(root, resolveIn(root, given)) : null
707
+ let r = rel ? applyEdit(textOf(rel), search, replace) : null
708
+ if (!r || r.error) {
709
+ const other = search.trim() && holder(search)
710
+ if (other && other !== rel) [rel, r] = [other, applyEdit(textOf(other), search, replace)]
711
+ }
712
+ if (!rel) return `error: ${label}${given ? `${given} does not exist; use write_file to create it` : 'no "path" given, and its search text is not in exactly one file'}. Nothing was changed.`
713
+ if (r.error) return `error: ${label}${r.error}. Nothing was changed.`
714
+ files.get(rel).text = r.text
715
+ }
716
+ const out = []
717
+ for (const [rel, f] of files) {
718
+ if (f.text === undefined) continue
719
+ const res = await proposeWrite(s, rel, f.old, f.text)
720
+ out.push(writeResult(rel, res) + (res.status === 'applied' ? `\nNow:\n${excerpt(res.text, res.ops)}` : ''))
721
+ }
722
+ return out.join('\n\n')
723
+ }
724
+
725
+ // apply_patch (patch.js): every file's change is worked out first (nothing is written if one
726
+ // fails), then each is shown, asked about and written like an edit_file change.
727
+ async function applyPatch(s, text) {
728
+ const body = /\*\*\* Begin Patch[\s\S]*?\*\*\* End Patch/.exec(String(text))?.[0] ?? String(text)
729
+ let ops
730
+ try {
731
+ ops = parsePatch(body)
732
+ } catch (e) {
733
+ return `error: ${e.message}. Nothing was changed.`
734
+ }
735
+ if (!ops.length) return 'error: the patch changes no files. Nothing was changed.'
736
+ const plan = [] // [rel, old, next (null: delete)]
737
+ for (const op of ops) {
738
+ const abs = resolveIn(s.root, op.path)
739
+ const rel = path.relative(s.root, abs)
740
+ const old = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null
741
+ if (op.type === 'add') {
742
+ plan.push([rel, old ?? '', op.content.join('\n') + '\n'])
743
+ continue
744
+ }
745
+ if (old === null) return `error: ${op.path} does not exist${op.type === 'update' ? ' (use *** Add File: to create it)' : ''}. Nothing was changed.`
746
+ if (op.type === 'delete') {
747
+ plan.push([rel, old, null])
748
+ continue
749
+ }
750
+ const r = applyChunks(old, op.chunks)
751
+ if (r.error) return `error: ${op.path}: ${r.error}. Nothing was changed.`
752
+ if (op.moveTo) plan.push([path.relative(s.root, resolveIn(s.root, op.moveTo)), '', r.text], [rel, old, null])
753
+ else plan.push([rel, old, r.text])
754
+ }
755
+ const out = []
756
+ for (const [rel, old, next] of plan) {
757
+ const res = await proposeWrite(s, rel, old, next)
758
+ if (next === null && res.status === 'applied') res.deleted = true
759
+ out.push(writeResult(rel, res) + (res.status === 'applied' && next !== null ? `\nNow:\n${excerpt(res.text, res.ops)}` : ''))
760
+ }
761
+ return out.join('\n\n')
762
+ }
763
+
764
+ // Every tool call goes through the user's PreToolUse and PostToolUse hooks (hooks.js).
765
+ export async function runTool(name, args, s, signal) {
766
+ log('tool', name, args)
767
+ const pre = await runHooks(s, 'PreToolUse', { tool_name: name, tool_input: args }, signal)
768
+ if (pre.block) return prompt('agent/hook-blocked-tool', { reason: pre.block })
769
+ let out = await tool(name, args, s, signal)
770
+ const post = await runHooks(s, 'PostToolUse', { tool_name: name, tool_input: args, tool_response: out }, signal)
771
+ if (post.block) out += `\n\n${prompt('agent/hook-after-tool', { reason: post.block })}`
772
+ log('tool', `${name} result:`, clip(out, 4000))
773
+ return out
774
+ }
775
+
776
+ // Argument names other tools use (Claude Code's file_path, old_string, new_string, …): models
777
+ // trained on them send them here too.
778
+ const ALIASES = { file_path: 'path', filePath: 'path', file: 'path', old_string: 'search', old_str: 'search', oldText: 'search', new_string: 'replace', new_str: 'replace', newText: 'replace', cmd: 'command' }
779
+ function normalize(args) {
780
+ const out = { ...args }
781
+ for (const [from, to] of Object.entries(ALIASES)) if (out[to] === undefined && out[from] !== undefined) out[to] = out[from]
782
+ return out
783
+ }
784
+
785
+ // The MCP tool a name means: mcp__server__tool (offered, or a server behind the mcp tool), or a
786
+ // bare tool name only one server has ("click").
787
+ function mcpTarget(s, name) {
788
+ const d = s.mcpDirect?.get(name)
789
+ if (d) return { server: s.mcp.get(d.server), tool: d.tool }
790
+ const m = /^mcp__(.+?)__(.+)$/.exec(name)
791
+ if (m && s.mcp?.get(m[1])) return { server: s.mcp.get(m[1]), tool: m[2] }
792
+ const same = [...(s.mcpDirect?.values() ?? [])].filter((x) => x.tool === name)
793
+ return same.length === 1 ? { server: s.mcp.get(same[0].server), tool: name } : null
794
+ }
795
+
796
+ // Tools the caller allowed (--allowedTools) or took away (--disallowedTools); all by default. An
797
+ // MCP tool goes by its own name, its server's (mcp__server, as in Claude Code) or "mcp" (all of them).
798
+ export function allowed(s, name) {
799
+ const names = name.startsWith('mcp__') ? [name, name.split('__').slice(0, 2).join('__'), 'mcp'] : [name]
800
+ if (names.some((n) => s.denyTools?.has(n))) return false
801
+ if (!s.allowTools) return true
802
+ return names.some((n) => s.allowTools.has(n)) || (name === 'mcp' && [...s.allowTools].some((n) => n.startsWith('mcp__')))
803
+ }
804
+
805
+ async function tool(name, args, s, signal) {
806
+ if (!name.startsWith('mcp__') && !mcpTarget(s, name)) args = normalize(args) // an MCP tool's arguments are its own
807
+ try {
808
+ const { root } = s
809
+ if (s.ui.permission === 'plan') {
810
+ if (PLAN_HIDDEN.has(name) || name === 'append_file' || name === 'manage' && args.action !== 'list' && args.action !== 'show' && args.action !== 'help') return prompt('agent/plan-refused')
811
+ }
812
+ switch (name) {
813
+ case 'read_file':
814
+ return readFileTool(root, args.path, args.start_line, args.end_line)
815
+ case 'list_files': {
816
+ const files = listFiles(root).filter((f) => !args.filter || f.includes(args.filter))
817
+ return files.slice(0, 500).join('\n') + (files.length > 500 ? `\n… ${files.length - 500} more` : '') || 'no files'
818
+ }
819
+ case 'search':
820
+ return searchFiles(root, String(args.query ?? ''), args.path)
821
+ case 'edit_file':
822
+ return await editFiles(s, args)
823
+ case 'write_file':
824
+ case 'append_file': {
825
+ if (!args.path) return 'error: write_file needs "path" (the file to write) and "content". Nothing was written.'
826
+ const abs = resolveIn(root, args.path)
827
+ const rel = path.relative(root, abs)
828
+ const old = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : ''
829
+ const append = name === 'append_file' || args.append === true || args.append === 'true'
830
+ const res = await proposeWrite(s, rel, old, (append ? old : '') + String(args.content ?? ''))
831
+ return writeResult(rel, res) + (res.formatted ? `\nNow:\n${excerpt(res.text, res.ops)}` : '') // what it wrote isn't what's there now
832
+ }
833
+ case 'run_command':
834
+ if (/^\s*apply_patch\b[\s\S]*\*\*\* Begin Patch/.test(String(args.command ?? ''))) return await applyPatch(s, args.command) // apply_patch <<'EOF' … as Codex runs it
835
+ return await runConfirmed(s, String(args.command ?? ''), signal)
836
+ case 'apply_patch':
837
+ return await applyPatch(s, args.input ?? args.patch ?? args.command ?? '')
838
+ case 'mcp':
839
+ return await mcpTool(s, args, signal)
840
+ case 'memory':
841
+ return memoryTool(s, args)
842
+ case 'todo':
843
+ return todoTool(s, args)
844
+ case 'web_search':
845
+ case 'web_fetch':
846
+ return await webTool(s, name, args, signal)
847
+ case 'agent':
848
+ return await s.subagent(args, signal)
849
+ case 'ask_user':
850
+ return await askUser(s, args)
851
+ case 'skill': {
852
+ const k = s.skills?.get(String(args.name ?? ''))
853
+ return k && k.use !== 'me' ? fillArgs(k.body, '') : `error: no skill "${args.name}". Skills: ${[...(s.skills?.values() ?? [])].filter((x) => x.use !== 'me').map((x) => x.name).join(', ') || 'none'}`
854
+ }
855
+ default: {
856
+ const m = mcpTarget(s, name)
857
+ if (m) return await callMcp(s, m.server, m.tool, args, signal)
858
+ return (await s.director?.tool(name, args, s, signal)) ?? `error: there is no tool named ${name}`
859
+ }
860
+ }
861
+ } catch (e) {
862
+ if (e.name === 'AbortError') throw e
863
+ return `error: ${e.message}`
864
+ }
865
+ }