heartcode 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +129 -2
- package/SKILL.md +170 -0
- package/bin/heart.js +77 -0
- package/bin/heartcode.js +1573 -0
- package/package.json +15 -4
- package/prompts/README.md +30 -0
- package/prompts/agent.md +320 -0
- package/prompts/compact.md +95 -0
- package/prompts/guided.md +193 -0
- package/prompts/probe.md +67 -0
- package/src/acp.js +248 -0
- package/src/agent.js +642 -0
- package/src/client.js +545 -0
- package/src/config.js +105 -0
- package/src/context.js +91 -0
- package/src/director.js +143 -0
- package/src/edit.js +127 -0
- package/src/format.js +74 -0
- package/src/guided.js +609 -0
- package/src/headless.js +217 -0
- package/src/hooks.js +80 -0
- package/src/importer.js +202 -0
- package/src/log.js +139 -0
- package/src/lsp.js +207 -0
- package/src/mcp.js +330 -0
- package/src/memory.js +222 -0
- package/src/oauth.js +122 -0
- package/src/patch.js +96 -0
- package/src/probe.js +343 -0
- package/src/prompts.js +103 -0
- package/src/reset.js +49 -0
- package/src/sessions.js +135 -0
- package/src/settings.html +1540 -0
- package/src/settings.js +526 -0
- package/src/skills.js +71 -0
- package/src/tabs.js +147 -0
- package/src/tools.js +774 -0
- package/src/tree.js +162 -0
- package/src/ui.js +1278 -0
- package/src/usage.js +134 -0
- package/src/voice.js +97 -0
- package/src/web.js +123 -0
- package/src/worktree.js +56 -0
package/src/lsp.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// After HeartCode writes a file, a language server for it (when one is installed) says what is now
|
|
2
|
+
// wrong, and the errors go back to the model with the edit's result: it fixes a type error right
|
|
3
|
+
// away instead of finding it at the end, or never.
|
|
4
|
+
// Servers, each found on PATH or in the project's node_modules/.bin: typescript-language-server,
|
|
5
|
+
// gopls, pyright (or basedpyright), rust-analyzer, clangd, dart. One per language and project,
|
|
6
|
+
// started at the first edit and kept until HeartCode exits. The settings page (General → Features) turns it off.
|
|
7
|
+
import fs from 'node:fs'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import { spawn } from 'node:child_process'
|
|
10
|
+
import { pathToFileURL } from 'node:url'
|
|
11
|
+
import { log } from './log.js'
|
|
12
|
+
|
|
13
|
+
const SERVERS = [
|
|
14
|
+
{ name: 'typescript', ext: /\.(m?[jt]sx?|cjs|cts)$/, cmds: [['typescript-language-server', '--stdio']], id: (f) => (/\.[cm]?tsx?$/.test(f) ? (f.endsWith('x') ? 'typescriptreact' : 'typescript') : f.endsWith('x') ? 'javascriptreact' : 'javascript') },
|
|
15
|
+
{ name: 'go', ext: /\.go$/, cmds: [['gopls']], id: () => 'go' },
|
|
16
|
+
{ name: 'python', ext: /\.pyi?$/, cmds: [['pyright-langserver', '--stdio'], ['basedpyright-langserver', '--stdio']], id: () => 'python' },
|
|
17
|
+
{ name: 'rust', ext: /\.rs$/, cmds: [['rust-analyzer']], id: () => 'rust' },
|
|
18
|
+
{ name: 'c', ext: /\.(c|cc|cpp|cxx|h|hh|hpp)$/, cmds: [['clangd']], id: (f) => (/\.(c|h)$/.test(f) ? 'c' : 'cpp') },
|
|
19
|
+
{ name: 'dart', ext: /\.dart$/, cmds: [['dart', 'language-server', '--client-id', 'heartcode']], id: () => 'dart' },
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
// Extra servers for tests or the user: { name, ext, cmds, id }; tried before the built-in ones.
|
|
23
|
+
export const extraServers = []
|
|
24
|
+
|
|
25
|
+
function find(root, cmd) {
|
|
26
|
+
const local = path.join(root, 'node_modules', '.bin', cmd)
|
|
27
|
+
if (fs.existsSync(local)) return local
|
|
28
|
+
if (path.isAbsolute(cmd)) return fs.existsSync(cmd) ? cmd : null
|
|
29
|
+
for (const d of (process.env.PATH ?? '').split(path.delimiter)) if (d && fs.existsSync(path.join(d, cmd))) return path.join(d, cmd)
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class Server {
|
|
34
|
+
constructor(spec, argv, root) {
|
|
35
|
+
Object.assign(this, { spec, root, next: 1, pending: new Map(), docs: new Map(), diags: new Map(), waiters: new Set(), dead: false })
|
|
36
|
+
this.child = spawn(argv[0], argv.slice(1), { cwd: root, stdio: ['pipe', 'pipe', 'ignore'], detached: process.platform !== 'win32' })
|
|
37
|
+
let buf = Buffer.alloc(0)
|
|
38
|
+
this.child.stdout.on('data', (d) => {
|
|
39
|
+
buf = Buffer.concat([buf, d])
|
|
40
|
+
for (;;) {
|
|
41
|
+
const head = buf.indexOf('\r\n\r\n')
|
|
42
|
+
if (head === -1) return
|
|
43
|
+
const len = Number(/content-length:\s*(\d+)/i.exec(buf.subarray(0, head).toString())?.[1])
|
|
44
|
+
if (!(len >= 0)) return this.#die('a message without Content-Length')
|
|
45
|
+
if (buf.length < head + 4 + len) return
|
|
46
|
+
const body = buf.subarray(head + 4, head + 4 + len).toString()
|
|
47
|
+
buf = buf.subarray(head + 4 + len)
|
|
48
|
+
try {
|
|
49
|
+
this.#receive(JSON.parse(body))
|
|
50
|
+
} catch (e) {
|
|
51
|
+
log('lsp', `${spec.name}: unreadable message: ${e.message}`)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
this.child.on('error', (e) => this.#die(e.message))
|
|
56
|
+
this.child.on('exit', (code) => this.#die(`exited (${code})`))
|
|
57
|
+
this.child.stdin.on('error', () => {})
|
|
58
|
+
// it never keeps HeartCode running: stopped on exit (below), waited on only while checking
|
|
59
|
+
this.child.unref()
|
|
60
|
+
this.child.stdin.unref?.()
|
|
61
|
+
this.child.stdout.unref?.()
|
|
62
|
+
const uri = pathToFileURL(root).href
|
|
63
|
+
this.ready = this.request('initialize', {
|
|
64
|
+
processId: process.pid,
|
|
65
|
+
rootUri: uri,
|
|
66
|
+
rootPath: root,
|
|
67
|
+
workspaceFolders: [{ uri, name: path.basename(root) }],
|
|
68
|
+
capabilities: {
|
|
69
|
+
textDocument: { synchronization: { didSave: true }, publishDiagnostics: { versionSupport: true } },
|
|
70
|
+
workspace: { configuration: true, workspaceFolders: true },
|
|
71
|
+
window: { workDoneProgress: true },
|
|
72
|
+
},
|
|
73
|
+
}).then(() => this.notify('initialized', {}))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#die(why) {
|
|
77
|
+
if (this.dead) return
|
|
78
|
+
this.dead = true
|
|
79
|
+
log('lsp', `${this.spec.name} stopped: ${why}`)
|
|
80
|
+
for (const { reject } of this.pending.values()) reject(new Error(`${this.spec.name} language server ${why}`))
|
|
81
|
+
this.pending.clear()
|
|
82
|
+
for (const w of this.waiters) w()
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#send(msg) {
|
|
86
|
+
if (this.dead) return
|
|
87
|
+
const body = JSON.stringify({ jsonrpc: '2.0', ...msg })
|
|
88
|
+
this.child.stdin.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
request(method, params) {
|
|
92
|
+
const id = this.next++
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
if (this.dead) return reject(new Error(`${this.spec.name} language server is not running`))
|
|
95
|
+
this.pending.set(id, { resolve, reject })
|
|
96
|
+
this.#send({ id, method, params })
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
notify(method, params) {
|
|
101
|
+
this.#send({ method, params })
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#receive(msg) {
|
|
105
|
+
if (msg.id !== undefined && !msg.method) {
|
|
106
|
+
const p = this.pending.get(msg.id)
|
|
107
|
+
this.pending.delete(msg.id)
|
|
108
|
+
return msg.error ? p?.reject(new Error(msg.error.message)) : p?.resolve(msg.result)
|
|
109
|
+
}
|
|
110
|
+
if (msg.id !== undefined) {
|
|
111
|
+
// the server asks something: its settings (none given: its defaults), progress tokens, registrations
|
|
112
|
+
const result = msg.method === 'workspace/configuration' ? (msg.params?.items ?? []).map(() => null) : msg.method === 'workspace/workspaceFolders' ? [{ uri: pathToFileURL(this.root).href, name: path.basename(this.root) }] : null
|
|
113
|
+
return this.#send({ id: msg.id, result })
|
|
114
|
+
}
|
|
115
|
+
if (msg.method === 'textDocument/publishDiagnostics') {
|
|
116
|
+
this.diags.set(msg.params.uri, { at: Date.now(), list: msg.params.diagnostics ?? [] })
|
|
117
|
+
for (const w of this.waiters) w()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The file's errors after this change: waits for the server's report on it, then for it to go
|
|
122
|
+
// quiet (servers often send a fast report, then a full one).
|
|
123
|
+
async check(abs, text, wait) {
|
|
124
|
+
await this.ready
|
|
125
|
+
const uri = pathToFileURL(abs).href
|
|
126
|
+
const since = Date.now()
|
|
127
|
+
const version = (this.docs.get(uri) ?? 0) + 1
|
|
128
|
+
if (version === 1) this.notify('textDocument/didOpen', { textDocument: { uri, languageId: this.spec.id(abs), version, text } })
|
|
129
|
+
else this.notify('textDocument/didChange', { textDocument: { uri, version }, contentChanges: [{ text }] })
|
|
130
|
+
this.notify('textDocument/didSave', { textDocument: { uri }, text })
|
|
131
|
+
this.docs.set(uri, version)
|
|
132
|
+
const fresh = () => (this.diags.get(uri)?.at ?? 0) >= since
|
|
133
|
+
const end = Date.now() + wait
|
|
134
|
+
while (!this.dead) {
|
|
135
|
+
const until = Math.min(end, fresh() ? this.diags.get(uri).at + 700 : end) // 0.7s without a newer report
|
|
136
|
+
if (Date.now() >= until) break
|
|
137
|
+
await new Promise((resolve) => {
|
|
138
|
+
const w = () => (this.waiters.delete(w), clearTimeout(t), resolve())
|
|
139
|
+
const t = setTimeout(w, until - Date.now())
|
|
140
|
+
this.waiters.add(w)
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
return fresh() ? this.diags.get(uri).list : null
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
stop() {
|
|
147
|
+
if (this.dead) return
|
|
148
|
+
this.dead = true
|
|
149
|
+
try {
|
|
150
|
+
if (process.platform !== 'win32') process.kill(-this.child.pid, 'SIGTERM')
|
|
151
|
+
else this.child.kill()
|
|
152
|
+
} catch {}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const running = new Map() // root + server name → Server, or null (none installed / it died)
|
|
157
|
+
process.on('exit', () => {
|
|
158
|
+
for (const s of running.values()) s?.stop()
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
function serverFor(root, rel) {
|
|
162
|
+
const spec = [...extraServers, ...SERVERS].find((x) => x.ext.test(rel))
|
|
163
|
+
if (!spec) return null
|
|
164
|
+
const key = `${root}\0${spec.name}`
|
|
165
|
+
if (!running.has(key)) {
|
|
166
|
+
let server = null
|
|
167
|
+
for (const [cmd, ...args] of spec.cmds) {
|
|
168
|
+
const bin = find(root, cmd)
|
|
169
|
+
if (!bin) continue
|
|
170
|
+
server = new Server(spec, [bin, ...args], root)
|
|
171
|
+
log('lsp', `started ${spec.name}: ${bin} ${args.join(' ')}`)
|
|
172
|
+
break
|
|
173
|
+
}
|
|
174
|
+
running.set(key, server)
|
|
175
|
+
}
|
|
176
|
+
const s = running.get(key)
|
|
177
|
+
return s && !s.dead ? s : null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const SEVERITY = { 1: 'error', 2: 'warning' }
|
|
181
|
+
|
|
182
|
+
// What's wrong in the file now, as a note for the model, or '' (no server, nothing wrong, or no
|
|
183
|
+
// report in time). Errors only, unless there are none: the last check's errors are fixed is said too.
|
|
184
|
+
export async function diagnose(s, rel, text) {
|
|
185
|
+
const server = serverFor(s.root, rel)
|
|
186
|
+
if (!server) return ''
|
|
187
|
+
const abs = path.join(s.root, rel)
|
|
188
|
+
let list
|
|
189
|
+
const keep = setInterval(() => {}, 1000) // the server is unref'd: this keeps HeartCode up while it answers
|
|
190
|
+
try {
|
|
191
|
+
list = await server.check(abs, text, server.checked ? 6000 : 30_000) // the first check waits for the server to start
|
|
192
|
+
server.checked = true
|
|
193
|
+
} catch (e) {
|
|
194
|
+
log('lsp', `${rel}: ${e.message}`)
|
|
195
|
+
return ''
|
|
196
|
+
} finally {
|
|
197
|
+
clearInterval(keep)
|
|
198
|
+
}
|
|
199
|
+
if (!list) return ''
|
|
200
|
+
const errors = list.filter((d) => (d.severity ?? 1) === 1)
|
|
201
|
+
const had = (s.lspErrors ??= new Map()).get(rel)
|
|
202
|
+
s.lspErrors.set(rel, errors.length)
|
|
203
|
+
log('lsp', `${rel}: ${errors.length} error(s)`, errors.slice(0, 20))
|
|
204
|
+
if (!errors.length) return had ? `The ${server.spec.name} language server reports no errors in ${rel} now.` : ''
|
|
205
|
+
const lines = errors.slice(0, 10).map((d) => `${rel}:${d.range.start.line + 1}:${d.range.start.character + 1}: ${SEVERITY[d.severity ?? 1]}: ${String(d.message).split('\n')[0]}${d.source ? ` (${d.source})` : ''}`)
|
|
206
|
+
return `The ${server.spec.name} language server reports ${errors.length} error${errors.length > 1 ? 's' : ''} in ${rel} now:\n${lines.join('\n')}${errors.length > 10 ? `\n… ${errors.length - 10} more` : ''}`
|
|
207
|
+
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// MCP servers (Model Context Protocol), JSON-RPC 2.0. Configured like Claude Code / Cursor, in
|
|
2
|
+
// ~/.heart/mcp.json and <project>/.heartcode/mcp.json:
|
|
3
|
+
// { "mcpServers": { "name": { "command": "npx", "args": [...], "env": {...}, "description": "...", "disabled": false } } }
|
|
4
|
+
// or a remote one: { "url": "https://…/mcp", "headers": { … }, "type": "http" | "sse", "oauth": { "clientId": …, "scope": … } }
|
|
5
|
+
// A local server talks over its stdin/stdout, one message per line. A remote one over Streamable
|
|
6
|
+
// HTTP (or the older HTTP+SSE: "type": "sse", a URL ending in /sse, or when HTTP is refused), and
|
|
7
|
+
// signs in with OAuth when it asks (oauth.js). ${VAR} in a URL or header is the environment's VAR.
|
|
8
|
+
// A server starts the first time the model uses it, and stops when HeartCode exits.
|
|
9
|
+
import { spawn } from 'node:child_process'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { pathToFileURL } from 'node:url'
|
|
12
|
+
import { HOME, readJson, readConfig } from './config.js'
|
|
13
|
+
import { log } from './log.js'
|
|
14
|
+
import { savedToken, signIn } from './oauth.js'
|
|
15
|
+
|
|
16
|
+
// Built in; a config entry with the same name changes it, "disabled": true turns it off.
|
|
17
|
+
export const DEFAULT_SERVERS = {
|
|
18
|
+
'chrome-devtools': {
|
|
19
|
+
command: 'npx',
|
|
20
|
+
args: ['-y', 'chrome-devtools-mcp@latest'],
|
|
21
|
+
description: 'a Chrome browser: open pages, click, fill forms, read the console and network requests, take screenshots',
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// extra: more configs from the caller (--mcp-config: a file path or JSON text); strict: only those
|
|
26
|
+
// (no built-in or configured servers), like Claude Code's --strict-mcp-config.
|
|
27
|
+
export function loadServers(root, { extra = [], strict = false } = {}) {
|
|
28
|
+
const merged = strict ? {} : structuredClone(DEFAULT_SERVERS) // yours (below) win on a name
|
|
29
|
+
const own = strict ? [] : [path.join(HOME, 'mcp.json'), root && path.join(root, '.heartcode', 'mcp.json')].filter(Boolean).map((f) => readConfig(f, {}))
|
|
30
|
+
const given = extra.map((c) => {
|
|
31
|
+
const data = /^\s*\{/.test(c) ? JSON.parse(c) : readJson(path.resolve(c), null)
|
|
32
|
+
if (!data) throw new Error(`can't read the MCP config ${c}`)
|
|
33
|
+
return data
|
|
34
|
+
})
|
|
35
|
+
for (const data of [...own, ...given]) {
|
|
36
|
+
for (const [name, cfg] of Object.entries(data.mcpServers ?? {})) merged[name] = { ...merged[name], ...cfg }
|
|
37
|
+
}
|
|
38
|
+
return Object.fromEntries(Object.entries(merged).filter(([, cfg]) => (cfg.command || cfg.url) && !cfg.disabled))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const expand = (v) => String(v).replace(/\$\{(\w+)\}/g, (m, k) => process.env[k] ?? '')
|
|
42
|
+
|
|
43
|
+
// A server's server-sent events: { event, data }.
|
|
44
|
+
async function* sseEvents(body) {
|
|
45
|
+
const dec = new TextDecoder()
|
|
46
|
+
let buf = ''
|
|
47
|
+
let ev = { event: 'message', data: [] }
|
|
48
|
+
for await (const chunk of body) {
|
|
49
|
+
buf += dec.decode(chunk, { stream: true })
|
|
50
|
+
let nl
|
|
51
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
52
|
+
const line = buf.slice(0, nl).replace(/\r$/, '')
|
|
53
|
+
buf = buf.slice(nl + 1)
|
|
54
|
+
if (!line) {
|
|
55
|
+
if (ev.data.length) yield { event: ev.event, data: ev.data.join('\n') }
|
|
56
|
+
ev = { event: 'message', data: [] }
|
|
57
|
+
} else if (!line.startsWith(':')) {
|
|
58
|
+
const i = line.indexOf(':')
|
|
59
|
+
const [field, value] = i < 0 ? [line, ''] : [line.slice(0, i), line.slice(i + 1).replace(/^ /, '')]
|
|
60
|
+
if (field === 'event') ev.event = value
|
|
61
|
+
else if (field === 'data') ev.data.push(value)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
class Server {
|
|
68
|
+
constructor(name, cfg, root, hub) {
|
|
69
|
+
Object.assign(this, { name, cfg, root, hub, next: 1, pending: new Map(), child: null, ready: null, tools: null })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get url() {
|
|
73
|
+
return this.cfg.url && expand(this.cfg.url)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
start(signal) {
|
|
77
|
+
this.ready ??= (async () => {
|
|
78
|
+
const init = () => this.request('initialize', { protocolVersion: '2025-06-18', capabilities: { roots: {} }, clientInfo: { name: 'heartcode', version: '0.1.0' } }, this.url ? 600_000 : 120_000, signal) // remote: time to sign in
|
|
79
|
+
let r
|
|
80
|
+
if (this.url) {
|
|
81
|
+
const type = this.cfg.type ?? this.cfg.transport
|
|
82
|
+
log('mcp', `connecting to ${this.name}: ${this.url}`)
|
|
83
|
+
this.sse = type === 'sse' || (!type && /\/sse\/?$/.test(new URL(this.url).pathname))
|
|
84
|
+
if (this.sse) {
|
|
85
|
+
try {
|
|
86
|
+
await this.#openSse(signal)
|
|
87
|
+
} catch (e) {
|
|
88
|
+
if (type || !(e.status >= 400 && e.status < 500)) throw e
|
|
89
|
+
log('mcp', `${this.name}: no HTTP+SSE (${e.status}); trying Streamable HTTP`)
|
|
90
|
+
this.sse = false // only guessed from the URL
|
|
91
|
+
this.noSse = true
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
r = await init()
|
|
96
|
+
} catch (e) {
|
|
97
|
+
if (type || this.sse || this.noSse || ![400, 404, 405].includes(e.status)) throw e
|
|
98
|
+
log('mcp', `${this.name}: no Streamable HTTP (${e.status}); trying HTTP+SSE`)
|
|
99
|
+
this.sse = true
|
|
100
|
+
await this.#openSse(signal)
|
|
101
|
+
r = await init()
|
|
102
|
+
}
|
|
103
|
+
} else r = await this.#spawn(init)
|
|
104
|
+
this.protocol = r?.protocolVersion
|
|
105
|
+
this.#send({ jsonrpc: '2.0', method: 'notifications/initialized' })
|
|
106
|
+
})().catch((e) => {
|
|
107
|
+
this.stop() // failed to start: the next use tries again
|
|
108
|
+
throw e
|
|
109
|
+
})
|
|
110
|
+
return this.ready
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async #spawn(init) {
|
|
114
|
+
const { command, args = [], env = {} } = this.cfg
|
|
115
|
+
log('mcp', `starting ${this.name}: ${command} ${args.join(' ')}`)
|
|
116
|
+
const child = (this.child = spawn(command, args, { cwd: this.root ?? undefined, env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'pipe'], detached: process.platform !== 'win32' })) // in the project, so a relative path in its config works
|
|
117
|
+
let buf = ''
|
|
118
|
+
child.stdout.on('data', (d) => {
|
|
119
|
+
buf += d
|
|
120
|
+
let nl
|
|
121
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
122
|
+
const line = buf.slice(0, nl).trim()
|
|
123
|
+
buf = buf.slice(nl + 1)
|
|
124
|
+
if (line) this.#receive(line)
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
child.stderr.on('data', (d) => log('mcp', `${this.name} stderr: ${String(d).trim()}`))
|
|
128
|
+
const gone = new Promise((_, reject) => {
|
|
129
|
+
child.on('error', (e) => reject(new Error(`${this.name} could not start: ${e.message}`)))
|
|
130
|
+
child.on('exit', (code) => {
|
|
131
|
+
this.ready = null
|
|
132
|
+
this.#failAll(new Error(`${this.name} stopped (exit ${code})`))
|
|
133
|
+
reject(new Error(`${this.name} stopped (exit ${code})`))
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
gone.catch(() => {})
|
|
137
|
+
return Promise.race([init(), gone])
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#failAll(err) {
|
|
141
|
+
for (const { reject } of this.pending.values()) reject(err)
|
|
142
|
+
this.pending.clear()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// An HTTP request to the server, signed in: a saved token goes with it; a 401 refreshes it, or
|
|
146
|
+
// signs in (the browser opens) once.
|
|
147
|
+
async #http(url, init, signal) {
|
|
148
|
+
this.token ??= await savedToken(this.url)
|
|
149
|
+
for (let attempt = 0; ; attempt++) {
|
|
150
|
+
const headers = { ...Object.fromEntries(Object.entries(this.cfg.headers ?? {}).map(([k, v]) => [k, expand(v)])), ...init.headers, ...(this.token && { authorization: `Bearer ${this.token}` }) }
|
|
151
|
+
const res = await fetch(url, { ...init, headers, signal: init.signal ?? signal })
|
|
152
|
+
if (res.status === 401 && attempt === 0) {
|
|
153
|
+
const how = res.headers.get('www-authenticate') ?? ''
|
|
154
|
+
await res.body?.cancel()
|
|
155
|
+
this.token = (this.token && (await savedToken(this.url, { force: true }))) || (await this.#signIn(how, signal))
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
if (!res.ok) {
|
|
159
|
+
const text = await res.text().catch(() => '')
|
|
160
|
+
throw Object.assign(new Error(`${this.name}: ${res.status} from ${url}${text ? `: ${text.slice(0, 200)}` : ''}`), { status: res.status })
|
|
161
|
+
}
|
|
162
|
+
return res
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#signIn(how, signal) {
|
|
167
|
+
const open = this.hub.onSignIn
|
|
168
|
+
if (!open) throw new Error(`${this.name} needs you to sign in: start heartcode and run /login_mcp ${this.name}`)
|
|
169
|
+
return signIn(this.url, how, (link) => open(this.name, link), { signal, oauth: this.cfg.oauth })
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// HTTP+SSE (the older transport): a stream of events from the server; the first names where
|
|
173
|
+
// to POST messages, the rest are its messages.
|
|
174
|
+
async #openSse(signal) {
|
|
175
|
+
this.abort?.abort()
|
|
176
|
+
this.abort = new AbortController()
|
|
177
|
+
const res = await this.#http(this.url, { method: 'GET', headers: { accept: 'text/event-stream' }, signal: this.abort.signal }, signal)
|
|
178
|
+
const events = sseEvents(res.body)
|
|
179
|
+
for (;;) {
|
|
180
|
+
const { value, done } = await events.next()
|
|
181
|
+
if (done) throw new Error(`${this.name} closed its event stream before saying where to send messages`)
|
|
182
|
+
if (value.event === 'endpoint') {
|
|
183
|
+
this.endpoint = new URL(value.data, this.url).href
|
|
184
|
+
break
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
;(async () => {
|
|
188
|
+
for await (const ev of events) if (ev.event === 'message') this.#receive(ev.data)
|
|
189
|
+
})()
|
|
190
|
+
.catch((e) => log('mcp', `${this.name} event stream: ${e.message}`))
|
|
191
|
+
.finally(() => {
|
|
192
|
+
if (this.abort?.signal.aborted) return
|
|
193
|
+
this.ready = null
|
|
194
|
+
this.#failAll(new Error(`${this.name} closed the connection`))
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Streamable HTTP: each message is a POST; the answer is JSON or a stream of events.
|
|
199
|
+
// (HTTP+SSE: answers come on the event stream instead.)
|
|
200
|
+
async #post(msg) {
|
|
201
|
+
const headers = { accept: 'application/json, text/event-stream', 'content-type': 'application/json', ...(this.session && { 'mcp-session-id': this.session }), ...(this.protocol && { 'mcp-protocol-version': this.protocol }) }
|
|
202
|
+
const res = await this.#http(this.sse ? this.endpoint : this.url, { method: 'POST', headers, body: JSON.stringify(msg) })
|
|
203
|
+
this.session = res.headers.get('mcp-session-id') ?? this.session
|
|
204
|
+
const type = res.headers.get('content-type') ?? ''
|
|
205
|
+
if (this.sse || res.status === 202 || !res.body) return void (await res.body?.cancel())
|
|
206
|
+
if (type.includes('text/event-stream')) {
|
|
207
|
+
for await (const ev of sseEvents(res.body)) if (ev.event === 'message') this.#receive(ev.data)
|
|
208
|
+
} else {
|
|
209
|
+
const text = await res.text()
|
|
210
|
+
if (text.trim()) for (const m of [JSON.parse(text)].flat()) this.#receive(m)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
#send(msg) {
|
|
215
|
+
if (!this.url) return this.child.stdin.write(JSON.stringify(msg) + '\n')
|
|
216
|
+
this.#post(msg).catch((e) => {
|
|
217
|
+
const p = this.pending.get(msg.id)
|
|
218
|
+
if (!p) return log('mcp', `${this.name}: ${e.message}`)
|
|
219
|
+
this.pending.delete(msg.id)
|
|
220
|
+
p.reject(e)
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#receive(line) {
|
|
225
|
+
let msg = line
|
|
226
|
+
if (typeof line === 'string') {
|
|
227
|
+
try {
|
|
228
|
+
msg = JSON.parse(line)
|
|
229
|
+
} catch {
|
|
230
|
+
return log('mcp', `${this.name} sent a non-JSON line: ${line.slice(0, 200)}`)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (msg.id !== undefined && (msg.result !== undefined || msg.error) && this.pending.has(msg.id)) {
|
|
234
|
+
const { resolve, reject } = this.pending.get(msg.id)
|
|
235
|
+
this.pending.delete(msg.id)
|
|
236
|
+
return msg.error ? reject(new Error(`${this.name}: ${msg.error.message ?? JSON.stringify(msg.error)}`)) : resolve(msg.result)
|
|
237
|
+
}
|
|
238
|
+
// requests from the server: answer the common ones, decline the rest
|
|
239
|
+
if (msg.id !== undefined && msg.method) {
|
|
240
|
+
if (msg.method === 'ping') return this.#send({ jsonrpc: '2.0', id: msg.id, result: {} })
|
|
241
|
+
// the project folder: servers that read or write files (screenshots, traces) may use it
|
|
242
|
+
if (msg.method === 'roots/list') return this.#send({ jsonrpc: '2.0', id: msg.id, result: { roots: this.root ? [{ uri: pathToFileURL(this.root).href, name: path.basename(this.root) }] : [] } })
|
|
243
|
+
return this.#send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `HeartCode does not support ${msg.method}` } })
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
request(method, params, timeout = 120_000, signal) {
|
|
248
|
+
return new Promise((resolve, reject) => {
|
|
249
|
+
const id = this.next++
|
|
250
|
+
const timer = setTimeout(() => {
|
|
251
|
+
this.pending.delete(id)
|
|
252
|
+
reject(new Error(`${this.name} did not answer ${method} within ${timeout / 1000}s`))
|
|
253
|
+
}, timeout)
|
|
254
|
+
const done = (fn) => (v) => {
|
|
255
|
+
clearTimeout(timer)
|
|
256
|
+
signal?.removeEventListener('abort', onAbort)
|
|
257
|
+
fn(v)
|
|
258
|
+
}
|
|
259
|
+
const onAbort = () => {
|
|
260
|
+
this.pending.delete(id)
|
|
261
|
+
done(reject)(Object.assign(new Error('stopped'), { name: 'AbortError' }))
|
|
262
|
+
}
|
|
263
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
264
|
+
this.pending.set(id, { resolve: done(resolve), reject: done(reject) })
|
|
265
|
+
this.#send({ jsonrpc: '2.0', id, method, params })
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async listTools(signal) {
|
|
270
|
+
await this.start(signal)
|
|
271
|
+
if (!this.tools) {
|
|
272
|
+
this.tools = []
|
|
273
|
+
let cursor
|
|
274
|
+
do {
|
|
275
|
+
const r = await this.request('tools/list', cursor ? { cursor } : {})
|
|
276
|
+
this.tools.push(...(r.tools ?? []))
|
|
277
|
+
cursor = r.nextCursor
|
|
278
|
+
} while (cursor)
|
|
279
|
+
}
|
|
280
|
+
return this.tools
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async call(tool, args, signal) {
|
|
284
|
+
await this.start(signal)
|
|
285
|
+
return this.request('tools/call', { name: tool, arguments: args ?? {} }, 300_000, signal)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
stop() {
|
|
289
|
+
if (this.url) {
|
|
290
|
+
this.abort?.abort()
|
|
291
|
+
Object.assign(this, { ready: null, session: null, endpoint: null })
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
if (!this.child) return
|
|
295
|
+
try {
|
|
296
|
+
if (this.child.pid && process.platform !== 'win32') process.kill(-this.child.pid, 'SIGTERM')
|
|
297
|
+
else this.child.kill()
|
|
298
|
+
} catch {}
|
|
299
|
+
this.child = null
|
|
300
|
+
this.ready = null
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// All configured servers for a project; nothing runs until a server is first used.
|
|
305
|
+
// onSignIn(name, url): set by the screen, to show a remote server's sign-in page (none headless).
|
|
306
|
+
export class Mcp {
|
|
307
|
+
constructor(root, opts) {
|
|
308
|
+
this.servers = new Map(Object.entries(loadServers(root, opts)).map(([name, cfg]) => [name, new Server(name, cfg, root, this)]))
|
|
309
|
+
this.onExit = () => this.stopAll()
|
|
310
|
+
process.on('exit', this.onExit)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Done with it (replaced after the config changed): servers stop, the exit hook goes.
|
|
314
|
+
dispose() {
|
|
315
|
+
this.stopAll()
|
|
316
|
+
process.off('exit', this.onExit)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
get names() {
|
|
320
|
+
return [...this.servers.keys()]
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
get(name) {
|
|
324
|
+
return this.servers.get(name)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
stopAll() {
|
|
328
|
+
for (const s of this.servers.values()) s.stop()
|
|
329
|
+
}
|
|
330
|
+
}
|