@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/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
  }
@@ -126,6 +126,11 @@ export function renderCells(cells) {
126
126
  }
127
127
  out += c.ch;
128
128
  }
129
+ // Close the LAST run too. Without this the final style stayed open past the end of the
130
+ // row, and a caller that concatenates rows — `drawFrameBuffer` does, once per line —
131
+ // carried it into the next one: a card drawn over a dimmed or highlighted row tinted
132
+ // every line below it, and the row's own trailing SGR was simply lost.
133
+ if (cur !== null && cur !== '') out += RESET;
129
134
  return out;
130
135
  }
131
136
 
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. */
@@ -57,16 +59,34 @@ const ONESHOT_MS = 20000;
57
59
 
58
60
  function win() { return process.platform === 'win32'; }
59
61
 
60
- // ---------------------------------------------------------------- copy
62
+ /**
63
+ * Put text on the clipboard.
64
+ *
65
+ * WINDOWS: `Set-Clipboard` through PowerShell, NOT `clip.exe`.
66
+ *
67
+ * `clip.exe` takes UTF-16LE and needed a byte order mark to be read correctly — without it
68
+ * non-ASCII arrived as mojibake (`中文测试` came back as `-N噀Km諎`, measured). But the mark is then
69
+ * part of the clipboard text: every copy began with a zero-width U+FEFF that showed up as a stray
70
+ * invisible character wherever it was pasted. Measured over six samples, `Set-Clipboard` was better
71
+ * on every one:
72
+ *
73
+ * copied clip.exe + BOM Set-Clipboard
74
+ * hello U+FEFF hello hello
75
+ * 中文测试 U+FEFF 中文测试 中文测试
76
+ * line1\nline2 U+FEFF line1 CRLF line2 line1\nline2 (line endings untouched)
77
+ * emoji 🎉 ok U+FEFF emoji 🎉 ok emoji 🎉 ok
78
+ *
79
+ * It also needs no escaping: PowerShell receives a .NET string rather than a byte stream, so there
80
+ * is no console parser to satisfy — which is what the BOM and the quoting were working around.
81
+ *
82
+ * `clip.exe` stays as a fallback for a machine with no PowerShell at all, where a copy carrying a
83
+ * stray BOM beats no copy.
84
+ */
61
85
  export function copyText(text) {
62
86
  const s = String(text == null ? '' : text);
63
87
  if (!s) return false;
64
88
  if (win()) {
65
- // clip.exe takes UTF-16LE. (`cmd /c echo "…" | clip` mangled quotes, '%'
66
- // and newlines; plain UTF-8 input turned non-ASCII into mojibake.) Prefix the
67
- // UTF-16 byte order mark (FF FE) so clip.exe is never left to guess the endian
68
- // or treat input as ANSI — without it, non-ASCII on some Windows versions /
69
- // code pages came out mojibake.
89
+ if (setClipboardText(s)) return true;
70
90
  cp.execSync('clip', { input: Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(s, 'utf16le')]), windowsHide: true, timeout: 5000 });
71
91
  return true;
72
92
  }
@@ -75,6 +95,24 @@ export function copyText(text) {
75
95
  return true;
76
96
  }
77
97
 
98
+ /** `Set-Clipboard` via PowerShell. False when no PowerShell can be run. */
99
+ function setClipboardText(s) {
100
+ // The text travels as base64 so nothing in it can end the script or be re-parsed. A quoted
101
+ // PowerShell literal would need every `'` doubled, and a newline or a backtick in the text would
102
+ // otherwise change what the script means.
103
+ const b64 = Buffer.from(s, 'utf16le').toString('base64');
104
+ const full = 'Add-Type -AssemblyName System.Windows.Forms; '
105
+ + '[System.Windows.Forms.Clipboard]::SetText('
106
+ + `[System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase64String('${b64}')))`;
107
+ for (const exe of [_psExe, 'powershell']) {
108
+ try {
109
+ cp.execFileSync(exe, ['-NoProfile', '-NonInteractive', '-Command', full], { timeout: 10000, windowsHide: true });
110
+ return true;
111
+ } catch { /* try the next exe */ }
112
+ }
113
+ return false;
114
+ }
115
+
78
116
  // Which PowerShell the helper runs. `pwsh` (7) starts far faster than the built-in
79
117
  // `powershell` (5.1) — measured 1.9s vs 11.5s for the same image probe — and the image
80
118
  // path needs the WinForms/Drawing assemblies. This is only the PREFERRED name: it is
