heartcode 0.1.15 → 0.1.24
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/CHANGELOG.md +186 -0
- package/SKILL.md +5 -3
- package/bin/heartcode.js +199 -71
- package/package.json +1 -1
- package/prompts/agent.md +147 -17
- package/prompts/guided.md +6 -3
- package/src/acp.js +2 -2
- package/src/agent.js +418 -34
- package/src/client.js +59 -3
- package/src/config.js +11 -6
- package/src/headless.js +1 -1
- package/src/lsp.js +168 -49
- package/src/mcp.js +20 -2
- package/src/sessions.js +2 -3
- package/src/settings.html +262 -131
- package/src/settings.js +11 -2
- package/src/skills.js +20 -1
- package/src/tabs.js +22 -7
- package/src/tools.js +68 -6
- package/src/ui.js +525 -94
- package/src/voice.js +70 -6
- package/src/web.js +211 -13
package/src/settings.js
CHANGED
|
@@ -21,7 +21,7 @@ import { log, LOGS, REQUESTS, useRequestLog, listRequests, readRequest, pruneLog
|
|
|
21
21
|
|
|
22
22
|
const PAGE = new URL('./settings.html', import.meta.url)
|
|
23
23
|
const EFFORTS = ['default', 'low', 'medium', 'high']
|
|
24
|
-
const TOGGLES = { autoCompact: 'compact the conversation when
|
|
24
|
+
const TOGGLES = { autoCompact: 'compact the conversation when it fills 85% of the model\'s context window', director: 'let the model add skills, MCP servers and change settings when asked', format: "run the project's formatter on every file HeartCode writes", lsp: "show the model a language server's errors after each edit" }
|
|
25
25
|
// The Logging page's: both off unless switched on; how much of each request ("full" or "summary")
|
|
26
26
|
const LOG_SWITCHES = ['log', 'logRequests']
|
|
27
27
|
const DETAILS = ['full', 'summary']
|
|
@@ -333,7 +333,7 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
333
333
|
const state = () => ({
|
|
334
334
|
providers: Object.entries(byName(cfg.providers))
|
|
335
335
|
.filter(([n]) => n !== cfg.adhoc)
|
|
336
|
-
.map(([name, p]) => ({ name, baseUrl: p.baseUrl, key: mask(p.apiKey), list: p.list !== false, added: p.models ?? [] })),
|
|
336
|
+
.map(([name, p]) => ({ name, baseUrl: p.baseUrl, key: mask(p.apiKey), list: p.list !== false, off: p.off === true, added: p.models ?? [] })),
|
|
337
337
|
provider: cfg.provider ?? null,
|
|
338
338
|
model: cfg.model ?? null,
|
|
339
339
|
order: [...(cfg.providers[cfg.provider] && cfg.model ? [{ provider: cfg.provider, model: cfg.model }] : []), ...rotationOf(cfg).then].map((t) => ({ ...t, tested: testedOf(t), known: knownOf(t), price: priceFor(t) })),
|
|
@@ -415,6 +415,15 @@ export function startSettings({ cfg, apply = () => {}, port = 0 } = {}) {
|
|
|
415
415
|
save('provider')
|
|
416
416
|
return state()
|
|
417
417
|
},
|
|
418
|
+
// A provider switched off: kept (its key, its models in your list), but HeartCode skips its models
|
|
419
|
+
// until it's on again.
|
|
420
|
+
'POST /api/provider/on': ({ name, on }) => {
|
|
421
|
+
if (!cfg.providers[name]) throw new Error(`no provider "${name}"`)
|
|
422
|
+
if (on) delete cfg.providers[name].off
|
|
423
|
+
else cfg.providers[name].off = true
|
|
424
|
+
save('provider')
|
|
425
|
+
return state()
|
|
426
|
+
},
|
|
418
427
|
// A model added by its name (one the provider doesn't list, or its list is off): remembered.
|
|
419
428
|
// all: every model added by name forgotten at once
|
|
420
429
|
'POST /api/provider/added': ({ name, model, remove, all }) => {
|
package/src/skills.js
CHANGED
|
@@ -18,7 +18,26 @@ export const USES = ['me', 'model', 'both']
|
|
|
18
18
|
|
|
19
19
|
export function frontMatter(text) {
|
|
20
20
|
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text)
|
|
21
|
-
const
|
|
21
|
+
const lines = (m?.[1] ?? '').replace(/\r/g, '').split('\n')
|
|
22
|
+
const meta = {}
|
|
23
|
+
const indent = (l) => l.length - l.trimStart().length
|
|
24
|
+
for (let i = 0; i < lines.length; i++) {
|
|
25
|
+
const key = /^([\w-]+):\s*(.*)$/.exec(lines[i])
|
|
26
|
+
if (!key) continue
|
|
27
|
+
const [, k, raw] = key
|
|
28
|
+
const base = indent(lines[i])
|
|
29
|
+
const block = /^([|>])([+-]?)\s*$/.exec(raw) // a YAML block scalar: | literal, > folded (Claude Code, others use "description: >-")
|
|
30
|
+
if (block) {
|
|
31
|
+
const kept = []
|
|
32
|
+
while (i + 1 < lines.length && (lines[i + 1].trim() === '' || indent(lines[i + 1]) > base)) kept.push(lines[++i])
|
|
33
|
+
const minIndent = Math.min(...kept.filter((l) => l.trim()).map(indent), Infinity)
|
|
34
|
+
let v = kept.map((l) => l.slice(Number.isFinite(minIndent) ? minIndent : 0)).join(block[1] === '>' ? ' ' : '\n')
|
|
35
|
+
if (block[2] !== '+') v = v.replace(/\n+$/, '') // "-" strips trailing newlines; default keeps one at most
|
|
36
|
+
meta[k] = v.trim()
|
|
37
|
+
} else {
|
|
38
|
+
meta[k] = raw.replace(/^["']|["']$/g, '')
|
|
39
|
+
}
|
|
40
|
+
}
|
|
22
41
|
return { meta, body: (m ? m[2] : text).trim() }
|
|
23
42
|
}
|
|
24
43
|
|
package/src/tabs.js
CHANGED
|
@@ -42,7 +42,7 @@ export class Tabs {
|
|
|
42
42
|
tab.output = Object.assign(new EventEmitter(), { isTTY: true, write: (s) => (on() ? real.output.write(s) : true) })
|
|
43
43
|
for (const k of ['columns', 'rows']) Object.defineProperty(tab.output, k, { get: () => real.output[k] })
|
|
44
44
|
Object.defineProperty(tab.output, 'hidden', { get: () => !on() })
|
|
45
|
-
tab.hooks = { tabBar: (cols) => this.bar(cols), onTab: (what) => this.key(what) }
|
|
45
|
+
tab.hooks = { tabBar: (cols) => this.bar(cols), onTab: (what) => this.key(what), onTabClick: (col) => this.click(col) }
|
|
46
46
|
this.list.push(tab)
|
|
47
47
|
this.at = tab
|
|
48
48
|
tab.id = this.made = (this.made ?? 0) + 1 // for its log file: tab-1.log… (its place in the bar can change)
|
|
@@ -96,6 +96,13 @@ export class Tabs {
|
|
|
96
96
|
if (n) this.show(this.list[Number(n) - 1])
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
// a click on the tab bar (a 0-based column): that tab
|
|
100
|
+
click(col) {
|
|
101
|
+
if (this.locked) return
|
|
102
|
+
const hit = this.spans?.find((s) => col >= s.from && col < s.to)
|
|
103
|
+
if (hit && hit.tab !== this.at) this.show(hit.tab)
|
|
104
|
+
}
|
|
105
|
+
|
|
99
106
|
// working, waiting for an answer, or done since you last looked at it
|
|
100
107
|
state(t) {
|
|
101
108
|
const ui = t.ui
|
|
@@ -108,40 +115,48 @@ export class Tabs {
|
|
|
108
115
|
// The tab bar, three rows: tabs drawn as tabs, the one on screen open onto it (its bottom
|
|
109
116
|
// edge missing), the others closed off. Number, name, and what it's doing.
|
|
110
117
|
// ╭─────────────╮ ╭──────────────╮
|
|
111
|
-
// │ 1 heart_cli │ │ 2 fix tests ●│
|
|
118
|
+
// │ 1 heart_cli │ │ 2 fix tests ●│ Ctrl+T open a new tab
|
|
112
119
|
// ─╯ ╰─┴──────────────┴──────────────────
|
|
113
120
|
bar(cols = 100) {
|
|
114
121
|
const mark = { '?': c.warn('?'), '●': c.brand('●'), '✓': c.ok('✓') }
|
|
115
122
|
const room = Math.max(6, Math.floor((cols - 24) / this.list.length) - 7) // a name's cells, so they all fit
|
|
116
123
|
let [top, mid, bottom] = [' ', ' ', c.ghost('─')]
|
|
124
|
+
let col = 1
|
|
125
|
+
this.spans = [] // the columns each tab takes, for a click (click)
|
|
117
126
|
this.list.forEach((t, i) => {
|
|
118
127
|
const on = t === this.at
|
|
119
128
|
const s = this.state(t)
|
|
120
|
-
const name = cut(t.name || t.app?.chat?.title || pathTail(t.app?.root), room)
|
|
129
|
+
const name = cut(t.name || t.app?.chat?.title || pathTail(t.app?.root), Math.min(NAME_MAX, room))
|
|
121
130
|
const text = ` ${i + 1} ${name}${s ? ' ' + s : ''} `
|
|
122
131
|
const w = width(text)
|
|
123
132
|
const line = on ? c.brand : c.ghost
|
|
124
133
|
const label = ` ${on ? c.bright(c.bold(`${i + 1} ${name}`)) : c.muted(`${i + 1} ${name}`)}${s ? ' ' + mark[s] : ''} `
|
|
134
|
+
this.spans.push({ from: col, to: col + w + 2, tab: t })
|
|
135
|
+
col += w + 3
|
|
125
136
|
top += line(`╭${'─'.repeat(w)}╮`) + ' '
|
|
126
137
|
mid += line('│') + label + line('│') + ' '
|
|
127
138
|
bottom += (on ? c.brand(`╯${' '.repeat(w)}╰`) : c.ghost(`┴${'─'.repeat(w)}┴`)) + c.ghost('─')
|
|
128
139
|
})
|
|
129
|
-
const hint = this.list.length > 1 ? 'Ctrl+T
|
|
140
|
+
const hint = this.list.length > 1 ? 'Ctrl+T open a tab · Ctrl+Q close this one · Ctrl+N/P switch tabs' : 'Ctrl+T open a new tab'
|
|
130
141
|
mid += ` ${c.faint(hint)}`
|
|
131
142
|
bottom += c.ghost('─'.repeat(Math.max(0, cols - width(bottom.replace(/\x1b\[[0-9;]*m/g, '')))))
|
|
132
143
|
return [top, mid, bottom]
|
|
133
144
|
}
|
|
134
145
|
}
|
|
135
146
|
|
|
136
|
-
//
|
|
147
|
+
// A tab's name: at most 20 cells, "..." included (fewer when many tabs share a narrow screen).
|
|
148
|
+
const NAME_MAX = 20
|
|
149
|
+
|
|
150
|
+
// At most n cells, ending in "..." when cut.
|
|
137
151
|
function cut(s, n) {
|
|
152
|
+
s = String(s ?? '').replace(/\s+/g, ' ').trim()
|
|
138
153
|
if (width(s) <= n) return s
|
|
139
154
|
let out = ''
|
|
140
155
|
for (const ch of s) {
|
|
141
|
-
if (width(out + ch) > n -
|
|
156
|
+
if (width(out + ch) > n - 3) break
|
|
142
157
|
out += ch
|
|
143
158
|
}
|
|
144
|
-
return out + '
|
|
159
|
+
return out.trimEnd() + '...'
|
|
145
160
|
}
|
|
146
161
|
|
|
147
162
|
const pathTail = (p = '') => p.split(/[\\/]/).filter(Boolean).pop() ?? p
|
package/src/tools.js
CHANGED
|
@@ -15,7 +15,7 @@ import { webFetch, webSearch } from './web.js'
|
|
|
15
15
|
import { runHooks } from './hooks.js'
|
|
16
16
|
import { parsePatch, applyChunks } from './patch.js'
|
|
17
17
|
import { formatFile } from './format.js'
|
|
18
|
-
import { diagnose } from './lsp.js'
|
|
18
|
+
import { diagnose, lspTool, findCommand } from './lsp.js'
|
|
19
19
|
|
|
20
20
|
const IGNORE = new Set(['.git', 'node_modules', 'dist', 'build', '.next', 'coverage', '.venv', 'venv', '__pycache__', 'target', '.cache', '.DS_Store'])
|
|
21
21
|
|
|
@@ -42,15 +42,28 @@ export function saveOutput(chatId, name, data, ext = 'txt') {
|
|
|
42
42
|
export function resolveIn(root, p) {
|
|
43
43
|
const clean = String(p ?? '').trim().replace(/^["'`]+|["'`]+$/g, '')
|
|
44
44
|
if (!clean) throw new Error('no path given')
|
|
45
|
-
|
|
45
|
+
const bare = root.replace(/^[\\/]+/, '') // the project's own path, its leading "/" left out by a sloppy copy
|
|
46
|
+
let abs = clean === bare || clean.startsWith(bare + '/') ? path.join(root, clean.slice(bare.length)) : path.resolve(root, clean)
|
|
46
47
|
if (abs.startsWith(OUTPUTS + path.sep)) return abs // a saved tool output
|
|
47
48
|
// "num.js:" or "num.js," from a sloppy model: the file without the stray mark, if that one exists
|
|
48
49
|
const trimmed = path.resolve(root, clean.replace(/[:;,.]+$/, ''))
|
|
49
50
|
if (trimmed !== abs && !fs.existsSync(abs) && fs.existsSync(trimmed)) abs = trimmed
|
|
50
|
-
if (abs !== root && !abs.startsWith(root + path.sep))
|
|
51
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) abs = intoProject(root, clean) ?? abs
|
|
52
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) throw new Error(`${clean} is outside the project; use a path relative to the project folder (${root}), like "index.html" or "src/app.js"`)
|
|
51
53
|
return abs
|
|
52
54
|
}
|
|
53
55
|
|
|
56
|
+
// A small model's mangled copy of the project's long path, meant inside it: any path with the
|
|
57
|
+
// project folder's name in it ("/Users-me-…/myapp/index.html"): what comes after that name.
|
|
58
|
+
// null: no such reading.
|
|
59
|
+
function intoProject(root, p) {
|
|
60
|
+
const slash = p.replace(/\\/g, '/')
|
|
61
|
+
const name = path.basename(root)
|
|
62
|
+
const parts = slash.split('/')
|
|
63
|
+
const at = parts.lastIndexOf(name)
|
|
64
|
+
return at >= 0 ? path.resolve(root, parts.slice(at + 1).join('/') || '.') : null
|
|
65
|
+
}
|
|
66
|
+
|
|
54
67
|
// Project files a message names: by its path, or by a file name only one file in the project has
|
|
55
68
|
// ("num.js"; in a project with three index.js, "index.js" names none of them).
|
|
56
69
|
export function namedFiles(text, files) {
|
|
@@ -99,6 +112,33 @@ export function listFiles(root, limit = 2000) {
|
|
|
99
112
|
return files.sort()
|
|
100
113
|
}
|
|
101
114
|
|
|
115
|
+
// One folder's entries (the project root: ''), all of them: folders end in "/", what .gitignore
|
|
116
|
+
// leaves out says "(ignored)". Names only. At most `limit`.
|
|
117
|
+
export function folderView(root, rel = '', limit = 200) {
|
|
118
|
+
const abs = resolveIn(root, rel || '.')
|
|
119
|
+
let entries
|
|
120
|
+
try {
|
|
121
|
+
entries = fs.readdirSync(abs, { withFileTypes: true })
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return { error: e.code === 'ENOENT' ? `no folder ${rel}` : e.code === 'ENOTDIR' ? `${rel} is a file, not a folder` : e.message }
|
|
124
|
+
}
|
|
125
|
+
let ignored = null // the entries .gitignore leaves out ("node_modules/", "dist/", ".env")
|
|
126
|
+
try {
|
|
127
|
+
const out = execFileSync('git', ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory', '--', abs], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 16 << 20 })
|
|
128
|
+
ignored = new Set(out.split('\0').filter(Boolean).map((f) => f.replace(/\/$/, '')))
|
|
129
|
+
} catch {}
|
|
130
|
+
const here = path.relative(root, abs)
|
|
131
|
+
const names = entries
|
|
132
|
+
.map((e) => ({ name: e.name, dir: e.isDirectory() || (e.isSymbolicLink() && fs.statSync(path.join(abs, e.name), { throwIfNoEntry: false })?.isDirectory()) }))
|
|
133
|
+
.sort((a, b) => b.dir - a.dir || a.name.localeCompare(b.name))
|
|
134
|
+
const shown = names.slice(0, limit).map(({ name, dir }) => {
|
|
135
|
+
const p = path.join(here, name).split(path.sep).join('/')
|
|
136
|
+
const off = ignored ? ignored.has(p) : IGNORE.has(name)
|
|
137
|
+
return `${name}${dir ? '/' : ''}${off && name !== '.git' ? ' (ignored)' : ''}`
|
|
138
|
+
})
|
|
139
|
+
return { entries: shown, more: Math.max(0, names.length - limit) }
|
|
140
|
+
}
|
|
141
|
+
|
|
102
142
|
const isBinary = (buf) => buf.subarray(0, 8000).includes(0)
|
|
103
143
|
export const numberLines = (lines, from = 1) => lines.map((l, i) => `${from + i}| ${l}`).join('\n')
|
|
104
144
|
|
|
@@ -285,6 +325,7 @@ export async function proposeWrite(s, rel, old, next) {
|
|
|
285
325
|
else {
|
|
286
326
|
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
287
327
|
fs.writeFileSync(abs, next)
|
|
328
|
+
if (/\.(html?|css)$/i.test(rel)) (s.uiTouched ??= new Set()).add(rel) // looked at in a browser when the model says it's done (agent.js)
|
|
288
329
|
}
|
|
289
330
|
const add = ops.filter(([t]) => t === '+').length
|
|
290
331
|
const del = ops.filter(([t]) => t === '-').length
|
|
@@ -441,7 +482,9 @@ const obj = (properties, required = []) => ({ type: 'object', properties, requir
|
|
|
441
482
|
export const TOOL_DEFS = [
|
|
442
483
|
{ name: 'read_file', description: prompt('agent/tool-read_file'), parameters: obj({ path: str, start_line: int, end_line: int }, ['path']) },
|
|
443
484
|
{ name: 'search', description: prompt('agent/tool-search'), parameters: obj({ query: str, path: str }, ['query']) },
|
|
444
|
-
{ name: 'list_files', description: prompt('agent/tool-list_files'), parameters: obj({ filter: str }) },
|
|
485
|
+
{ name: 'list_files', description: prompt('agent/tool-list_files'), parameters: obj({ path: str, filter: str }) },
|
|
486
|
+
{ name: 'find_command', description: prompt('agent/tool-find_command'), parameters: obj({ names: { type: 'array', items: str } }, ['names']) },
|
|
487
|
+
{ name: 'lsp', description: prompt('agent/tool-lsp'), parameters: obj({ server: str, language: str, action: { type: 'string', enum: ['definition', 'references', 'hover', 'errors'] }, file: str, line: int, column: int, name: str }, ['server', 'language', 'action', 'file']) },
|
|
445
488
|
// One edit per call: a list of edits per call measured 2–6 malformed calls per benchmark run vs 0.
|
|
446
489
|
{ name: 'edit_file', description: prompt('agent/tool-edit_file'), parameters: obj({ path: str, search: str, replace: str }, ['path', 'search', 'replace']) },
|
|
447
490
|
{ name: 'write_file', description: prompt('agent/tool-write_file'), parameters: obj({ path: str, content: str, append: { type: 'boolean' } }, ['path', 'content']) },
|
|
@@ -455,7 +498,15 @@ export const TOOL_DEFS = [
|
|
|
455
498
|
// Plan mode (permission "plan"): looking around is fine, changing things isn't. Edits are refused,
|
|
456
499
|
// and a command runs only when it only reads (else it's refused, not asked about).
|
|
457
500
|
export const PLAN_HIDDEN = new Set(['edit_file', 'write_file', 'apply_patch'])
|
|
458
|
-
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/
|
|
501
|
+
const READ_ONLY = /^(cd|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/
|
|
502
|
+
// A command that went into a folder outside the project (a small model copying the project's long
|
|
503
|
+
// path wrong, then lost in what it finds there): told where the project is.
|
|
504
|
+
function outsideNote(root, cmd) {
|
|
505
|
+
const dirs = [...cmd.matchAll(/(?:^|&&|;|\|\|)\s*cd\s+("[^"]+"|'[^']+'|\S+)/g)].map((m) => m[1].replace(/^["']|["']$/g, '').replace(/^~(?=\/|$)/, os.homedir()))
|
|
506
|
+
const out = dirs.map((d) => path.resolve(root, d)).find((d) => d !== root && !d.startsWith(root + path.sep))
|
|
507
|
+
return out ? `\n\n${prompt('agent/outside-project', { dir: out, root })}` : ''
|
|
508
|
+
}
|
|
509
|
+
|
|
459
510
|
export function readOnlyCommand(cmd) {
|
|
460
511
|
const c = String(cmd).trim()
|
|
461
512
|
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
|
|
@@ -940,11 +991,22 @@ async function tool(name, args, s, signal, id) {
|
|
|
940
991
|
case 'read_file':
|
|
941
992
|
return IMAGE.test(String(args.path ?? '')) ? readImage(s, args.path) : readFileTool(root, args.path, args.start_line, args.end_line)
|
|
942
993
|
case 'list_files': {
|
|
994
|
+
if (args.path !== undefined && args.path !== '' && !args.filter) {
|
|
995
|
+
const v = folderView(root, String(args.path))
|
|
996
|
+
return v.error ? `error: ${v.error}` : v.entries.join('\n') + (v.more ? `\n… ${v.more} more` : '') || '(empty folder)'
|
|
997
|
+
}
|
|
943
998
|
const files = listFiles(root).filter((f) => !args.filter || f.includes(args.filter))
|
|
944
999
|
return files.slice(0, 500).join('\n') + (files.length > 500 ? `\n… ${files.length - 500} more` : '') || 'no files'
|
|
945
1000
|
}
|
|
946
1001
|
case 'search':
|
|
947
1002
|
return searchFiles(root, String(args.query ?? ''), args.path)
|
|
1003
|
+
case 'find_command': {
|
|
1004
|
+
const names = (Array.isArray(args.names) ? args.names : String(args.names ?? args.name ?? '').split(/[\s,]+/)).map(String).filter(Boolean)
|
|
1005
|
+
if (!names.length) return 'error: find_command needs "names": the commands to look for, like ["rg", "pnpm"]'
|
|
1006
|
+
return names.map((n) => `${n}: ${findCommand(root, n) ?? 'not installed'}`).join('\n')
|
|
1007
|
+
}
|
|
1008
|
+
case 'lsp':
|
|
1009
|
+
return await lspTool(s, args, signal)
|
|
948
1010
|
case 'edit_file':
|
|
949
1011
|
return await editFiles(s, args)
|
|
950
1012
|
case 'write_file':
|
|
@@ -963,7 +1025,7 @@ async function tool(name, args, s, signal, id) {
|
|
|
963
1025
|
}
|
|
964
1026
|
case 'run_command':
|
|
965
1027
|
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
|
|
966
|
-
return await runConfirmed(s, String(args.command ?? ''), signal)
|
|
1028
|
+
return (await runConfirmed(s, String(args.command ?? ''), signal)) + outsideNote(root, String(args.command ?? ''))
|
|
967
1029
|
case 'apply_patch':
|
|
968
1030
|
return await applyPatch(s, args.input ?? args.patch ?? args.command ?? '')
|
|
969
1031
|
case 'mcp':
|