@hncode/hncode 0.5.1 → 0.5.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.
Files changed (60) hide show
  1. package/bin/install.cjs +24 -44
  2. package/bin/preinstall.cjs +0 -14
  3. package/package.json +2 -2
  4. package/src/agent-task.js +12 -1
  5. package/src/agent.js +279 -48
  6. package/src/cache.js +175 -130
  7. package/src/cell-buffer.js +5 -0
  8. package/src/clipboard.js +111 -8
  9. package/src/context-bar.js +46 -5
  10. package/src/doctor.js +7 -2
  11. package/src/git.js +35 -4
  12. package/src/index.js +29 -5
  13. package/src/keybindings.js +25 -3
  14. package/src/llm.js +60 -6
  15. package/src/mcp.js +419 -384
  16. package/src/permissions.js +29 -7
  17. package/src/plugin.js +95 -10
  18. package/src/registry-browser.js +21 -6
  19. package/src/remote.js +307 -289
  20. package/src/session-core.js +203 -0
  21. package/src/session-host.js +213 -0
  22. package/src/session-view.js +110 -0
  23. package/src/settings-schema.js +12 -9
  24. package/src/shell-safety.js +610 -473
  25. package/src/swarm-progress.js +348 -293
  26. package/src/tasks-browser.js +4 -1
  27. package/src/team/constants.js +20 -0
  28. package/src/team/context.js +89 -0
  29. package/src/team/mailbox.js +216 -0
  30. package/src/team/permission.js +121 -0
  31. package/src/team/protocol.js +167 -0
  32. package/src/team/runner.js +462 -0
  33. package/src/team/spawn.js +305 -0
  34. package/src/team/store.js +172 -0
  35. package/src/team/tasks.js +177 -0
  36. package/src/team/team.js +346 -0
  37. package/src/team-panel.js +624 -0
  38. package/src/term-caps.js +457 -410
  39. package/src/term.js +162 -19
  40. package/src/tool-result.js +70 -7
  41. package/src/tools/agent-swarm.js +114 -11
  42. package/src/tools/agent.js +464 -386
  43. package/src/tools/bash.js +53 -14
  44. package/src/tools/diagnostics.js +13 -0
  45. package/src/tools/edit.js +428 -50
  46. package/src/tools/git.js +15 -3
  47. package/src/tools/glob.js +5 -3
  48. package/src/tools/grep.js +19 -5
  49. package/src/tools/ignore.js +17 -3
  50. package/src/tools/index.js +19 -3
  51. package/src/tools/read.js +2 -1
  52. package/src/tools/shell.js +145 -134
  53. package/src/tools/tasks.js +214 -141
  54. package/src/tools/team.js +411 -0
  55. package/src/tools/utils.js +12 -3
  56. package/src/tools/web-search.js +53 -40
  57. package/src/tui.js +7496 -5366
  58. package/src/update.js +320 -115
  59. package/src/web-daemon.js +27 -4
  60. package/src/web-live.js +250 -0