@@ -99,9 +137,24 @@ function runPowershell(args, opts) {
99
137
  // ------------------------------------------------------- PowerShell helper
100
138
  export function warmClipboard() {
101
139
  if (!win() || helper) return;
140
+ _shuttingDown = false; // an explicit warm cancels a shutdown in progress
102
141
  startHelper(_psExe);
103
142
  }
104
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
+
105
158
  /** Spawn the long-lived shell. `exe` is tried first; if that exe does not exist,
106
159
  * the spawn reports ENOENT on its `error` event and this retries with 5.1. */
107
160
  function startHelper(exe) {
@@ -153,6 +206,18 @@ function dropHelper() {
153
206
  const waiting = helperQueue;
154
207
  helperQueue = [];
155
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
+ }
156
221
  }
157
222
 
158
223
  /** Resolve once the shell can take commands, or false when it never got there. */
@@ -242,6 +307,24 @@ export async function readText(timeoutMs = HELPER_CMD_MS) {
242
307
  // in use here), so it silently failed everywhere and image paste never worked.
243
308
  // `[Windows.Forms.Clipboard]::GetImage()` exists on Windows PowerShell 5.1 AND
244
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
+
245
328
  const CLIP_PROBE = [
246
329
  "$ErrorActionPreference='SilentlyContinue'",
247
330
  "$f=Get-Clipboard -Format FileDropList",
@@ -315,9 +398,29 @@ async function readViaHelper() {
315
398
  await new Promise((r) => setTimeout(r, 25));
316
399
  }
317
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.
318
404
  helperBusy = true;
319
- const out = await helperRun(CLIP_PROBE, HELPER_CMD_MS);
320
- 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);
321
424
  }
322
425
 
323
426
  export function readClipboardContent() {
@@ -228,10 +228,51 @@ export function formatReadout(used, window, freeCols) {
228
228
  return '';
229
229
  }
230
230
 
231
- /** 1200 -> `1.2k`, 97000 -> `97.0k`, 3_400_000 -> `3.4M`. */
231
+ /**
232
+ * A token count as a compact string: `1200` -> `1.2k`, `97000` -> `97k`, `3400000` -> `3.4M`.
233
+ *
234
+ * Deliberately the SAME shape `fmtTokensShort` produces for the working row, because both appear
235
+ * in one frame — the context bar and the live turn counter would otherwise disagree about the same
236
+ * quantity. Two rules:
237
+ *
238
+ * * one decimal while it carries information, dropped when it is a zero (`97000` is `97k`, not
239
+ * `97.0k` — the trailing `.0` is three characters saying nothing);
240
+ * * K/M/B/T suffixes, with only the kilo lower-case, so `3.4M` here and `3.4M` on the working row
241
+ * read as one vocabulary.
242
+ *
243
+ * The previous version kept `.0` and disagreed with the working row on units entirely: it divided
244
+ * by 1024 where the row divided by 1000, so a 97k context read `94.7k` beside a `97k` turn.
245
+ */
232
246
  export function compactTokens(n) {
233
- const v = Number(n) || 0;
234
- if (v >= 1e6) return `${(v / 1e6).toFixed(1)}M`;
235
- if (v >= 1e3) return `${(v / 1e3).toFixed(1)}k`;
236
- return String(Math.round(v));
247
+ const v = Math.max(0, Math.floor(Number(n) || 0));
248
+ const units = [
249
+ { at: 1e12, suffix: 'T' },
250
+ { at: 1e9, suffix: 'B' },
251
+ { at: 1e6, suffix: 'M' },
252
+ { at: 1e3, suffix: 'k' },
253
+ ];
254
+ for (let i = 0; i < units.length; i++) {
255
+ const u = units[i];
256
+ if (v < u.at) continue;
257
+ // The identical scaling rule the working row and the /ctx readout use: one decimal below 100,
258
+ // whole numbers above. Three formatters render token counts into one frame, so a single rule is
259
+ // the only way `262k` cannot appear beside `262.1k` for the same quantity.
260
+ const shown = scaleUnit(v / u.at);
261
+ // Rounding can reach the next unit (`999999` is `1000k`, which is `1m`), and showing `1000k`
262
+ // makes the reader convert a unit themselves.
263
+ if (Number(shown) >= 1000 && i > 0) return `${scaleUnit(v / units[i - 1].at)}${units[i - 1].suffix}`;
264
+ return `${shown}${u.suffix}`;
265
+ }
266
+ return String(v);
267
+ }
268
+
269
+ /**
270
+ * One unit's worth of a count: `1.2`, `97`, `262`.
271
+ *
272
+ * Duplicated from tui.js rather than imported, because `context-bar.js` must stay a leaf module the
273
+ * TUI can import without a cycle. The two are pinned together by test/context-bar.test.mjs and
274
+ * test/working-tokens.test.mjs, which assert the same table of values through both.
275
+ */
276
+ function scaleUnit(v) {
277
+ return v < 100 ? v.toFixed(1).replace(/\.0$/, '') : String(Math.round(v));
237
278
  }
package/src/doctor.js CHANGED
@@ -306,8 +306,14 @@ function checkGit(workspace, cp) {
306
306
  const branch = run(['rev-parse', '--abbrev-ref', 'HEAD']);
307
307
  out.push(check('git', 'ok', `${branch || '(detached)'} in ${top}`));
308
308
 
309
+ // Because `run` pins cwd to the workspace, `config --get` reports the identity that a
310
+ // commit HERE would actually use — not one inherited from an unrelated directory.
311
+ // A value that IS set is reported too: only a row in the report distinguishes
312
+ // "configured" from "the check never ran", and silence reads as success.
309
313
  for (const key of ['user.name', 'user.email']) {
310
- if (!run(['config', '--get', key])) {
314
+ const value = run(['config', '--get', key]);
315
+ if (value) out.push(check(`git ${key}`, 'ok', value));
316
+ else {
311
317
  out.push(check(`git ${key}`, 'warn', 'not set',
312
318
  `Every commit fails without it. Run: git config --global ${key} "…"`));
313
319
  }
@@ -324,7 +330,6 @@ function checkGit(workspace, cp) {
324
330
  }
325
331
  }
326
332
  return out;
327
- return out;
328
333
  }
329
334
 
330
335
  /**