@hncode/hncode 0.5.2 → 0.5.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.
Files changed (45) hide show
  1. package/package.json +38 -38
  2. package/src/agent.js +172 -19
  3. package/src/cache.js +175 -130
  4. package/src/clipboard.js +69 -2
  5. package/src/config.js +10 -3
  6. package/src/index.js +29 -5
  7. package/src/keybindings.js +25 -3
  8. package/src/llm.js +54 -6
  9. package/src/mcp.js +419 -384
  10. package/src/plugin.js +95 -10
  11. package/src/remote.js +307 -289
  12. package/src/session-hub.js +37 -0
  13. package/src/settings-schema.js +2 -0
  14. package/src/shell-safety.js +610 -562
  15. package/src/swarm-progress.js +1 -1
  16. package/src/team/mailbox.js +216 -139
  17. package/src/team/permission.js +121 -0
  18. package/src/team/protocol.js +167 -0
  19. package/src/team/runner.js +127 -12
  20. package/src/team/spawn.js +63 -5
  21. package/src/team/store.js +31 -4
  22. package/src/team/tasks.js +15 -2
  23. package/src/team/team.js +51 -16
  24. package/src/team-panel.js +418 -20
  25. package/src/term-caps.js +457 -410
  26. package/src/term.js +142 -26
  27. package/src/tool-result.js +50 -2
  28. package/src/tools/agent.js +464 -436
  29. package/src/tools/bash.js +53 -14
  30. package/src/tools/diagnostics.js +13 -0
  31. package/src/tools/edit.js +727 -577
  32. package/src/tools/fetch-url.js +118 -5
  33. package/src/tools/glob.js +5 -3
  34. package/src/tools/grep.js +19 -5
  35. package/src/tools/ignore.js +17 -3
  36. package/src/tools/index.js +17 -3
  37. package/src/tools/read.js +13 -2
  38. package/src/tools/shell.js +145 -134
  39. package/src/tools/tasks.js +214 -141
  40. package/src/tools/team.js +13 -0
  41. package/src/tools/utils.js +12 -3
  42. package/src/tools/web-search.js +85 -34
  43. package/src/tui.js +2089 -714
  44. package/src/update.js +38 -3
  45. package/src/web.js +47 -1