package/src/git.js CHANGED
@@ -33,6 +33,10 @@ const GIT_ENV = [
33
33
 
34
34
  // Run git with args in `cwd`. Returns { ok, stdout, stderr, code, error }.
35
35
  // Never throws: a missing git binary or a non-repo is data, not an exception.
36
+ //
37
+ // SYNCHRONOUS, and therefore only for the queries made at STARTUP (the status bar's branch and
38
+ // sha). It blocks the event loop, so an AbortSignal cannot fire during it — use `runGitAsync` for
39
+ // anything a tool call reaches.
36
40
  export function runGit(args, cwd, opts = {}) {
37
41
  const timeout = opts.timeoutMs || 30_000;
38
42
  try {
@@ -53,7 +57,6 @@ export function runGit(args, cwd, opts = {}) {
53
57
  return { ok: false, stdout: '', stderr: '', code: null, error: e.message };
54
58
  }
55
59
  }
56
-
57
60
  export function isRepo(cwd) {
58
61
  const r = runGit(['rev-parse', '--is-inside-work-tree'], cwd);
59
62
  return r.ok && r.stdout.trim() === 'true';
@@ -436,10 +439,20 @@ export function rebase(cwd, onto) {
436
439
  // The async form below runs the same command off the main thread, so the frame
437
440
  // draws immediately and the badge fills in when the data arrives.
438
441
 
442
+ /**
443
+ * Run git without blocking the event loop.
444
+ *
445
+ * `opts.signal` makes the command interruptible, which is the whole reason a tool call must use this
446
+ * rather than `runGit`: a synchronous `spawnSync` holds the event loop, so an AbortSignal can never
447
+ * fire during it — measured, `spawnSync` accepts a `signal` option and ignores it, waiting out the
448
+ * full timeout. With Esc interrupting a turn, `git status` or `git log` on a large repository would
449
+ * otherwise freeze the TUI for up to the timeout with no key able to reach it.
450
+ */
439
451
  export function runGitAsync(args, cwd, opts = {}) {
440
452
  return new Promise((resolve) => {
441
453
  let child;
442
454
  const timeout = opts.timeoutMs || 30_000;
455
+ const signal = opts.signal;
443
456
  try {
444
457
  child = cp.spawn('git', [...GIT_ENV, ...args], {
445
458
  cwd: cwd || process.cwd(),
@@ -453,11 +466,29 @@ export function runGitAsync(args, cwd, opts = {}) {
453
466
  let stdout = '';
454
467
  let stderr = '';
455
468
  let done = false;
456
- const finish = (r) => { if (!done) { done = true; clearTimeout(timer); resolve(r); } };
469
+ const finish = (r) => {
470
+ if (done) return;
471
+ done = true;
472
+ clearTimeout(timer);
473
+ if (signal) signal.removeEventListener('abort', onAbort);
474
+ resolve(r);
475
+ };
457
476
  const timer = setTimeout(() => {
458
- try { child.kill(); } catch {}
459
- finish({ ok: false, stdout: '', stderr: '', code: null, error: 'git timed out' });
477
+ try { child.kill(); } catch { /* already gone */ }
478
+ // The partial output is returned rather than dropped: it is often still useful, and an empty
479
+ // string would look like "git had nothing to say" instead of "git was cut short".
480
+ finish({ ok: false, stdout, stderr, code: null, error: 'git timed out' });
460
481
  }, timeout);
482
+ // `interrupted` is a distinct error from a timeout, so the caller can tell a user's Esc from a
483
+ // command that hung.
484
+ const onAbort = () => {
485
+ try { child.kill(); } catch { /* already gone */ }
486
+ finish({ ok: false, stdout, stderr, code: null, error: 'interrupted' });
487
+ };
488
+ if (signal) {
489
+ if (signal.aborted) { clearTimeout(timer); onAbort(); return; }
490
+ signal.addEventListener('abort', onAbort, { once: true });
491
+ }
461
492
  if (child.stdout) child.stdout.on('data', (d) => { stdout += d.toString('utf8'); });
462
493
  if (child.stderr) child.stderr.on('data', (d) => { stderr += d.toString('utf8'); });
463
494
  child.on('error', (e) => finish({ ok: false, stdout: '', stderr: '', code: null, error: e.message }));
package/src/index.js CHANGED
@@ -5,9 +5,8 @@ import path from 'node:path';
5
5
  import fs from 'node:fs';
6
6
  import { resolveConfig, effectiveSnapshot, saveConfig, resolveModelArg, readPersonalPrompt } from './config.js';
7
7
  import * as sess from './session.js';
8
- import { Agent, SYSTEM_PROMPT } from './agent.js';
8
+ import { Agent, SYSTEM_PROMPT, canSplitAfter } from './agent.js';
9
9
  import { llmTools } from './tools/index.js';
10
- import { setToolsList } from './llm.js';
11
10
  import { localVersion } from './version.js';
12
11
  import { EXIT, FORMATS, isValidFormat, buildResult, exitCodeFor, resultLine, createRunTracker, trackEvent, finalReply } from './ci.js';
13
12
 
@@ -301,13 +300,31 @@ async function runPrompt({ cfg, prompt, format, session, modelArg, timeoutSecond
301
300
  return EXIT.CONFIG;
302
301
  }
303
302
  const messages = [];
304
- if (session && session.messages) for (const m of session.messages.slice(-30)) messages.push(m);
305
303
  // Same layering as the TUI: built-in prompt first, then the user's own
306
304
  // system_prompt override if any, then the /personal notes, then calm mode.
307
305
  let sysText = (cfg.systemPrompt && String(cfg.systemPrompt).trim()) || SYSTEM_PROMPT;
308
306
  const personal = readPersonalPrompt(cfg.workspace);
309
307
  if (personal) sysText += '\n\n' + personal;
310
308
  messages.push({ role: 'system', content: sysText });
309
+ if (session && session.messages) {
310
+ // Resume on a TOOL-EXCHANGE BOUNDARY: a cut that separates an assistant
311
+ // message from its tool results (or leaves orphaned tool results) is a 400
312
+ // from the provider, not just a shorter history. `canSplitAfter` is the same
313
+ // rule the compaction logic uses. System messages are dropped — the fresh
314
+ // one above is authoritative.
315
+ const hist = session.messages.filter((m) => m && m.role !== 'system');
316
+ // A session persisted mid-step can end with an assistant message whose tool
317
+ // results were never appended; sending it before the user message is a 400.
318
+ while (hist.length && hist[hist.length - 1].role === 'assistant'
319
+ && Array.isArray(hist[hist.length - 1].toolCalls) && hist[hist.length - 1].toolCalls.length) hist.pop();
320
+ let start = Math.max(0, hist.length - 30);
321
+ while (start > 0 && !canSplitAfter(hist, start - 1)) start++;
322
+ for (let i = start; i < hist.length; i++) messages.push(hist[i]);
323
+ }
324
+ // THE PROMPT ITSELF. This used to never be pushed: -p sent only the resumed
325
+ // history and the system message, so a fresh session answered nothing the
326
+ // user asked and a resumed one answered nothing new.
327
+ messages.push({ role: 'user', content: prompt });
311
328
  const saved = session || { id: sess.newId(), title: prompt.slice(0, 60), workspace: path.resolve(process.cwd()), model: cfg.model, createdAt: Date.now(), messages: [] };
312
329
  const tracker = createRunTracker();
313
330
  const startedAt = Date.now();
@@ -355,6 +372,13 @@ async function runPrompt({ cfg, prompt, format, session, modelArg, timeoutSecond
355
372
  await agent.run();
356
373
  } finally {
357
374
  if (timer) clearTimeout(timer);
375
+ // MCP stdio children were spawned for this run; most die on stdin EOF at
376
+ // process exit, but one that ignores EOF would linger. Kill them outright —
377
+ // best-effort, the result has already been produced.
378
+ try {
379
+ const mcp = await import('./mcp.js');
380
+ mcp.disconnectAll(mcp.getLiveConnections());
381
+ } catch { /* best-effort */ }
358
382
  }
359
383
  sess.saveSession(saved);
360
384
 
@@ -505,14 +529,14 @@ export async function main(argv) {
505
529
  // starts this in the background and reports the result as a notice.
506
530
  // Failures are reported but never fatal: an unreachable server must not stop
507
531
  // the session from starting.
508
- const connectMcp = async () => {
532
+ const connectMcp = async (mcpOpts = {}) => {
509
533
  if (cfg.mcp === false) return { servers: [], toolCount: 0 };
510
534
  try {
511
535
  const mcp = await import('./mcp.js');
512
536
  const { API } = await import('./plugin.js');
513
537
  const mcpCfg = mcp.loadMcpConfig(cfg.workspace);
514
538
  if (!Object.keys(mcpCfg.servers).length) return { servers: [], toolCount: 0 };
515
- const res = await mcp.connectAll(mcpCfg, (spec) => API.registerTool(spec));
539
+ const res = await mcp.connectAll(mcpCfg, (spec) => API.registerTool(spec), mcpOpts);
516
540
  mcp.setLiveConnections(res.servers);
517
541
  if (process.env.HNCODE_MCP_VERBOSE === '1') {
518
542
  for (const s of res.servers) {
@@ -110,13 +110,35 @@ const isCommand = (s) => typeof s === 'string' && s.trim().startsWith('/');
110
110
  * Read and validate the bindings file.
111
111
 
112
112
  /**
113
- * Read and validate the bindings file.
113
+ * Read and validate the bindings file, cached by mtime+size.
114
114
  *
115
- * @returns {{bindings: Map<string, {key?:string, command?:string}>, errors: string[], file: string}}
116
- * `bindings` maps a NORMALISED source key to exactly one target.
115
+ * The TUI resolves EVERY special keystroke through this, and a readFileSync per key
116
+ * (an ENOENT open is still a syscall; with AV scanning on Windows it is milliseconds)
117
+ * sat directly in the input path. The cache returns the parsed result unchanged while
118
+ * the file's stat is unchanged, so an edit still takes effect immediately — the stat
119
+ * is the only thing a keystroke pays for.
117
120
  */
121
+ let _kbCache = null; // { file, mtimeMs, size, out }
118
122
  export function loadKeybindings(home) {
119
123
  const file = keybindingsFile(home);
124
+ try {
125
+ const st = fs.statSync(file);
126
+ if (_kbCache && _kbCache.file === file && _kbCache.mtimeMs === st.mtimeMs && _kbCache.size === st.size) {
127
+ return _kbCache.out;
128
+ }
129
+ const out = readKeybindingsFile(file);
130
+ _kbCache = { file, mtimeMs: st.mtimeMs, size: st.size, out };
131
+ return out;
132
+ } catch {
133
+ // Absent/unstatable: parse anyway so the error reporting below still runs,
134
+ // and drop the cache — the file may have just been deleted.
135
+ _kbCache = null;
136
+ return readKeybindingsFile(file);
137
+ }
138
+ }
139
+
140
+ /** The uncached parse (the body of the old loadKeybindings). */
141
+ function readKeybindingsFile(file) {
120
142
  const out = { bindings: new Map(), errors: [], file };
121
143
  let raw;
122
144
  try { raw = fs.readFileSync(file, 'utf8'); }
package/src/llm.js CHANGED
@@ -93,7 +93,7 @@ export function toOpenAi(messages) {
93
93
  } else if (m.role === 'assistant') {
94
94
  const hasText = typeof m.content === 'string' && m.content.trim();
95
95
  const hasCalls = Array.isArray(m.toolCalls) && m.toolCalls.length;
96
- if (!hasText && !hasCalls) continue; // 上游不允许空 assistant,直接丢弃
96
+ if (!hasText && !hasCalls) continue; // the provider rejects an empty assistant turn; drop it
97
97
  const msg = { role: 'assistant', content: m.content || null };
98
98
  if (hasCalls) {
99
99
  msg.tool_calls = m.toolCalls.map((tc) => ({
@@ -296,7 +296,9 @@ function buildBody(cfg, messages, streaming = true, opts = {}) {
296
296
  model: cfg.innerModel, messages: toOpenAi(messages),
297
297
  ...common, ...wire,
298
298
  };
299
- if (!opts.noTools) body.tools = defs;
299
+ // Only attach a NON-EMPTY tools array: `tools: []` is rejected by some strict
300
+ // gateways, and an instance that registered no list means "no tools here".
301
+ if (!opts.noTools && defs.length) body.tools = defs;
300
302
  return JSON.stringify(applyPromptCache('openai', body, {
301
303
  enabled: cfg.promptCache !== false,
302
304
  sessionKey: cfg.sessionId,
@@ -313,6 +315,12 @@ export class LLM {
313
315
  constructor(cfg) {
314
316
  this.cfg = cfg;
315
317
  this.controller = null;
318
+ // The tool defs THIS instance sends. Per-instance, NOT module-global: the
319
+ // global (`llmToolsList` below) was overwritten by every `new Agent()` in
320
+ // the process, so a subagent, swarm member or teammate constructed while a
321
+ // parent request was building its body silently swapped the parent's tool
322
+ // list mid-turn. The agent sets this via setTools() right after construction.
323
+ this._tools = null;
316
324
  // Inline-reasoning state carried ACROSS requests. Our system prompt tells the
317
325
  // model to put all its reasoning inside one <think> span, and tool calls happen
318
326
  // inside that span — so the span outlives a single request. Reset when the turn
@@ -321,6 +329,11 @@ export class LLM {
321
329
  this._tagPending = '';
322
330
  }
323
331
 
332
+ // Register the tool list this instance advertises (see `_tools`). Only ever
333
+ // store an array or null: a non-array would make every later request throw
334
+ // when the defs are built.
335
+ setTools(list) { this._tools = Array.isArray(list) ? list : null; }
336
+
324
337
  // Called by the agent at the end of a turn: the next turn starts a fresh
325
338
  // reasoning block, even if the previous one never saw its closing tag.
326
339
  resetThink() {
@@ -341,13 +354,29 @@ export class LLM {
341
354
  // a capped budget plus a reasoning model is what silently produced an empty
342
355
  // title (see buildBody), and an internal request has no tools to call.
343
356
  async requestText(messages, opts = {}) {
357
+ // One silent retry on FAILURE (null). These one-shot requests (title,
358
+ // compaction summary) have no outer retry loop, and a single transient
359
+ // 429/network blip used to come back as null — which the compaction path
360
+ // treated as "no summary" and trimmed the history over. `''` (capped-empty)
361
+ // is a meaningful verdict for the caller and is NOT retried here.
362
+ let out = await this._requestTextOnce(messages, opts);
363
+ if (out === null && !(this.controller && this.controller.signal.aborted)) {
364
+ await new Promise((r) => setTimeout(r, 400));
365
+ out = await this._requestTextOnce(messages, opts);
366
+ }
367
+ return out;
368
+ }
369
+
370
+ async _requestTextOnce(messages, opts = {}) {
344
371
  const cfg = this.cfg;
345
372
  this.controller = new AbortController();
373
+ // This instance's tools, unless the caller opted out (internal one-shots).
374
+ const bodyOpts = { ...opts, ...((this._tools && !opts.noTools) ? { tools: this._tools } : {}) };
346
375
  try {
347
376
  const res = await fetch(cfg.endpoint, {
348
377
  method: 'POST',
349
378
  headers: (() => { const h = authHeaders(cfg); try { const out = runPatchSync('llmHeaders', { headers: h }, (c) => c); if (out && out.headers) return out.headers; } catch (e) {} return h; })(),
350
- body: (() => { let b = buildBody(cfg, messages, false, opts); try { const out = runPatchSync('llmBody', { body: b, cfg }, (c) => c); if (out && out.body !== undefined) b = out.body; } catch (e) {} return b; })(),
379
+ body: (() => { let b = buildBody(cfg, messages, false, bodyOpts); try { const out = runPatchSync('llmBody', { body: b, cfg }, (c) => c); if (out && out.body !== undefined) b = out.body; } catch (e) {} return b; })(),
351
380
  signal: this.controller.signal,
352
381
  });
353
382
  if (!res || !res.ok) {
@@ -397,7 +426,7 @@ export class LLM {
397
426
  }
398
427
  }
399
428
 
400
- async request(messages, onEvent) {
429
+ async request(messages, onEvent, cacheEnd) {
401
430
  // Mixin seam: let plugins mutate the messages sent to the model.
402
431
  try { const out = await runPatch('llmRequest', { messages, cfg: this.cfg }, (c) => c); if (out && out.messages) messages = out.messages; } catch (e) { /* patch failed; proceed with original */ }
403
432
  const cfg = this.cfg;
@@ -418,9 +447,20 @@ export class LLM {
418
447
  // turns it off and the retry goes out without it — degrading to an estimate
419
448
  // rather than failing the request over an accounting nicety.
420
449
  const reqOpts = {};
450
+ // This instance's tools ride along on every attempt (per-instance; see the
451
+ // constructor note on `_tools`). `tools` is only included when set, so
452
+ // buildBody's hasOwnProperty probe falls through to the global for callers
453
+ // that never registered a list.
454
+ const instanceTools = this._tools ? { tools: this._tools } : {};
421
455
 
422
456
  for (let attempt = 1; attempt <= RETRIES; attempt++) {
423
457
  attempts = attempt;
458
+ // Reset per attempt. Left holding the PREVIOUS attempt's response, a network error on
459
+ // this attempt left `res` truthy and `!res.ok` true, so the loop reported the old
460
+ // status and the old body — `LLM request failed (503)` for a connection that was
461
+ // actually refused, with `lastError` (the real cause) never reaching the user.
462
+ res = null;
463
+ lastError = null;
424
464
  // Fresh controller per attempt so an abort aborts this specific request
425
465
  // and the retry loop stops.
426
466
  this.controller = new AbortController();
@@ -429,7 +469,7 @@ export class LLM {
429
469
  res = await fetch(cfg.endpoint, {
430
470
  method: 'POST',
431
471
  headers: (() => { const h = authHeaders(cfg); try { const out = runPatchSync('llmHeaders', { headers: h }, (c) => c); if (out && out.headers) return out.headers; } catch (e) {} return h; })(),
432
- body: (() => { let b = buildBody(cfg, messages, true, reqOpts); try { const out = runPatchSync('llmBody', { body: b, cfg }, (c) => c); if (out && out.body !== undefined) b = out.body; } catch (e) {} return b; })(),
472
+ body: (() => { let b = buildBody(cfg, messages, true, { ...reqOpts, cacheEnd, ...instanceTools }); try { const out = runPatchSync('llmBody', { body: b, cfg }, (c) => c); if (out && out.body !== undefined) b = out.body; } catch (e) {} return b; })(),
433
473
  signal,
434
474
  });
435
475
  } catch (e) {
@@ -446,6 +486,10 @@ export class LLM {
446
486
  if (res.status === 400 && reqOpts.includeUsage !== false) reqOpts.includeUsage = false;
447
487
  }
448
488
  if (attempt < RETRIES) {
489
+ // Release the FAILED attempt's body so its socket returns to the pool.
490
+ // An unread body pins the connection until GC — under a sustained
491
+ // 429/5xx storm the retries accumulated one open connection per attempt.
492
+ if (res && res.body) { try { await res.body.cancel(); } catch { /* already gone */ } }
449
493
  // Small exponential backoff (200ms, 400ms). If the user hit Esc during
450
494
  // the wait, stop retrying.
451
495
  const tryingSignal = this.controller.signal;
@@ -469,7 +513,17 @@ export class LLM {
469
513
  return;
470
514
  }
471
515
  if (cfg.stream === false) {
472
- const json = await res.json();
516
+ // A 200 with a malformed/empty body used to reject here, and the caller
517
+ // (an unawaited `runAgent`) turned that rejection into a process exit via
518
+ // the global unhandledRejection handler. Surface it as an error EVENT —
519
+ // the same channel every other failure on this path uses.
520
+ let json;
521
+ try {
522
+ json = await res.json();
523
+ } catch (e) {
524
+ onEvent({ type: 'error', error: new Error(`LLM returned a malformed non-streaming body: ${e && e.message}`) });
525
+ return;
526
+ }
473
527
  return consumeNonStreaming(cfg.protocol, json, onEvent);
474
528
  }
475
529
  if (!res.body) { onEvent({ type: 'end' }); return; }