@hncode/hncode 0.3.2 → 0.3.4
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/README.md +43 -0
- package/package.json +1 -1
- package/src/agent-task.js +78 -0
- package/src/agent.js +150 -45
- package/src/cache.js +130 -0
- package/src/ci.js +244 -0
- package/src/colors.js +18 -0
- package/src/config.js +13 -0
- package/src/git.js +501 -0
- package/src/hooks.js +247 -0
- package/src/index.js +131 -17
- package/src/llm.js +14 -2
- package/src/mcp.js +384 -0
- package/src/plan.js +127 -0
- package/src/skills.js +150 -0
- package/src/subagent-types.js +1 -1
- package/src/tools/edit.js +17 -5
- package/src/tools/git.js +155 -0
- package/src/tools/index.js +2 -0
- package/src/tui.js +1256 -121
- package/src/update.js +35 -14
package/src/mcp.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// MCP client — connect to Model Context Protocol servers and expose their tools.
|
|
2
|
+
//
|
|
3
|
+
// The Model Context Protocol is an open JSON-RPC 2.0 standard. A server speaks it
|
|
4
|
+
// over stdio (a child process) or HTTP. This module implements the CLIENT side so
|
|
5
|
+
// an hncode session can use tools from servers the user configures.
|
|
6
|
+
//
|
|
7
|
+
// Config (`~/.hncode/mcp.json`, or a per-project `<workspace>/.hncode/mcp.json`):
|
|
8
|
+
//
|
|
9
|
+
// {
|
|
10
|
+
// "mcpServers": {
|
|
11
|
+
// "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] },
|
|
12
|
+
// "remote": { "url": "https://example.com/mcp", "headers": { "Authorization": "Bearer ..." } }
|
|
13
|
+
// }
|
|
14
|
+
// }
|
|
15
|
+
//
|
|
16
|
+
// Lifecycle, per the spec:
|
|
17
|
+
// 1. start the transport
|
|
18
|
+
// 2. `initialize` (protocolVersion + clientInfo) -> capabilities
|
|
19
|
+
// 3. `notifications/initialized`
|
|
20
|
+
// 4. `tools/list` -> tool definitions
|
|
21
|
+
// 5. `tools/call` -> per invocation
|
|
22
|
+
//
|
|
23
|
+
// Discovered tools are registered through plugin.registerTool() so they reach the
|
|
24
|
+
// model with no changes to the toolbelt, the agent loop, or the LLM adapters.
|
|
25
|
+
|
|
26
|
+
import fs from 'node:fs';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import cp from 'node:child_process';
|
|
30
|
+
|
|
31
|
+
// The protocol revision this client speaks. Servers negotiate down if needed.
|
|
32
|
+
export const PROTOCOL_VERSION = '2024-11-05';
|
|
33
|
+
export const CLIENT_INFO = { name: 'hncode', version: '0.3.1' };
|
|
34
|
+
|
|
35
|
+
// How long a server gets to complete the initialize + tools/list handshake. This
|
|
36
|
+
// runs BEFORE the TUI opens, so it is deliberately short: a wedged server should
|
|
37
|
+
// cost a few seconds, not the 30s a tool call is allowed.
|
|
38
|
+
export const CONNECT_TIMEOUT_MS = 8_000;
|
|
39
|
+
|
|
40
|
+
// Live connections made at startup, shared by every consumer of the MCP client
|
|
41
|
+
// (the CLI startup and the TUI's /mcp). Kept in this module rather than passed
|
|
42
|
+
// around so the status view can always see the real state.
|
|
43
|
+
let liveConnections = [];
|
|
44
|
+
export function setLiveConnections(conns) { liveConnections = conns || []; }
|
|
45
|
+
export function getLiveConnections() { return liveConnections; }
|
|
46
|
+
|
|
47
|
+
export function globalMcpFile() {
|
|
48
|
+
return process.env.HNCODE_MCP_FILE || path.join(os.homedir(), '.hncode', 'mcp.json');
|
|
49
|
+
}
|
|
50
|
+
export function projectMcpFile(workspace) {
|
|
51
|
+
return path.join(workspace || process.cwd(), '.hncode', 'mcp.json');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Merge global + project server definitions (project wins on a name clash).
|
|
55
|
+
// Returns { servers: {name: def}, errors: [string] }. Never throws.
|
|
56
|
+
export function loadMcpConfig(workspace) {
|
|
57
|
+
const out = { servers: {}, errors: [] };
|
|
58
|
+
const files = workspace ? [globalMcpFile(), projectMcpFile(workspace)] : [globalMcpFile()];
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
for (const f of files) {
|
|
61
|
+
const abs = path.resolve(f);
|
|
62
|
+
if (seen.has(abs)) continue;
|
|
63
|
+
seen.add(abs);
|
|
64
|
+
let text;
|
|
65
|
+
try { text = fs.readFileSync(abs, 'utf8'); } catch { continue; }
|
|
66
|
+
let doc;
|
|
67
|
+
try { doc = JSON.parse(text); } catch (e) { out.errors.push(`${abs}: invalid JSON (${e.message})`); continue; }
|
|
68
|
+
const servers = (doc && doc.mcpServers) || {};
|
|
69
|
+
for (const [name, def] of Object.entries(servers)) {
|
|
70
|
+
if (!def || typeof def !== 'object') { out.errors.push(`${abs}: server "${name}" must be an object`); continue; }
|
|
71
|
+
if (!def.command && !def.url) { out.errors.push(`${abs}: server "${name}" needs "command" or "url"`); continue; }
|
|
72
|
+
out.servers[name] = { ...def, _source: abs };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---- JSON-RPC framing -------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
// A stdio connection: newline-delimited JSON-RPC over the child's stdin/stdout.
|
|
81
|
+
// MCP's stdio transport is line-delimited (NOT Content-Length framed — that is
|
|
82
|
+
// the LSP convention, and mixing them up is the usual way this breaks).
|
|
83
|
+
export function createStdioTransport(command, args, opts = {}) {
|
|
84
|
+
const child = cp.spawn(command, args || [], {
|
|
85
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
86
|
+
env: { ...process.env, ...(opts.env || {}) },
|
|
87
|
+
cwd: opts.cwd || process.cwd(),
|
|
88
|
+
windowsHide: true,
|
|
89
|
+
// npm-installed shims are `.cmd` on Windows and need a shell to run.
|
|
90
|
+
shell: process.platform === 'win32' && /\.(cmd|bat)$/i.test(command),
|
|
91
|
+
});
|
|
92
|
+
const state = {
|
|
93
|
+
child,
|
|
94
|
+
buffer: '',
|
|
95
|
+
nextId: 1,
|
|
96
|
+
pending: new Map(), // id -> { resolve, reject, timer }
|
|
97
|
+
closed: false,
|
|
98
|
+
stderr: '',
|
|
99
|
+
handlers: [],
|
|
100
|
+
};
|
|
101
|
+
child.stdout.on('data', (d) => {
|
|
102
|
+
state.buffer += d.toString('utf8');
|
|
103
|
+
let i;
|
|
104
|
+
while ((i = state.buffer.indexOf('\n')) >= 0) {
|
|
105
|
+
const line = state.buffer.slice(0, i).trim();
|
|
106
|
+
state.buffer = state.buffer.slice(i + 1);
|
|
107
|
+
if (!line) continue;
|
|
108
|
+
let msg;
|
|
109
|
+
try { msg = JSON.parse(line); } catch { continue; } // ignore non-JSON noise
|
|
110
|
+
handleMessage(state, msg);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
child.stderr.on('data', (d) => { state.stderr = (state.stderr + d.toString('utf8')).slice(-4000); });
|
|
114
|
+
child.on('error', (e) => failAll(state, `server process error: ${e.message}`));
|
|
115
|
+
child.on('close', (code) => { state.closed = true; failAll(state, `server exited (code ${code})`); });
|
|
116
|
+
return state;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function handleMessage(state, msg) {
|
|
120
|
+
if (msg.id != null && state.pending.has(msg.id)) {
|
|
121
|
+
const p = state.pending.get(msg.id);
|
|
122
|
+
state.pending.delete(msg.id);
|
|
123
|
+
clearTimeout(p.timer);
|
|
124
|
+
if (msg.error) p.reject(new Error(msg.error.message || JSON.stringify(msg.error)));
|
|
125
|
+
else p.resolve(msg.result);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
// A server-initiated REQUEST (e.g. sampling). Answer politely so the server
|
|
129
|
+
// does not hang; hncode does not implement sampling, so it is "not supported".
|
|
130
|
+
if (msg.method && msg.id != null) {
|
|
131
|
+
try {
|
|
132
|
+
writeMessage(state, { jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: `method not supported: ${msg.method}` } });
|
|
133
|
+
} catch { /* transport closed */ }
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
for (const h of state.handlers) { try { h(msg); } catch { /* a notification handler must not throw */ } }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function failAll(state, reason) {
|
|
140
|
+
for (const [, p] of state.pending) { clearTimeout(p.timer); p.reject(new Error(reason)); }
|
|
141
|
+
state.pending.clear();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function writeMessage(state, msg) {
|
|
145
|
+
if (state.closed || !state.child.stdin || state.child.stdin.destroyed) throw new Error('transport closed');
|
|
146
|
+
state.child.stdin.write(JSON.stringify(msg) + '\n');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Send a request and await its reply. `timeoutMs` guards a wedged server.
|
|
150
|
+
export function request(state, method, params, timeoutMs = 30_000) {
|
|
151
|
+
const id = state.nextId++;
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
const timer = setTimeout(() => {
|
|
154
|
+
state.pending.delete(id);
|
|
155
|
+
reject(new Error(`${method} timed out after ${timeoutMs}ms`));
|
|
156
|
+
}, timeoutMs);
|
|
157
|
+
state.pending.set(id, { resolve, reject, timer });
|
|
158
|
+
try { writeMessage(state, { jsonrpc: '2.0', id, method, params: params || {} }); }
|
|
159
|
+
catch (e) { state.pending.delete(id); clearTimeout(timer); reject(e); }
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function notify(state, method, params) {
|
|
164
|
+
try { writeMessage(state, { jsonrpc: '2.0', method, params: params || {} }); } catch { /* best-effort */ }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---- HTTP transport ---------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
// Streamable-HTTP transport: one POST per JSON-RPC message. The server may reply
|
|
170
|
+
// with JSON or an SSE stream; both are parsed here.
|
|
171
|
+
export function createHttpTransport(url, headers) {
|
|
172
|
+
return {
|
|
173
|
+
kind: 'http',
|
|
174
|
+
url,
|
|
175
|
+
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', ...(headers || {}) },
|
|
176
|
+
sessionId: null,
|
|
177
|
+
nextId: 1,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function httpRequest(state, method, params, timeoutMs = 30_000) {
|
|
182
|
+
const id = state.nextId++;
|
|
183
|
+
const ctrl = new AbortController();
|
|
184
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
185
|
+
try {
|
|
186
|
+
const res = await fetch(state.url, {
|
|
187
|
+
method: 'POST',
|
|
188
|
+
headers: state.sessionId ? { ...state.headers, 'mcp-session-id': state.sessionId } : state.headers,
|
|
189
|
+
body: JSON.stringify({ jsonrpc: '2.0', id, method, params: params || {} }),
|
|
190
|
+
signal: ctrl.signal,
|
|
191
|
+
});
|
|
192
|
+
const sid = res.headers.get('mcp-session-id');
|
|
193
|
+
if (sid) state.sessionId = sid;
|
|
194
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
|
195
|
+
const ct = (res.headers.get('content-type') || '').toLowerCase();
|
|
196
|
+
if (ct.includes('text/event-stream')) {
|
|
197
|
+
const msg = parseSseForId(await res.text(), id);
|
|
198
|
+
if (!msg) throw new Error('no response message in SSE stream');
|
|
199
|
+
if (msg.error) throw new Error(msg.error.message || JSON.stringify(msg.error));
|
|
200
|
+
return msg.result;
|
|
201
|
+
}
|
|
202
|
+
const json = await res.json();
|
|
203
|
+
if (json.error) throw new Error(json.error.message || JSON.stringify(json.error));
|
|
204
|
+
return json.result;
|
|
205
|
+
} finally {
|
|
206
|
+
clearTimeout(timer);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Pull the JSON-RPC reply for `id` out of an SSE body.
|
|
211
|
+
export function parseSseForId(body, id) {
|
|
212
|
+
for (const line of String(body || '').split('\n')) {
|
|
213
|
+
const l = line.trim();
|
|
214
|
+
if (!l.startsWith('data:')) continue;
|
|
215
|
+
const payload = l.slice(5).trim();
|
|
216
|
+
if (!payload || payload === '[DONE]') continue;
|
|
217
|
+
try {
|
|
218
|
+
const msg = JSON.parse(payload);
|
|
219
|
+
if (msg && msg.id === id) return msg;
|
|
220
|
+
} catch { /* not JSON: skip */ }
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---- connection + tool discovery -------------------------------------------
|
|
226
|
+
|
|
227
|
+
// Connect to one server, handshake, and list its tools.
|
|
228
|
+
// Returns { ok, tools, error, serverInfo, transport, isHttp } — never throws.
|
|
229
|
+
export async function connectServer(name, def, opts = {}) {
|
|
230
|
+
// CONNECT_TIMEOUT (not the 30s tool-call timeout): this runs during startup, so
|
|
231
|
+
// a wedged server must not hold the session hostage. 8s is generous for a
|
|
232
|
+
// handshake while keeping a dead server's cost to single-digit seconds.
|
|
233
|
+
const timeoutMs = opts.timeoutMs || CONNECT_TIMEOUT_MS;
|
|
234
|
+
let transport;
|
|
235
|
+
const isHttp = !!def.url;
|
|
236
|
+
try {
|
|
237
|
+
transport = isHttp
|
|
238
|
+
? createHttpTransport(def.url, def.headers)
|
|
239
|
+
: createStdioTransport(def.command, def.args, { env: def.env, cwd: def.cwd });
|
|
240
|
+
} catch (e) {
|
|
241
|
+
return { ok: false, tools: [], error: `could not start "${name}": ${e.message}` };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const send = (method, params) => (isHttp
|
|
245
|
+
? httpRequest(transport, method, params, timeoutMs)
|
|
246
|
+
: request(transport, method, params, timeoutMs));
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
const init = await send('initialize', {
|
|
250
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
251
|
+
capabilities: { tools: {} },
|
|
252
|
+
clientInfo: CLIENT_INFO,
|
|
253
|
+
});
|
|
254
|
+
if (!isHttp) notify(transport, 'notifications/initialized', {});
|
|
255
|
+
const list = await send('tools/list', {});
|
|
256
|
+
const tools = Array.isArray(list && list.tools) ? list.tools : [];
|
|
257
|
+
return { ok: true, tools, error: null, serverInfo: (init && init.serverInfo) || {}, transport, isHttp };
|
|
258
|
+
} catch (e) {
|
|
259
|
+
try { if (!isHttp) transport.child.kill(); } catch { /* already gone */ }
|
|
260
|
+
return { ok: false, tools: [], error: `"${name}": ${e.message}` };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Call one tool on a connected server. Returns { ok, text, error, raw }.
|
|
265
|
+
export async function callTool(transport, isHttp, toolName, args, timeoutMs = 60_000) {
|
|
266
|
+
try {
|
|
267
|
+
const result = isHttp
|
|
268
|
+
? await httpRequest(transport, 'tools/call', { name: toolName, arguments: args || {} }, timeoutMs)
|
|
269
|
+
: await request(transport, 'tools/call', { name: toolName, arguments: args || {} }, timeoutMs);
|
|
270
|
+
return { ok: true, text: flattenContent(result), error: null, raw: result };
|
|
271
|
+
} catch (e) {
|
|
272
|
+
return { ok: false, text: '', error: e.message, raw: null };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// MCP tool results carry a `content` array of typed blocks. Flatten to text for
|
|
277
|
+
// the model; non-text blocks are described rather than dropped silently.
|
|
278
|
+
export function flattenContent(result) {
|
|
279
|
+
if (!result) return '';
|
|
280
|
+
if (typeof result === 'string') return result;
|
|
281
|
+
const blocks = Array.isArray(result.content) ? result.content : [];
|
|
282
|
+
const parts = [];
|
|
283
|
+
for (const b of blocks) {
|
|
284
|
+
if (!b || typeof b !== 'object') continue;
|
|
285
|
+
if (b.type === 'text' && typeof b.text === 'string') parts.push(b.text);
|
|
286
|
+
else if (b.type === 'resource' && b.resource) parts.push(`[resource ${b.resource.uri || ''}]`);
|
|
287
|
+
else if (b.type === 'image') parts.push(`[image ${b.mimeType || ''}]`);
|
|
288
|
+
else parts.push(`[${b.type || 'unknown'} block]`);
|
|
289
|
+
}
|
|
290
|
+
if (!parts.length && typeof result.text === 'string') parts.push(result.text);
|
|
291
|
+
return parts.join('\n');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// A tool name safe for every provider's function-name rules and for matching
|
|
295
|
+
// back to its server + original name. Providers want [A-Za-z0-9_-], so
|
|
296
|
+
// "server.tool" becomes "mcp__server__tool".
|
|
297
|
+
export function mcpToolName(server, tool) {
|
|
298
|
+
const clean = (s) => String(s || '').replace(/[^A-Za-z0-9_-]/g, '_');
|
|
299
|
+
return `mcp__${clean(server)}__${clean(tool)}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Convert an MCP tool definition into an hncode tool spec whose execute() calls
|
|
303
|
+
// back into the server.
|
|
304
|
+
export function toHncodeSpec(server, tool, transport, isHttp) {
|
|
305
|
+
return {
|
|
306
|
+
name: mcpToolName(server, tool.name),
|
|
307
|
+
description: (tool.description || `MCP tool ${tool.name} from ${server}.`).slice(0, 4000),
|
|
308
|
+
parameters: tool.inputSchema && typeof tool.inputSchema === 'object'
|
|
309
|
+
? tool.inputSchema
|
|
310
|
+
: { type: 'object', properties: {} },
|
|
311
|
+
_mcp: { server, tool: tool.name },
|
|
312
|
+
async execute(args) {
|
|
313
|
+
const r = await callTool(transport, isHttp, tool.name, args);
|
|
314
|
+
if (!r.ok) return `Error calling ${server}/${tool.name}: ${r.error}`;
|
|
315
|
+
// MCP signals tool-level failure with isError, not a JSON-RPC error.
|
|
316
|
+
if (r.raw && r.raw.isError) return `Error from ${server}/${tool.name}: ${r.text}`;
|
|
317
|
+
return r.text || '(no output)';
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Connect every configured server and register its tools through the plugin API.
|
|
323
|
+
// Returns { servers: [...], toolCount }.
|
|
324
|
+
export async function connectAll(config, registerTool, opts = {}) {
|
|
325
|
+
const only = opts.only;
|
|
326
|
+
const entries = Object.entries((config && config.servers) || {}).filter(([name]) => !only || only.includes(name));
|
|
327
|
+
// Connect in PARALLEL. This runs on the startup path, before the TUI opens, so
|
|
328
|
+
// a serial loop meant one slow/wedged server delayed every later one — N dead
|
|
329
|
+
// servers cost N × timeout before the user saw anything. Runs concurrently, the
|
|
330
|
+
// whole set costs the SLOWEST single server instead of their sum.
|
|
331
|
+
const results = await Promise.all(entries.map(async ([name, def]) => {
|
|
332
|
+
const conn = await connectServer(name, def, opts);
|
|
333
|
+
if (!conn.ok) return { name, ok: false, toolCount: 0, error: conn.error, serverInfo: {} };
|
|
334
|
+
let registered = 0;
|
|
335
|
+
let err = null;
|
|
336
|
+
for (const tool of conn.tools) {
|
|
337
|
+
if (!tool || !tool.name) continue;
|
|
338
|
+
try {
|
|
339
|
+
registerTool(toHncodeSpec(name, tool, conn.transport, conn.isHttp));
|
|
340
|
+
registered++;
|
|
341
|
+
} catch (e) {
|
|
342
|
+
// A name clash with a built-in is reported, not fatal: keep the rest.
|
|
343
|
+
err = err || `tool "${tool.name}": ${e.message}`;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return { name, ok: true, toolCount: registered, error: err, serverInfo: conn.serverInfo, transport: conn.transport, isHttp: conn.isHttp };
|
|
347
|
+
}));
|
|
348
|
+
const toolCount = results.reduce((n, r) => n + (r.toolCount || 0), 0);
|
|
349
|
+
return { servers: results, toolCount };
|
|
350
|
+
}
|
|
351
|
+
// Disconnect every connected server (kills stdio children). HTTP needs nothing.
|
|
352
|
+
|
|
353
|
+
// Disconnect every connected server (kills stdio children). HTTP needs nothing.
|
|
354
|
+
export function disconnectAll(connections) {
|
|
355
|
+
for (const c of connections || []) {
|
|
356
|
+
if (c && c.transport && c.transport.child) {
|
|
357
|
+
try { c.transport.child.kill(); } catch { /* already gone */ }
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Human-readable status for /mcp.
|
|
363
|
+
export function describeServers(config, connections) {
|
|
364
|
+
const names = Object.keys((config && config.servers) || {});
|
|
365
|
+
if (!names.length) {
|
|
366
|
+
return ['No MCP servers configured.', '', `Config file: ${globalMcpFile()}`,
|
|
367
|
+
'Add one with /mcp-config add <name> <command|url> [args...]'];
|
|
368
|
+
}
|
|
369
|
+
const byName = new Map((connections || []).map((c) => [c.name, c]));
|
|
370
|
+
const lines = [`MCP servers (${names.length}) — config: ${globalMcpFile()}`, ''];
|
|
371
|
+
for (const n of names) {
|
|
372
|
+
const def = config.servers[n];
|
|
373
|
+
const conn = byName.get(n);
|
|
374
|
+
const target = def.url ? def.url : `${def.command || ''} ${(def.args || []).join(' ')}`.trim();
|
|
375
|
+
let status;
|
|
376
|
+
if (!conn) status = 'not connected';
|
|
377
|
+
else if (!conn.ok) status = `FAILED: ${conn.error}`;
|
|
378
|
+
else status = `connected — ${conn.toolCount} tool(s)${conn.serverInfo && conn.serverInfo.name ? ` (${conn.serverInfo.name})` : ''}`;
|
|
379
|
+
lines.push(` ${n}: ${status}`);
|
|
380
|
+
lines.push(` ${target}`);
|
|
381
|
+
if (conn && conn.error) lines.push(` ! ${conn.error}`);
|
|
382
|
+
}
|
|
383
|
+
return lines;
|
|
384
|
+
}
|
package/src/plan.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Plan review helpers — turning a model's <plan> block into something the user
|
|
2
|
+
// can actually review before any file is touched.
|
|
3
|
+
//
|
|
4
|
+
// Plan mode already asks the model for a <plan>…</plan> block and prompts to
|
|
5
|
+
// approve it. What this module adds is the review material and the persistence:
|
|
6
|
+
//
|
|
7
|
+
// * filesReferenced(plan) — which paths the plan says it will touch, so the
|
|
8
|
+
// user sees the blast radius instead of prose.
|
|
9
|
+
// * planStats(plan) — step/line counts for a compact summary line.
|
|
10
|
+
// * savePlan() — write the approved plan to
|
|
11
|
+
// <workspace>/.hncode/plans/<timestamp>.md so it
|
|
12
|
+
// survives the session and can be handed to a
|
|
13
|
+
// subagent or pasted into an issue.
|
|
14
|
+
//
|
|
15
|
+
// Everything here is pure except savePlan(). The parsing is deliberately
|
|
16
|
+
// tolerant: a plan is free-form Markdown, so a missed file reference degrades to
|
|
17
|
+
// "fewer files listed", never to an error.
|
|
18
|
+
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
|
|
22
|
+
// Paths that appear in a plan: backticked spans, and bare tokens that look like
|
|
23
|
+
// a path (contains a slash and a file-ish extension, or a well-known filename).
|
|
24
|
+
// Ordered by first appearance, de-duplicated, and filtered to plausible files.
|
|
25
|
+
const PATH_EXT = /\.(?:[a-z0-9]{1,8})$/i;
|
|
26
|
+
const KNOWN_FILES = /^(?:AGENTS\.md|README(?:\.[a-z]+)?|package\.json|pyproject\.toml|Cargo\.toml|go\.mod|Makefile|Dockerfile|\.gitignore|tsconfig\.json)$/i;
|
|
27
|
+
|
|
28
|
+
export function filesReferenced(plan) {
|
|
29
|
+
const text = String(plan || '');
|
|
30
|
+
const found = [];
|
|
31
|
+
const seen = new Set();
|
|
32
|
+
const push = (raw) => {
|
|
33
|
+
let p = String(raw || '').trim();
|
|
34
|
+
// Strip trailing punctuation the prose adds around a path.
|
|
35
|
+
p = p.replace(/^[`'"(\[]+/, '').replace(/[`'")\],.;:]+$/, '');
|
|
36
|
+
if (!p || p.length > 200) return;
|
|
37
|
+
if (/\s/.test(p)) return;
|
|
38
|
+
if (p.includes('://')) return; // a URL, not a path
|
|
39
|
+
if (/^[-*#>]/.test(p)) return;
|
|
40
|
+
// A token is a plausible path when it has a separator, a file-ish extension,
|
|
41
|
+
// or is a well-known project filename. This keeps a bare word like "config"
|
|
42
|
+
// from being reported as a file.
|
|
43
|
+
const looksPath = p.includes('/') || p.includes('\\') || PATH_EXT.test(p) || KNOWN_FILES.test(p);
|
|
44
|
+
if (!looksPath) return;
|
|
45
|
+
const key = p.replace(/\\/g, '/');
|
|
46
|
+
if (seen.has(key)) return;
|
|
47
|
+
seen.add(key);
|
|
48
|
+
found.push(p);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// 1. backticked spans (the prompt asks for files in backticks)
|
|
52
|
+
for (const m of text.matchAll(/`([^`\n]+)`/g)) push(m[1]);
|
|
53
|
+
// 2. bare tokens that look like paths (covers un-backticked mentions)
|
|
54
|
+
for (const m of text.matchAll(/(?:^|[\s(])((?:[\w.@-]+\/)+[\w.@-]+|\b[\w.-]+\.[a-z0-9]{1,8}\b)/gi)) push(m[1]);
|
|
55
|
+
return found;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Compact summary for the review box: how many steps and how many lines.
|
|
59
|
+
export function planStats(plan) {
|
|
60
|
+
const text = String(plan || '');
|
|
61
|
+
const lines = text.split('\n');
|
|
62
|
+
const nonEmpty = lines.filter((l) => l.trim()).length;
|
|
63
|
+
// A "step" is a heading (## / ###) or a top-level numbered item.
|
|
64
|
+
const steps = lines.filter((l) => /^\s*(?:#{1,6}\s+\S|\d+[.)]\s+\S)/.test(l)).length;
|
|
65
|
+
const words = text.split(/\s+/).filter(Boolean).length;
|
|
66
|
+
return { lines: nonEmpty, steps, words, files: filesReferenced(text).length };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Where a saved plan goes: <workspace>/.hncode/plans/<id>.md. Kept inside the
|
|
70
|
+
// project (not ~/.hncode) so it can be committed or reviewed with the change.
|
|
71
|
+
export function plansDir(workspace) {
|
|
72
|
+
return path.join(workspace || process.cwd(), '.hncode', 'plans');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Persist a plan. `id` defaults to a timestamp slug. Returns
|
|
76
|
+
// { ok, path, error }. Never throws.
|
|
77
|
+
export function savePlan(plan, workspace, id) {
|
|
78
|
+
const text = String(plan || '').trim();
|
|
79
|
+
if (!text) return { ok: false, path: '', error: 'empty plan' };
|
|
80
|
+
const dir = plansDir(workspace);
|
|
81
|
+
const slug = id || new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
|
|
82
|
+
const file = path.join(dir, `${slug}.md`);
|
|
83
|
+
try {
|
|
84
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
85
|
+
fs.writeFileSync(file, `# Plan (${new Date().toISOString()})\n\n${text}\n`, 'utf8');
|
|
86
|
+
} catch (e) {
|
|
87
|
+
return { ok: false, path: file, error: e.message };
|
|
88
|
+
}
|
|
89
|
+
return { ok: true, path: file, error: null };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function listPlans(workspace) {
|
|
93
|
+
const dir = plansDir(workspace);
|
|
94
|
+
let names;
|
|
95
|
+
try { names = fs.readdirSync(dir); } catch { return []; }
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const n of names) {
|
|
98
|
+
if (!n.endsWith('.md')) continue;
|
|
99
|
+
const full = path.join(dir, n);
|
|
100
|
+
try { const st = fs.statSync(full); if (st.isFile()) out.push({ name: n, path: full, mtimeMs: st.mtimeMs }); }
|
|
101
|
+
catch { /* unreadable: skip */ }
|
|
102
|
+
}
|
|
103
|
+
// Newest first. Tie-break by name DESCENDING so that two plans written in the
|
|
104
|
+
// same millisecond still come back in a deterministic (and, for the
|
|
105
|
+
// timestamp-slug naming, newest-looking) order.
|
|
106
|
+
out.sort((a, b) => (b.mtimeMs - a.mtimeMs) || (a.name < b.name ? 1 : a.name > b.name ? -1 : 0));
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The review panel's lines. Kept here (not the TUI) so it can be unit-tested.
|
|
111
|
+
export function reviewLines(plan, workspace) {
|
|
112
|
+
const stats = planStats(plan);
|
|
113
|
+
const files = filesReferenced(plan);
|
|
114
|
+
const lines = [];
|
|
115
|
+
lines.push(`Steps: ${stats.steps} Lines: ${stats.lines} Files mentioned: ${files.length}`);
|
|
116
|
+
lines.push('');
|
|
117
|
+
if (files.length) {
|
|
118
|
+
lines.push('Files this plan will touch:');
|
|
119
|
+
for (const f of files.slice(0, 40)) lines.push(` ${f}`);
|
|
120
|
+
if (files.length > 40) lines.push(` … and ${files.length - 40} more`);
|
|
121
|
+
} else {
|
|
122
|
+
lines.push('No specific files named in the plan.');
|
|
123
|
+
}
|
|
124
|
+
lines.push('');
|
|
125
|
+
lines.push('Enter = approve & execute e = edit the plan Esc = keep planning');
|
|
126
|
+
return lines;
|
|
127
|
+
}
|
package/src/skills.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Skills — user-supplied reusable prompt packages.
|
|
2
|
+
//
|
|
3
|
+
// A skill is a Markdown file under ~/.hncode/skills/<name>.md. Its body is
|
|
4
|
+
// text that gets injected into the conversation when the user activates it, so
|
|
5
|
+
// a skill is essentially a saved prompt fragment ("review this PR for security
|
|
6
|
+
// issues", "write tests in this project's style", …).
|
|
7
|
+
//
|
|
8
|
+
// Two ways a skill comes into being:
|
|
9
|
+
// * /import-skill <file.md> — copies (or overwrites) an external .md into the
|
|
10
|
+
// skills directory. A duplicate name is REPLACED, not rejected.
|
|
11
|
+
// * hand-authoring a file in the skills directory.
|
|
12
|
+
//
|
|
13
|
+
// Activation is /skill:<name>. The file body is what gets sent; an optional YAML
|
|
14
|
+
// front-matter block (--- … ---) is stripped from the body and only used for the
|
|
15
|
+
// display name / description, so a skill file can stay self-describing without
|
|
16
|
+
// leaking its metadata into the prompt.
|
|
17
|
+
//
|
|
18
|
+
// Everything here is filesystem-only and side-effect free apart from the
|
|
19
|
+
// explicit import/write helpers.
|
|
20
|
+
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
|
|
25
|
+
export const SKILL_PREFIX = 'skill:';
|
|
26
|
+
|
|
27
|
+
// The skills directory. Kept next to the other hncode state (~/.hncode/…), and
|
|
28
|
+
// overridable through the environment for tests.
|
|
29
|
+
export function skillsDir() {
|
|
30
|
+
return process.env.HNCODE_SKILLS_DIR || path.join(os.homedir(), '.hncode', 'skills');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A skill name is the file's basename without extension. Normalise what the
|
|
34
|
+
// user typed ("Skill:Review", "/skill:review", "review.md") to a bare name.
|
|
35
|
+
export function normalizeSkillName(name) {
|
|
36
|
+
let n = String(name == null ? '' : name).trim();
|
|
37
|
+
n = n.replace(/^\/+/, ''); // leading slash
|
|
38
|
+
if (n.toLowerCase().startsWith(SKILL_PREFIX)) n = n.slice(SKILL_PREFIX.length);
|
|
39
|
+
n = n.replace(/\.md$/i, ''); // trailing .md
|
|
40
|
+
return n.trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Parse an optional leading `---\n…\n---` front-matter block. Returns the body
|
|
44
|
+
// with the block removed, plus whatever fields were found. Tolerates a missing
|
|
45
|
+
// or malformed block (then body === text and meta is empty).
|
|
46
|
+
export function parseFrontMatter(text) {
|
|
47
|
+
const src = String(text == null ? '' : text);
|
|
48
|
+
const m = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/.exec(src);
|
|
49
|
+
if (!m) return { meta: {}, body: src };
|
|
50
|
+
const meta = {};
|
|
51
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
52
|
+
const kv = /^\s*([A-Za-z0-9_-]+)\s*:\s*(.*?)\s*$/.exec(line);
|
|
53
|
+
if (!kv) continue;
|
|
54
|
+
let v = kv[2];
|
|
55
|
+
// Strip a single layer of matching quotes.
|
|
56
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
|
57
|
+
meta[kv[1].toLowerCase()] = v;
|
|
58
|
+
}
|
|
59
|
+
return { meta, body: src.slice(m[0].length) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// All skills, sorted by name. Each entry: { name, description, path, size }.
|
|
63
|
+
// Unreadable files are skipped rather than failing the whole listing.
|
|
64
|
+
export function listSkills() {
|
|
65
|
+
const dir = skillsDir();
|
|
66
|
+
let files;
|
|
67
|
+
try { files = fs.readdirSync(dir); } catch { return []; }
|
|
68
|
+
const out = [];
|
|
69
|
+
for (const f of files) {
|
|
70
|
+
if (!f.toLowerCase().endsWith('.md')) continue;
|
|
71
|
+
const full = path.join(dir, f);
|
|
72
|
+
let st;
|
|
73
|
+
try { st = fs.statSync(full); } catch { continue; }
|
|
74
|
+
if (!st.isFile()) continue;
|
|
75
|
+
const name = f.replace(/\.md$/i, '');
|
|
76
|
+
let description = '';
|
|
77
|
+
try {
|
|
78
|
+
const { meta } = parseFrontMatter(fs.readFileSync(full, 'utf8'));
|
|
79
|
+
description = meta.description || meta.title || '';
|
|
80
|
+
} catch { /* unreadable: keep it listed with no description */ }
|
|
81
|
+
out.push({ name, description, path: full, size: st.size });
|
|
82
|
+
}
|
|
83
|
+
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Read a skill's body (front-matter stripped). Returns null when not found.
|
|
88
|
+
export function readSkill(name) {
|
|
89
|
+
const n = normalizeSkillName(name);
|
|
90
|
+
if (!n) return null;
|
|
91
|
+
const full = path.join(skillsDir(), `${n}.md`);
|
|
92
|
+
let text;
|
|
93
|
+
try { text = fs.readFileSync(full, 'utf8'); } catch { return null; }
|
|
94
|
+
const { meta, body } = parseFrontMatter(text);
|
|
95
|
+
return { name: n, description: meta.description || meta.title || '', body: body.trim(), path: full };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Import (copy or overwrite) an external Markdown file as a skill.
|
|
99
|
+
// `name` overrides the derived name when given. Returns
|
|
100
|
+
// { ok: true, name, path, replaced } or { ok: false, error }.
|
|
101
|
+
export function importSkill(file, name) {
|
|
102
|
+
const srcPath = path.resolve(String(file == null ? '' : file));
|
|
103
|
+
let text;
|
|
104
|
+
try {
|
|
105
|
+
const st = fs.statSync(srcPath);
|
|
106
|
+
if (!st.isFile()) return { ok: false, error: `Not a file: ${srcPath}` };
|
|
107
|
+
text = fs.readFileSync(srcPath, 'utf8');
|
|
108
|
+
} catch (e) {
|
|
109
|
+
return { ok: false, error: `Cannot read ${srcPath}: ${e.message}` };
|
|
110
|
+
}
|
|
111
|
+
// Name precedence: explicit argument, else front-matter `name`, else filename.
|
|
112
|
+
const { meta } = parseFrontMatter(text);
|
|
113
|
+
const derived = name || meta.name || path.basename(srcPath).replace(/\.md$/i, '');
|
|
114
|
+
const skillName = normalizeSkillName(derived);
|
|
115
|
+
if (!skillName) return { ok: false, error: 'Could not derive a skill name (pass one explicitly).' };
|
|
116
|
+
if (/[\\/]/.test(skillName)) return { ok: false, error: `Invalid skill name: ${skillName}` };
|
|
117
|
+
|
|
118
|
+
const dir = skillsDir();
|
|
119
|
+
const dest = path.join(dir, `${skillName}.md`);
|
|
120
|
+
const replaced = fs.existsSync(dest);
|
|
121
|
+
try {
|
|
122
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
123
|
+
fs.writeFileSync(dest, text, 'utf8');
|
|
124
|
+
} catch (e) {
|
|
125
|
+
return { ok: false, error: `Cannot write ${dest}: ${e.message}` };
|
|
126
|
+
}
|
|
127
|
+
return { ok: true, name: skillName, path: dest, replaced };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function deleteSkill(name) {
|
|
131
|
+
const n = normalizeSkillName(name);
|
|
132
|
+
if (!n) return false;
|
|
133
|
+
try { fs.unlinkSync(path.join(skillsDir(), `${n}.md`)); return true; } catch { return false; }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Completion candidates for the composer: names prefixed with the marker the
|
|
137
|
+
// user must type, so accepting a candidate yields `/skill:<name>`.
|
|
138
|
+
export function skillCompletions(prefix = '') {
|
|
139
|
+
const p = normalizeSkillName(prefix).toLowerCase();
|
|
140
|
+
return listSkills()
|
|
141
|
+
.filter((s) => !p || s.name.toLowerCase().startsWith(p))
|
|
142
|
+
.map((s) => ({ name: s.name, description: s.description, insert: SKILL_PREFIX + s.name }));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Build the text a skill contributes to the conversation. A short header keeps
|
|
146
|
+
// the model from treating the body as the user's own words.
|
|
147
|
+
export function skillPrompt(skill) {
|
|
148
|
+
if (!skill || !skill.body) return '';
|
|
149
|
+
return `[skill: ${skill.name}]\n\n${skill.body}`;
|
|
150
|
+
}
|
package/src/subagent-types.js
CHANGED
|
@@ -41,7 +41,7 @@ export const SUBAGENT_TYPES = {
|
|
|
41
41
|
// An explicit allowlist (not `null` = everything) so Agent/AgentSwarm are
|
|
42
42
|
// excluded. Mirrors kimi's CODER_TOOLS.
|
|
43
43
|
tools: [
|
|
44
|
-
'Read', 'Write', 'Edit', 'Glob', 'Grep', 'FileLines', 'Bash',
|
|
44
|
+
'Read', 'Write', 'Edit', 'Glob', 'Grep', 'FileLines', 'Bash', 'Git',
|
|
45
45
|
'TodoList', 'FetchURL', 'WebSearch', 'ReadMediaFile',
|
|
46
46
|
'TaskList', 'TaskOutput', 'TaskStop', 'TaskWait',
|
|
47
47
|
],
|