package/src/cache.js CHANGED
@@ -1,131 +1,176 @@
1
- // Prompt caching — explicit cache breakpoints for long, stable prefixes.
2
- //
3
- // Why this is worth doing: an agent resends the whole conversation every turn,
4
- // and the prefix (system prompt + tool definitions + early messages) is
5
- // byte-identical across turns. Providers that support prompt caching bill those
6
- // tokens at a fraction of the input price and skip the prefill compute. Without
7
- // explicit breakpoints only the providers that cache automatically benefit.
8
- //
9
- // Protocol differences:
10
- // * anthropic — explicit: mark up to N breakpoints with
11
- // `cache_control: { type: "ephemeral" }`. Anthropic caches everything UP TO
12
- // and INCLUDING a marked block, so the LAST breakpoint should sit at the end
13
- // of the stable prefix. The minimum cacheable prefix is model-dependent
14
- // (~1024 tokens); marking a shorter prefix is a no-op, not an error.
15
- // * openai — automatic for prompts over ~1024 tokens on supporting models. A
16
- // stable `prompt_cache_key` improves routing; keys must not vary per request.
17
- // * everything else — no-op. Unknown providers are left untouched: sending an
18
- // unsupported `cache_control` field can 400 some gateways.
19
- //
20
- // The cache is only useful when the prefix is STABLE, so the helpers here take
21
- // care to mark the same positions every turn for the same conversation.
22
-
23
- import { estimateTokens } from './term.js';
24
-
25
- // Anthropic allows at most 4 cache breakpoints per request.
26
- export const MAX_BREAKPOINTS = 4;
27
- // Below this, a breakpoint is pointless: most providers will not cache a prefix
28
- // this short, and the marker is wasted budget.
29
- export const MIN_CACHEABLE_TOKENS = 1024;
30
-
31
- export function supportsCaching(protocol) {
32
- return protocol === 'anthropic' || protocol === 'openai' || protocol === 'responses';
33
- }
34
-
35
- // Index (into `messages`) of the last message that belongs to the STABLE prefix.
36
- //
37
- // The prefix is only cacheable while it cannot change between turns. The system
38
- // prompt and tool list are fixed for a session; the conversation then grows by
39
- // APPENDING. So the prefix is "everything except the recent tail", and the tail
40
- // must be excluded because it is what changes next turn.
41
- //
42
- // We keep the last `keepTail` messages out of the cached region: a tool result
43
- // appended this turn is not part of the stable prefix next turn until it is
44
- // itself superseded. Returns -1 when there is nothing worth marking.
45
- export function stablePrefixEnd(messages, keepTail = 2) {
46
- const n = Array.isArray(messages) ? messages.length : 0;
47
- if (n === 0) return -1;
48
- // Never mark the very last message: it is the newest content and is the most
49
- // likely to be replaced/regenerated.
50
- const end = Math.min(n - 1, n - 1 - Math.max(1, keepTail));
51
- return end >= 0 ? end : -1;
52
- }
53
-
54
- // Attach Anthropic `cache_control` breakpoints to a request body IN PLACE-safe
55
- // fashion (returns a new body). Breakpoints are placed:
56
- // 1. on the LAST tool definition (caches the whole tool block),
57
- // 2. on the system prompt (caches tools + system together),
58
- // 3. on the last message of the stable prefix (caches the conversation head).
59
- // Marking the tail first and working backwards keeps the total <= MAX_BREAKPOINTS.
60
- export function withAnthropicCache(body, opts = {}) {
61
- if (!body || typeof body !== 'object') return body;
62
- const minTokens = opts.minTokens || MIN_CACHEABLE_TOKENS;
63
- const out = { ...body };
64
- const marker = { type: 'ephemeral' };
65
-
66
- // Tools: marking the last one caches the entire tools array.
67
- if (Array.isArray(out.tools) && out.tools.length) {
68
- const tools = out.tools.slice();
69
- tools[tools.length - 1] = { ...tools[tools.length - 1], cache_control: marker };
70
- out.tools = tools;
71
- }
72
-
73
- // System: a plain string must become a text block to carry cache_control.
74
- if (typeof out.system === 'string' && out.system.trim()) {
75
- out.system = [{ type: 'text', text: out.system, cache_control: marker }];
76
- } else if (Array.isArray(out.system) && out.system.length) {
77
- const sys = out.system.slice();
78
- const last = sys.length - 1;
79
- sys[last] = { ...(typeof sys[last] === 'string' ? { type: 'text', text: sys[last] } : sys[last]), cache_control: marker };
80
- out.system = sys;
81
- }
82
-
83
- // Conversation head: mark the stable prefix's last message.
84
- if (Array.isArray(out.messages) && out.messages.length) {
85
- const end = stablePrefixEnd(out.messages, opts.keepTail);
86
- if (end >= 0 && end < out.messages.length) {
87
- // Only worth a breakpoint once the prefix is long enough to cache.
88
- const prefixText = out.messages.slice(0, end + 1)
89
- .map((m) => (typeof m.content === 'string' ? m.content
90
- : Array.isArray(m.content) ? m.content.map((b) => b.text || '').join('') : ''))
91
- .join('\n');
92
- if (estimateTokens(prefixText) >= minTokens) {
93
- const msgs = out.messages.slice();
94
- const msg = { ...msgs[end] };
95
- if (typeof msg.content === 'string') {
96
- msg.content = [{ type: 'text', text: msg.content }];
97
- }
98
- if (Array.isArray(msg.content) && msg.content.length) {
99
- const c = msg.content.slice();
100
- const li = c.length - 1;
101
- c[li] = { ...c[li], cache_control: marker };
102
- msg.content = c;
103
- }
104
- msgs[end] = msg;
105
- out.messages = msgs;
106
- }
107
- }
108
- }
109
- return out;
110
- }
111
-
112
- // OpenAI-compatible: most providers cache automatically. A STABLE
113
- // `prompt_cache_key` helps routing on the providers that accept it, and must not
114
- // change between turns of the same conversation or the cache thrashes.
115
- // `seed` should therefore identify the SESSION, not the request.
116
- export function withOpenAiCache(body, opts = {}) {
117
- if (!body || typeof body !== 'object') return body;
118
- const key = opts.sessionKey;
119
- if (!key) return body;
120
- return { ...body, prompt_cache_key: String(key) };
121
- }
122
-
123
- // Apply the right caching strategy for `protocol`. Returns the body unchanged
124
- // for protocols with no explicit support.
125
- export function applyPromptCache(protocol, body, opts = {}) {
126
- if (!opts.enabled) return body;
127
- if (protocol === 'anthropic') return withAnthropicCache(body, opts);
128
- // Responses API accepts the same stable `prompt_cache_key` as chat/completions.
129
- if (protocol === 'openai' || protocol === 'responses') return withOpenAiCache(body, opts);
130
- return body;
1
+ // Prompt caching — explicit cache breakpoints for long, stable prefixes.
2
+ //
3
+ // Why this is worth doing: an agent resends the whole conversation every turn,
4
+ // and the prefix (system prompt + tool definitions + early messages) is
5
+ // byte-identical across turns. Providers that support prompt caching bill those
6
+ // tokens at a fraction of the input price and skip the prefill compute. Without
7
+ // explicit breakpoints only the providers that cache automatically benefit.
8
+ //
9
+ // Protocol differences:
10
+ // * anthropic — explicit: mark up to N breakpoints with
11
+ // `cache_control: { type: "ephemeral" }`. Anthropic caches everything UP TO
12
+ // and INCLUDING a marked block, so the LAST breakpoint should sit at the end
13
+ // of the stable prefix. The minimum cacheable prefix is model-dependent
14
+ // (~1024 tokens); marking a shorter prefix is a no-op, not an error.
15
+ // * openai — automatic for prompts over ~1024 tokens on supporting models. A
16
+ // stable `prompt_cache_key` improves routing; keys must not vary per request.
17
+ // * everything else — no-op. Unknown providers are left untouched: sending an
18
+ // unsupported `cache_control` field can 400 some gateways.
19
+ //
20
+ // The cache is only useful when the prefix is STABLE, so the helpers here take
21
+ // care to mark the same positions every turn for the same conversation.
22
+
23
+ import { estimateTokens } from './term.js';
24
+
25
+ // Anthropic allows at most 4 cache breakpoints per request.
26
+ export const MAX_BREAKPOINTS = 4;
27
+ // Below this, a breakpoint is pointless: most providers will not cache a prefix
28
+ // this short, and the marker is wasted budget.
29
+ export const MIN_CACHEABLE_TOKENS = 1024;
30
+
31
+ export function supportsCaching(protocol) {
32
+ return protocol === 'anthropic' || protocol === 'openai' || protocol === 'responses';
33
+ }
34
+
35
+ // Index (into `messages`) of the last message that belongs to the STABLE prefix.
36
+ // The prefix is only cacheable while it cannot change between turns. The system
37
+ // prompt and tool list are fixed for a session; the conversation then grows by
38
+ // APPENDING. So the prefix is "everything except the recent tail", and the tail
39
+ // must be excluded because it is what changes next turn.
40
+ //
41
+ // We keep the last `keepTail` messages out of the cached region: a tool result
42
+ // appended this turn is not part of the stable prefix next turn until it is
43
+ // itself superseded. Returns -1 when there is nothing worth marking.
44
+ export function stablePrefixEnd(messages, keepTail = 2) {
45
+ const n = Array.isArray(messages) ? messages.length : 0;
46
+ if (n === 0) return -1;
47
+ // Never mark the very last message: it is the newest content and is the most
48
+ // likely to be replaced/regenerated.
49
+ const end = Math.min(n - 1, n - 1 - Math.max(1, keepTail));
50
+ return end >= 0 ? end : -1;
51
+ }
52
+
53
+ // Attach Anthropic `cache_control` breakpoints to a request body IN PLACE-safe
54
+ // Total character length of the prefix's content. `.length` is O(1) per string,
55
+ // so this is O(#messages) — versus the full-string join plus the per-character
56
+ // token walk the decision below used to make on EVERY request (and on every
57
+ // retry attempt): ~1 MB of building and ~1M loop iterations on a 200k-token
58
+ // history.
59
+ function prefixContentLength(messages, end) {
60
+ let n = 0;
61
+ for (let i = 0; i <= end; i++) {
62
+ const c = messages[i] && messages[i].content;
63
+ if (typeof c === 'string') n += c.length;
64
+ else if (Array.isArray(c)) {
65
+ for (const b of c) if (b && typeof b.text === 'string') n += b.text.length;
66
+ }
67
+ }
68
+ return n;
69
+ }
70
+
71
+ // Memo of the last decision, keyed by `end:totalLength`. Tool-result trimming
72
+ // rewrites content in place, which changes the length and so invalidates it.
73
+ let _prefixDecision = { key: '', cacheable: false };
74
+
75
+ // Attach Anthropic `cache_control` breakpoints to a request body IN PLACE-safe
76
+ // fashion (returns a new body). Breakpoints are placed:
77
+ // 1. on the LAST tool definition (caches the whole tool block),
78
+ // 2. on the system prompt (caches tools + system together),
79
+ // 3. on the last message of the stable prefix (caches the conversation head).
80
+ // Marking the tail first and working backwards keeps the total <= MAX_BREAKPOINTS.
81
+ //
82
+ // `cacheEnd` is the index of the last message that belongs to the stable prefix,
83
+ // counted by the CALLER. The agent loop knows it exactly — everything before that
84
+ // index was already sent in an earlier request of this turn — so passing it beats
85
+ // any guess made from the message count here.
86
+ export function withAnthropicCache(body, opts = {}) {
87
+ if (!body || typeof body !== 'object') return body;
88
+ const minTokens = opts.minTokens || MIN_CACHEABLE_TOKENS;
89
+ const out = { ...body };
90
+ const marker = { type: 'ephemeral' };
91
+
92
+ // Tools: marking the last one caches the entire tools array.
93
+ if (Array.isArray(out.tools) && out.tools.length) {
94
+ const tools = out.tools.slice();
95
+ tools[tools.length - 1] = { ...tools[tools.length - 1], cache_control: marker };
96
+ out.tools = tools;
97
+ }
98
+
99
+ // System: a plain string must become a text block to carry cache_control.
100
+ if (typeof out.system === 'string' && out.system.trim()) {
101
+ out.system = [{ type: 'text', text: out.system, cache_control: marker }];
102
+ } else if (Array.isArray(out.system) && out.system.length) {
103
+ const sys = out.system.slice();
104
+ const last = sys.length - 1;
105
+ sys[last] = { ...(typeof sys[last] === 'string' ? { type: 'text', text: sys[last] } : sys[last]), cache_control: marker };
106
+ out.system = sys;
107
+ }
108
+
109
+ // Conversation head: mark the stable prefix's last message.
110
+ if (Array.isArray(out.messages) && out.messages.length) {
111
+ // The caller's boundary when it has one; otherwise fall back to counting off
112
+ // the tail. A caller's value ABOVE the last index means the array grew after it
113
+ // was taken, so it is clamped to the end rather than rejected — the prefix it
114
+ // describes is still the stable one, it just no longer reaches the tail.
115
+ const n = out.messages.length;
116
+ const end = Number.isInteger(opts.cacheEnd)
117
+ ? Math.min(n - 1, opts.cacheEnd)
118
+ : stablePrefixEnd(out.messages, opts.keepTail);
119
+ // An EMPTY prefix is not worth marking. With `cacheEnd === -1` the caller is
120
+ // saying "nothing has been sent yet", so every message in this request is new.
121
+ if (end >= 0 && end < n) {
122
+ // Only worth a breakpoint once the prefix is long enough to cache. Memoized
123
+ // on the length fingerprint; a cold decision uses ~4 chars/token — exact for
124
+ // ASCII code and prose, conservative for CJK (whose real token count is
125
+ // HIGHER than chars/4, so a marked prefix is always truly cacheable). Being
126
+ // wrong on the other side is a harmless no-op: the provider ignores a
127
+ // breakpoint on a sub-minimum prefix.
128
+ const plen = prefixContentLength(out.messages, end);
129
+ const key = `${end}:${plen}`;
130
+ let cacheable;
131
+ if (_prefixDecision.key === key) cacheable = _prefixDecision.cacheable;
132
+ else {
133
+ cacheable = plen / 4 >= minTokens;
134
+ _prefixDecision.key = key;
135
+ _prefixDecision.cacheable = cacheable;
136
+ }
137
+ if (cacheable) {
138
+ const msgs = out.messages.slice();
139
+ const msg = { ...msgs[end] };
140
+ if (typeof msg.content === 'string') {
141
+ msg.content = [{ type: 'text', text: msg.content }];
142
+ }
143
+ if (Array.isArray(msg.content) && msg.content.length) {
144
+ const c = msg.content.slice();
145
+ const li = c.length - 1;
146
+ c[li] = { ...c[li], cache_control: marker };
147
+ msg.content = c;
148
+ }
149
+ msgs[end] = msg;
150
+ out.messages = msgs;
151
+ }
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+
157
+ // OpenAI-compatible: most providers cache automatically. A STABLE
158
+ // `prompt_cache_key` helps routing on the providers that accept it, and must not
159
+ // change between turns of the same conversation or the cache thrashes.
160
+ // `seed` should therefore identify the SESSION, not the request.
161
+ export function withOpenAiCache(body, opts = {}) {
162
+ if (!body || typeof body !== 'object') return body;
163
+ const key = opts.sessionKey;
164
+ if (!key) return body;
165
+ return { ...body, prompt_cache_key: String(key) };
166
+ }
167
+
168
+ // Apply the right caching strategy for `protocol`. Returns the body unchanged
169
+ // for protocols with no explicit support.
170
+ export function applyPromptCache(protocol, body, opts = {}) {
171
+ if (!opts.enabled) return body;
172
+ if (protocol === 'anthropic') return withAnthropicCache(body, opts);
173
+ // Responses API accepts the same stable `prompt_cache_key` as chat/completions.
174
+ if (protocol === 'openai' || protocol === 'responses') return withOpenAiCache(body, opts);
175
+ return body;
131
176
  }
package/src/clipboard.js CHANGED
@@ -40,6 +40,8 @@ let helperBuf = ''; // stdout accumulated since the last sentinel
40
40
  let helperQueue = []; // pending read() resolvers, ONE per sentinel written
41
41
  let helperBusy = false; // one command in flight at a time
42
42
  let helperReady = false; // the warm sequence has answered: the shell can take commands
43
+ let keepAliveTimer = null; // a scheduled respawn, after the helper died
44
+ let _shuttingDown = false; // set by the exit handler so a dead shell is not respawned during cleanup
43
45
 
44
46
  /** How long a read will wait for the helper to finish warming. It is already coming
45
47
  * up, and waiting for a ~10ms answer beats the multi-second cold fallback. */
@@ -135,9 +137,24 @@ function runPowershell(args, opts) {
135
137
  // ------------------------------------------------------- PowerShell helper
136
138
  export function warmClipboard() {
137
139
  if (!win() || helper) return;
140
+ _shuttingDown = false; // an explicit warm cancels a shutdown in progress
138
141
  startHelper(_psExe);
139
142
  }
140
143
 
144
+ /**
145
+ * Stop the keep-alive respawn and let the helper die with the process.
146
+ *
147
+ * Called from the exit handler. Without it a helper that exits during shutdown would be respawned by
148
+ * `dropHelper`, leaving a PowerShell child running after the TUI is gone.
149
+ */
150
+ export function stopClipboardHelper() {
151
+ _shuttingDown = true;
152
+ if (keepAliveTimer) { clearTimeout(keepAliveTimer); keepAliveTimer = null; }
153
+ const h = helper;
154
+ dropHelper();
155
+ if (h) { try { h.stdin.end(); } catch { /* already closed */ } }
156
+ }
157
+
141
158
  /** Spawn the long-lived shell. `exe` is tried first; if that exe does not exist,
142
159
  * the spawn reports ENOENT on its `error` event and this retries with 5.1. */
143
160
  function startHelper(exe) {
@@ -189,6 +206,18 @@ function dropHelper() {
189
206
  const waiting = helperQueue;
190
207
  helperQueue = [];
191
208
  for (const w of waiting) w(null);
209
+ // RESPAWN, unless this is a deliberate shutdown.
210
+ //
211
+ // The helper can die without anyone noticing — a shell that exits on its own, a resource hiccup — and
212
+ // nothing restarted it. The next paste then paid the cold path: a fresh spawn plus the warm-up
213
+ // sentinel, measured at 1.2 seconds, on a keypress that is supposed to be instant.
214
+ //
215
+ // A respawn is scheduled rather than immediate so a crash loop cannot spin: the shell gets a moment to
216
+ // be gone before a new one is asked for. `_shuttingDown` stops it after cleanup.
217
+ if (!_shuttingDown) {
218
+ if (keepAliveTimer) clearTimeout(keepAliveTimer);
219
+ keepAliveTimer = setTimeout(() => { if (!_shuttingDown) warmClipboard(); }, 200);
220
+ }
192
221
  }
193
222
 
194
223
  /** Resolve once the shell can take commands, or false when it never got there. */
@@ -278,6 +307,24 @@ export async function readText(timeoutMs = HELPER_CMD_MS) {
278
307
  // in use here), so it silently failed everywhere and image paste never worked.
279
308
  // `[Windows.Forms.Clipboard]::GetImage()` exists on Windows PowerShell 5.1 AND
280
309
  // pwsh 7 — verified on 5.1 — so it is the portable way to read a copied image.
310
+ /**
311
+ * The TEXT-ONLY probe, run first.
312
+ *
313
+ * The full probe below checks three clipboard formats in order — files, text, image — so a plain text
314
+ * paste pays for two failed checks before the one that answers. Text is overwhelmingly the common case,
315
+ * and the two other checks are the slow ones: `Get-Clipboard -Format FileDropList` round-trips through
316
+ * the shell's COM clipboard, and `Clipboard::GetImage()` decodes an image.
317
+ *
318
+ * So the text check is split out and run alone. Only when it comes back EMPTY — no text on the clipboard,
319
+ * so it must be a file or an image — does the caller run the full probe. A text paste now costs one
320
+ * clipboard call instead of three.
321
+ */
322
+ const CLIP_PROBE_TEXT = [
323
+ "$ErrorActionPreference='SilentlyContinue'",
324
+ "$t=Get-Clipboard -Raw",
325
+ "if($t){'T::'+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($t))}",
326
+ ].join('; ');
327
+
281
328
  const CLIP_PROBE = [
282
329
  "$ErrorActionPreference='SilentlyContinue'",
283
330
  "$f=Get-Clipboard -Format FileDropList",
@@ -351,9 +398,29 @@ async function readViaHelper() {
351
398
  await new Promise((r) => setTimeout(r, 25));
352
399
  }
353
400
  if (helperBusy || !helper) return null;
401
+
402
+ // TEXT FIRST. One clipboard call instead of the full probe's three, so the common paste answers in a
403
+ // third of the round-trips.
354
404
  helperBusy = true;
355
- const out = await helperRun(CLIP_PROBE, HELPER_CMD_MS);
356
- return out == null ? null : parseClipResult(out);
405
+ let out;
406
+ try {
407
+ out = await helperRun(CLIP_PROBE_TEXT, HELPER_CMD_MS);
408
+ } finally {
409
+ helperBusy = false;
410
+ }
411
+ const parsed = out == null ? null : parseClipResult(out);
412
+ // Text found — done. An EMPTY answer means there is no text, so the clipboard holds a file or an
413
+ // image and the full probe is worth its cost.
414
+ if (parsed) return parsed;
415
+
416
+ helperBusy = true;
417
+ let out2;
418
+ try {
419
+ out2 = await helperRun(CLIP_PROBE, HELPER_CMD_MS);
420
+ } finally {
421
+ helperBusy = false;
422
+ }
423
+ return out2 == null ? null : parseClipResult(out2);
357
424
  }
358
425
 
359
426
  export function readClipboardContent() {
package/src/config.js CHANGED
@@ -339,9 +339,16 @@ const b = baseUrl.replace(/\/+$/, '');
339
339
  // An empty base URL produces a relative endpoint like "/chat/completions",
340
340
  // which fetch() rejects with a URL-scheme error the model can't decode. Leave
341
341
  // the endpoint empty instead so the caller can show "not set".
342
- const endpoint = !b ? '' : (protocol === 'anthropic' ? `${b}/messages`
343
- : protocol === 'responses' ? `${b}/responses`
344
- : `${b}/chat/completions`);
342
+ // ANTHROPIC ALWAYS SPEAKS /v1/messages. A base of `https://api.anthropic.com` (which is what the
343
+ // provider table stores, and what people type) produced `…/messages` and 404'd; adding /v1 only when
344
+ // the base does not already carry a version segment makes both spellings work.
345
+ //
346
+ // The trailing slashes are stripped above, which is what kept `https://api.anthropic.com/v1/` from
347
+ // becoming `…/v1//messages`.
348
+ const versioned = protocol === 'anthropic' && !/\/v\d+$/.test(b) ? `${b}/v1` : b;
349
+ const endpoint = !b ? '' : (protocol === 'anthropic' ? `${versioned}/messages`
350
+ : protocol === 'responses' ? `${b}/responses`
351
+ : `${b}/chat/completions`);
345
352
 
346
353
  // Context window: an explicit env/root override wins, then the model's own
347
354
  // `contextLength` (discovered from the provider's /v1/models), then the
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'); }