@hncode/hncode 0.5.2 → 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.
- package/package.json +2 -2
- package/src/agent.js +172 -19
- package/src/cache.js +175 -130
- package/src/clipboard.js +69 -2
- package/src/index.js +29 -5
- package/src/keybindings.js +25 -3
- package/src/llm.js +54 -6
- package/src/mcp.js +419 -384
- package/src/plugin.js +95 -10
- package/src/remote.js +307 -289
- package/src/shell-safety.js +610 -562
- package/src/swarm-progress.js +1 -1
- package/src/team/mailbox.js +216 -139
- package/src/team/permission.js +121 -0
- package/src/team/protocol.js +167 -0
- package/src/team/runner.js +127 -12
- package/src/team/spawn.js +63 -5
- package/src/team/store.js +31 -4
- package/src/team/tasks.js +15 -2
- package/src/team/team.js +51 -16
- package/src/team-panel.js +418 -20
- package/src/term-caps.js +457 -410
- package/src/term.js +142 -26
- package/src/tool-result.js +50 -2
- package/src/tools/agent.js +464 -436
- package/src/tools/bash.js +53 -14
- package/src/tools/diagnostics.js +13 -0
- package/src/tools/edit.js +813 -590
- package/src/tools/glob.js +5 -3
- package/src/tools/grep.js +19 -5
- package/src/tools/ignore.js +17 -3
- package/src/tools/index.js +17 -3
- package/src/tools/read.js +2 -1
- package/src/tools/shell.js +145 -134
- package/src/tools/tasks.js +214 -141
- package/src/tools/team.js +13 -0
- package/src/tools/utils.js +12 -3
- package/src/tools/web-search.js +88 -88
- package/src/tui.js +1815 -628
- package/src/update.js +38 -3
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
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
//
|
|
124
|
-
// for
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
356
|
-
|
|
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/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) {
|
package/src/keybindings.js
CHANGED
|
@@ -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
|
-
*
|
|
116
|
-
*
|
|
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'); }
|