@hncode/hncode 0.3.3 → 0.3.5
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 +131 -674
- package/README.md +5 -4
- package/README_CN.md +5 -4
- package/package.json +2 -2
- package/src/agent-task.js +78 -0
- package/src/agent.js +75 -45
- package/src/config.js +63 -19
- package/src/git.js +170 -1
- package/src/index.js +27 -13
- package/src/llm.js +7 -3
- package/src/mcp.js +21 -13
- package/src/plugin.js +10 -8
- package/src/term.js +84 -46
- package/src/tui.js +659 -186
- package/src/tui.js.bak_20260921_191527 +9203 -0
- package/src/update.js +35 -14
package/src/index.js
CHANGED
|
@@ -356,35 +356,46 @@ export async function main(argv) {
|
|
|
356
356
|
await loadPlugins(cfg.pluginDir);
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
-
// Connect configured MCP servers and register their tools
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
|
|
359
|
+
// Connect configured MCP servers and register their tools.
|
|
360
|
+
//
|
|
361
|
+
// This is a FUNCTION rather than an inline block because the two modes want
|
|
362
|
+
// different timing:
|
|
363
|
+
// * headless (`-p`) MUST wait — the prompt runs immediately and its model
|
|
364
|
+
// request has to see the MCP tools in the tool list.
|
|
365
|
+
// * the TUI must NOT wait — connecting can take seconds (8s per wedged
|
|
366
|
+
// server), and blocking the first paint for that is unacceptable. The TUI
|
|
367
|
+
// starts this in the background and reports the result as a notice.
|
|
368
|
+
// Failures are reported but never fatal: an unreachable server must not stop
|
|
369
|
+
// the session from starting.
|
|
370
|
+
const connectMcp = async () => {
|
|
371
|
+
if (cfg.mcp === false) return { servers: [], toolCount: 0 };
|
|
363
372
|
try {
|
|
364
373
|
const mcp = await import('./mcp.js');
|
|
365
374
|
const { API } = await import('./plugin.js');
|
|
366
375
|
const mcpCfg = mcp.loadMcpConfig(cfg.workspace);
|
|
367
|
-
if (Object.keys(mcpCfg.servers).length) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
}
|
|
376
|
+
if (!Object.keys(mcpCfg.servers).length) return { servers: [], toolCount: 0 };
|
|
377
|
+
const res = await mcp.connectAll(mcpCfg, (spec) => API.registerTool(spec));
|
|
378
|
+
mcp.setLiveConnections(res.servers);
|
|
379
|
+
if (process.env.HNCODE_MCP_VERBOSE === '1') {
|
|
380
|
+
for (const s of res.servers) {
|
|
381
|
+
console.error(s.ok ? `[hncode-mcp] ${s.name}: ${s.toolCount} tool(s)` : `[hncode-mcp] ${s.name}: ${s.error}`);
|
|
374
382
|
}
|
|
375
383
|
}
|
|
384
|
+
return res;
|
|
376
385
|
} catch (e) {
|
|
377
386
|
console.error(`hncode: MCP setup failed: ${e.message}`);
|
|
387
|
+
return { servers: [], toolCount: 0 };
|
|
378
388
|
}
|
|
379
|
-
}
|
|
389
|
+
};
|
|
380
390
|
|
|
381
|
-
// headless prompt
|
|
391
|
+
// headless prompt: MCP must be live BEFORE the request goes out.
|
|
382
392
|
if (args.prompt) {
|
|
383
393
|
const format = args['output-format'];
|
|
384
394
|
if (!isValidFormat(format)) {
|
|
385
395
|
console.error(`hncode: unknown --output-format "${format}". Use one of: ${FORMATS.join(', ')}.`);
|
|
386
396
|
return EXIT.CONFIG;
|
|
387
397
|
}
|
|
398
|
+
await connectMcp();
|
|
388
399
|
let session = null;
|
|
389
400
|
if (args.session) session = typeof args.session === 'string' ? sess.loadSession(args.session) : sess.latestSession(undefined, undefined, { skipEmpty: true });
|
|
390
401
|
else if (args.continue) session = sess.latestSession(undefined, undefined, { skipEmpty: true });
|
|
@@ -408,6 +419,9 @@ export async function main(argv) {
|
|
|
408
419
|
// Remote control: expose a local socket so a script can queue a prompt or
|
|
409
420
|
// query status on the running session (see ci.js).
|
|
410
421
|
control: args.control === true ? '' : (typeof args.control === 'string' ? args.control : null),
|
|
422
|
+
// MCP runs in the BACKGROUND once the TUI is up (see startTUI): connecting
|
|
423
|
+
// can take seconds and must not delay the first paint.
|
|
424
|
+
mcpConnect: connectMcp,
|
|
411
425
|
session: loadOrCreateSession(args, cfg),
|
|
412
426
|
};
|
|
413
427
|
await startTui(opts);
|
package/src/llm.js
CHANGED
|
@@ -12,7 +12,9 @@ import { effortWire } from './config.js';
|
|
|
12
12
|
import { applyPromptCache } from './cache.js';
|
|
13
13
|
|
|
14
14
|
export function openAiToolDefs(tools) {
|
|
15
|
-
|
|
15
|
+
// Guard: a caller that passes null/undefined (e.g. setToolsList(null)) used to
|
|
16
|
+
// throw on `.map`, failing the whole request. An empty tool list is valid.
|
|
17
|
+
return (Array.isArray(tools) ? tools : []).map((t) => ({
|
|
16
18
|
type: 'function',
|
|
17
19
|
function: {
|
|
18
20
|
name: t.name,
|
|
@@ -23,7 +25,7 @@ export function openAiToolDefs(tools) {
|
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
export function anthropicToolDefs(tools) {
|
|
26
|
-
return tools.map((t) => ({
|
|
28
|
+
return (Array.isArray(tools) ? tools : []).map((t) => ({
|
|
27
29
|
name: t.name,
|
|
28
30
|
description: t.description,
|
|
29
31
|
input_schema: t.parameters,
|
|
@@ -120,7 +122,9 @@ function buildBody(cfg, messages, streaming = true) {
|
|
|
120
122
|
|
|
121
123
|
// tool list used for defs (injected via setTools)
|
|
122
124
|
let llmToolsList = [];
|
|
123
|
-
|
|
125
|
+
// Only ever store an array: `null`/undefined here would make every later request
|
|
126
|
+
// throw when the defs are built.
|
|
127
|
+
export function setToolsList(list) { llmToolsList = Array.isArray(list) ? list : []; }
|
|
124
128
|
|
|
125
129
|
export class LLM {
|
|
126
130
|
constructor(cfg) {
|
package/src/mcp.js
CHANGED
|
@@ -32,6 +32,11 @@ import cp from 'node:child_process';
|
|
|
32
32
|
export const PROTOCOL_VERSION = '2024-11-05';
|
|
33
33
|
export const CLIENT_INFO = { name: 'hncode', version: '0.3.1' };
|
|
34
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
|
+
|
|
35
40
|
// Live connections made at startup, shared by every consumer of the MCP client
|
|
36
41
|
// (the CLI startup and the TUI's /mcp). Kept in this module rather than passed
|
|
37
42
|
// around so the status view can always see the real state.
|
|
@@ -222,7 +227,10 @@ export function parseSseForId(body, id) {
|
|
|
222
227
|
// Connect to one server, handshake, and list its tools.
|
|
223
228
|
// Returns { ok, tools, error, serverInfo, transport, isHttp } — never throws.
|
|
224
229
|
export async function connectServer(name, def, opts = {}) {
|
|
225
|
-
|
|
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;
|
|
226
234
|
let transport;
|
|
227
235
|
const isHttp = !!def.url;
|
|
228
236
|
try {
|
|
@@ -314,16 +322,15 @@ export function toHncodeSpec(server, tool, transport, isHttp) {
|
|
|
314
322
|
// Connect every configured server and register its tools through the plugin API.
|
|
315
323
|
// Returns { servers: [...], toolCount }.
|
|
316
324
|
export async function connectAll(config, registerTool, opts = {}) {
|
|
317
|
-
const servers = [];
|
|
318
|
-
let toolCount = 0;
|
|
319
325
|
const only = opts.only;
|
|
320
|
-
|
|
321
|
-
|
|
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]) => {
|
|
322
332
|
const conn = await connectServer(name, def, opts);
|
|
323
|
-
if (!conn.ok) {
|
|
324
|
-
servers.push({ name, ok: false, toolCount: 0, error: conn.error, serverInfo: {} });
|
|
325
|
-
continue;
|
|
326
|
-
}
|
|
333
|
+
if (!conn.ok) return { name, ok: false, toolCount: 0, error: conn.error, serverInfo: {} };
|
|
327
334
|
let registered = 0;
|
|
328
335
|
let err = null;
|
|
329
336
|
for (const tool of conn.tools) {
|
|
@@ -336,11 +343,12 @@ export async function connectAll(config, registerTool, opts = {}) {
|
|
|
336
343
|
err = err || `tool "${tool.name}": ${e.message}`;
|
|
337
344
|
}
|
|
338
345
|
}
|
|
339
|
-
toolCount
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
return { servers, toolCount };
|
|
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 };
|
|
343
350
|
}
|
|
351
|
+
// Disconnect every connected server (kills stdio children). HTTP needs nothing.
|
|
344
352
|
|
|
345
353
|
// Disconnect every connected server (kills stdio children). HTTP needs nothing.
|
|
346
354
|
export function disconnectAll(connections) {
|
package/src/plugin.js
CHANGED
|
@@ -152,17 +152,19 @@ export async function loadPlugins(dir) {
|
|
|
152
152
|
continue;
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
// Metadata
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
155
|
+
// Metadata can arrive three ways, and all three are in the wild:
|
|
156
|
+
// export default { name: 'x', version: '1' } (object)
|
|
157
|
+
// export default 'x' (plain string)
|
|
158
|
+
// export const meta = { name: 'x' } (named export)
|
|
159
|
+
// Only the default export used to be read, so a plugin using the named form
|
|
160
|
+
// showed up as its FILENAME with version 0.0.0 in /plugins. Check them all.
|
|
160
161
|
const def = (mod && mod.default) || {};
|
|
161
|
-
const
|
|
162
|
+
const defMeta = typeof def === 'string' ? { name: def } : def;
|
|
163
|
+
const namedMeta = (mod && mod.meta) || {};
|
|
162
164
|
const plugin = {
|
|
163
165
|
id: f,
|
|
164
|
-
name:
|
|
165
|
-
version:
|
|
166
|
+
name: defMeta.name || defMeta.meta?.name || namedMeta.name || namedMeta.meta?.name || f,
|
|
167
|
+
version: defMeta.version || defMeta.meta?.version || namedMeta.version || namedMeta.meta?.version || '0.0.0',
|
|
166
168
|
};
|
|
167
169
|
loadedPlugins.push(plugin);
|
|
168
170
|
installFn(API);
|
package/src/term.js
CHANGED
|
@@ -50,13 +50,47 @@ export function strWidth(s) {
|
|
|
50
50
|
// renders East Asian / wide / some symbol glyphs as 2 columns. Must be used for any
|
|
51
51
|
// cursor positioning and line wrapping so an I-shaped (insert) caret lines up.
|
|
52
52
|
// Source of truth: Unicode wide ranges + a small set of CJK-fused symbol glyphs.
|
|
53
|
+
//
|
|
54
|
+
// PERFORMANCE (this is the hottest function in the whole TUI — a profile of a
|
|
55
|
+
// 500-message transcript streaming markdown put ~48% of samples in this plus
|
|
56
|
+
// clusterWidth/clusterGlyphWidth/isWideByDefault):
|
|
57
|
+
// * a printable-ASCII fast path returns the length immediately. Agent output
|
|
58
|
+
// is overwhelmingly ASCII, and measuring it cost ~30 range comparisons per
|
|
59
|
+
// character for nothing. 1MB of ASCII went from 219ms to ~0.
|
|
60
|
+
// * everything else is memoised (see WIDTH_CACHE_MAX). Rows are re-measured
|
|
61
|
+
// every frame while a reply streams, and the same substrings come back
|
|
62
|
+
// again and again.
|
|
53
63
|
export function visualWidth(s) {
|
|
54
64
|
if (!s) return 0;
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
|
|
65
|
+
const str = typeof s === 'string' ? s : String(s);
|
|
66
|
+
// Fast path: printable ASCII measures exactly its length.
|
|
67
|
+
if (isPrintableAscii(str)) return str.length;
|
|
68
|
+
const hit = widthCache.get(str);
|
|
69
|
+
if (hit !== undefined) return hit;
|
|
70
|
+
const w = clusterWidth(str);
|
|
71
|
+
if (widthCache.size >= WIDTH_CACHE_MAX) {
|
|
72
|
+
// Cheap eviction: drop the oldest insertion (Map preserves order).
|
|
73
|
+
const oldest = widthCache.keys().next().value;
|
|
74
|
+
if (oldest !== undefined) widthCache.delete(oldest);
|
|
75
|
+
}
|
|
76
|
+
widthCache.set(str, w);
|
|
77
|
+
return w;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// All code units printable ASCII? `\t`/newline/escape all fail, which is what we
|
|
81
|
+
// want: those have their own width rules (tab = 4, ESC starts an ANSI sequence).
|
|
82
|
+
function isPrintableAscii(str) {
|
|
83
|
+
for (let i = 0; i < str.length; i++) {
|
|
84
|
+
const c = str.charCodeAt(i);
|
|
85
|
+
if (c < 0x20 || c > 0x7e) return false;
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
58
88
|
}
|
|
59
89
|
|
|
90
|
+
const WIDTH_CACHE_MAX = 4096;
|
|
91
|
+
const widthCache = new Map();
|
|
92
|
+
|
|
93
|
+
|
|
60
94
|
// ---- East Asian Width tables (Unicode 15) --------------------------------
|
|
61
95
|
// Wide/Fullwidth ranges -> 2 columns. Everything else is 1 unless it is a
|
|
62
96
|
// zero-width character (see ZERO_WIDTH) or emoji presentation (see below).
|
|
@@ -151,69 +185,69 @@ export function charWidth(ch) {
|
|
|
151
185
|
// Tab renders as 4 spaces in most terminals.
|
|
152
186
|
if (c === 0x09) return 4;
|
|
153
187
|
if (c < 0x20 || c === 0x7F) return 0;
|
|
188
|
+
// FAST PATH: plain printable ASCII is exactly one cell wide. Without this each
|
|
189
|
+
// ASCII character paid for isZeroWidth + isWideByDefault (≈30 range tests) + a
|
|
190
|
+
// binary search, which is most of what made width measurement the hottest
|
|
191
|
+
// function in the TUI.
|
|
192
|
+
if (c >= 0x20 && c <= 0x7e) return 1;
|
|
154
193
|
if (isZeroWidth(c)) return 0;
|
|
155
194
|
if (isWideByDefault(c)) return 2;
|
|
156
195
|
if (inRanges(c, WIDE)) return 2;
|
|
157
196
|
return 1;
|
|
158
197
|
}
|
|
159
198
|
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const len = (cp) => cp > 0xFFFF ? 2 : 1;
|
|
169
|
-
|
|
170
|
-
const firstCp = at(i);
|
|
171
|
-
let end = i + len(firstCp);
|
|
172
|
-
const startedRegional = isRegional(firstCp);
|
|
173
|
-
|
|
174
|
-
for (;;) {
|
|
175
|
-
if (end >= s.length) break;
|
|
176
|
-
const cp = at(end);
|
|
177
|
-
if (cp === 0x200D) { // ZWJ: absorb the next base too
|
|
178
|
-
end += 1;
|
|
179
|
-
if (end >= s.length) break;
|
|
180
|
-
end += len(at(end));
|
|
181
|
-
continue;
|
|
182
|
-
}
|
|
183
|
-
if (isZeroWidth(cp) || cp === 0xFE0F) { end += len(cp); continue; }
|
|
184
|
-
// A regional-indicator PAIR forms one flag glyph.
|
|
185
|
-
if (startedRegional && isRegional(cp)) { end += len(cp); break; }
|
|
186
|
-
break;
|
|
187
|
-
}
|
|
188
|
-
return { text: s.slice(i, end), next: end };
|
|
189
|
-
}
|
|
199
|
+
// Shared native segmenter. `Intl.Segmenter` implements the full Unicode
|
|
200
|
+
// grapheme-break algorithm in native code, so it is both more correct than the
|
|
201
|
+
// hand-rolled scanner it replaces (ZWJ families, flags, combining marks) and
|
|
202
|
+
// much cheaper — that scanner allocated a `{text, next}` object per character,
|
|
203
|
+
// which showed up as GC pressure in profiles.
|
|
204
|
+
const graphemeSegmenter = typeof Intl !== 'undefined' && typeof Intl.Segmenter === 'function'
|
|
205
|
+
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
206
|
+
: null;
|
|
190
207
|
|
|
191
208
|
// Width of a whole string measured by GRAPHEME CLUSTER: a ZWJ sequence or a
|
|
192
209
|
// flag counts 2 columns total, not the sum of its codepoints (which used to
|
|
193
210
|
// make 👨👩👧 measure 8 columns and break every row it appeared on).
|
|
194
211
|
export function clusterWidth(s) {
|
|
195
212
|
const str = String(s || '');
|
|
213
|
+
if (!str) return 0;
|
|
196
214
|
let w = 0;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
215
|
+
if (graphemeSegmenter) {
|
|
216
|
+
// NOTE: an "ASCII runs are free" split was tried here and made things WORSE
|
|
217
|
+
// (a 500KB markdown string went 97ms -> 544ms): slicing the string into a
|
|
218
|
+
// run per CJK character costs far more than handing the whole thing to the
|
|
219
|
+
// native segmenter. The real win is the printable-ASCII fast path in
|
|
220
|
+
// visualWidth() plus the memo cache, not micro-splitting here.
|
|
221
|
+
for (const { segment } of graphemeSegmenter.segment(str)) w += clusterGlyphWidth(segment);
|
|
222
|
+
return w;
|
|
202
223
|
}
|
|
224
|
+
// Fallback for a runtime without Intl.Segmenter: per code point.
|
|
225
|
+
for (const ch of str) w += clusterGlyphWidth(ch);
|
|
203
226
|
return w;
|
|
204
227
|
}
|
|
228
|
+
// Width of one grapheme cluster. Allocation-free on the common path: a
|
|
205
229
|
|
|
230
|
+
// Width of one grapheme cluster. Allocation-free on the common path: a
|
|
231
|
+
// single-code-point cluster (every ASCII character, most CJK) returns
|
|
232
|
+
// charWidth directly instead of first building an array of code points.
|
|
206
233
|
function clusterGlyphWidth(cluster) {
|
|
207
|
-
const
|
|
234
|
+
const first = cluster.codePointAt(0);
|
|
235
|
+
if (first === undefined) return 0;
|
|
236
|
+
if (cluster.length <= (first > 0xFFFF ? 2 : 1)) return charWidth(cluster);
|
|
237
|
+
|
|
238
|
+
const cps = [];
|
|
239
|
+
for (let i = 0; i < cluster.length;) {
|
|
240
|
+
const cp = cluster.codePointAt(i);
|
|
241
|
+
cps.push(cp);
|
|
242
|
+
i += cp > 0xFFFF ? 2 : 1;
|
|
243
|
+
}
|
|
208
244
|
// ZWJ sequence / flag pair / VS16-presented symbol => 2 columns.
|
|
209
245
|
if (cps.includes(0x200D)) return 2;
|
|
210
|
-
if (cps.
|
|
211
|
-
const base = cps[0];
|
|
246
|
+
if (cps.some(isRegional)) return 2;
|
|
212
247
|
if (cps.includes(0xFE0F)) return 2; // explicit emoji presentation
|
|
213
|
-
|
|
214
|
-
let
|
|
215
|
-
|
|
216
|
-
return baseW + extra;
|
|
248
|
+
let width = charWidth(String.fromCodePoint(cps[0]));
|
|
249
|
+
for (let k = 1; k < cps.length; k++) width += charWidth(String.fromCodePoint(cps[k]));
|
|
250
|
+
return width;
|
|
217
251
|
}
|
|
218
252
|
|
|
219
253
|
// Estimate the LLM token count of a plain string, matching kimi-code's
|
|
@@ -264,7 +298,11 @@ export function expandTabs(s, tabSize = 8) {
|
|
|
264
298
|
continue;
|
|
265
299
|
}
|
|
266
300
|
out += ch;
|
|
267
|
-
|
|
301
|
+
// Per CHARACTER, `charWidth` is the right measure and is far cheaper than
|
|
302
|
+
// clusterWidth here: the loop already iterates code points (so a surrogate
|
|
303
|
+
// pair arrives as a single `ch`), and clusterWidth would spin up the
|
|
304
|
+
// segmenter for every single character.
|
|
305
|
+
col += charWidth(ch);
|
|
268
306
|
}
|
|
269
307
|
return out;
|
|
270
308
|
}
|