@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/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, so a headless
360
- // `-p` run and the TUI both see them. Failures are reported but never fatal:
361
- // an unreachable server must not stop the session from starting.
362
- if (cfg.mcp !== false) {
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
- const res = await mcp.connectAll(mcpCfg, (spec) => API.registerTool(spec));
369
- mcp.setLiveConnections(res.servers);
370
- if (process.env.HNCODE_MCP_VERBOSE === '1') {
371
- for (const s of res.servers) {
372
- console.error(s.ok ? `[hncode-mcp] ${s.name}: ${s.toolCount} tool(s)` : `[hncode-mcp] ${s.name}: ${s.error}`);
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
- return tools.map((t) => ({
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
- export function setToolsList(list) { llmToolsList = list; }
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
- const timeoutMs = opts.timeoutMs || 30_000;
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
- for (const [name, def] of Object.entries((config && config.servers) || {})) {
321
- if (only && !only.includes(name)) continue;
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 += registered;
340
- servers.push({ name, ok: true, toolCount: registered, error: err, serverInfo: conn.serverInfo, transport: conn.transport, isHttp: conn.isHttp });
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 comes from the default export. `name` there is a plain STRING
156
- // (`export default { name: 'recorder' }`), so reading `meta.name` off it
157
- // returned undefined and the plugin showed up as its FILENAME with version
158
- // 0.0.0 — /plugins listed every plugin under the wrong name. Accept both
159
- // shapes: a string name, or an object carrying { name, version }.
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 meta = typeof def === 'string' ? { name: def } : def;
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: meta.name || meta.meta?.name || f,
165
- version: meta.version || meta.meta?.version || '0.0.0',
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
- // Measured per GRAPHEME CLUSTER (see clusterWidth): summing charWidth per
56
- // codepoint made a ZWJ emoji sequence count 8 columns instead of 2.
57
- return clusterWidth(s);
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
- // Split a string into grapheme clusters that a terminal renders as ONE glyph.
161
- // Minimal but covers what actually appears in agent output: ZWJ sequences
162
- // (👨‍👩‍👧), variation selectors (❤️), and flag pairs (🇺🇸).
163
- function nextCluster(s, i) {
164
- // NOTE: every offset here is a UTF-16 INDEX (what slice() consumes). Mixing
165
- // in codepoint offsets desynced the scan across surrogate pairs, so a ZWJ
166
- // family measured 5 columns instead of 2.
167
- const at = (idx) => s.codePointAt(idx);
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
- for (let i = 0; i < str.length;) {
198
- const { text, next } = nextCluster(str, i);
199
- i = next;
200
- if (!text) { i++; continue; }
201
- w += clusterGlyphWidth(text);
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 cps = [...cluster].map((c) => c.codePointAt(0));
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.filter(isRegional).length >= 1) return 2;
211
- const base = cps[0];
246
+ if (cps.some(isRegional)) return 2;
212
247
  if (cps.includes(0xFE0F)) return 2; // explicit emoji presentation
213
- const baseW = charWidth(String.fromCodePoint(base));
214
- let extra = 0;
215
- for (let k = 1; k < cps.length; k++) extra += charWidth(String.fromCodePoint(cps[k]));
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
- col += clusterWidth(ch);
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
  }