@hncode/hncode 0.4.2 → 0.5.1

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 (65) hide show
  1. package/README.md +1 -15
  2. package/README_CN.md +1 -15
  3. package/bin/hncode +22 -9
  4. package/package.json +3 -2
  5. package/src/agent.js +149 -35
  6. package/src/bootstrap.js +56 -0
  7. package/src/btw-panel.js +224 -0
  8. package/src/cache.js +3 -2
  9. package/src/cell-buffer.js +173 -0
  10. package/src/ci.js +4 -1
  11. package/src/clipboard.js +202 -43
  12. package/src/colors.js +478 -242
  13. package/src/config.js +409 -40
  14. package/src/context-bar.js +237 -0
  15. package/src/doctor.js +518 -0
  16. package/src/experiments.js +122 -0
  17. package/src/file-history.js +112 -0
  18. package/src/file-tree.js +255 -0
  19. package/src/flush-tick.js +56 -0
  20. package/src/fork.js +44 -0
  21. package/src/fx.js +148 -0
  22. package/src/index.js +123 -11
  23. package/src/keybindings.js +229 -0
  24. package/src/llm.js +355 -34
  25. package/src/lsp.js +401 -0
  26. package/src/memory-profile.js +247 -0
  27. package/src/notebook.js +232 -0
  28. package/src/output-styles.js +183 -0
  29. package/src/patch.js +248 -0
  30. package/src/permissions.js +130 -1
  31. package/src/plan.js +3 -2
  32. package/src/plugin.js +611 -58
  33. package/src/prompt-presets.js +1 -1
  34. package/src/registry-browser.js +31 -13
  35. package/src/remote.js +20 -2
  36. package/src/rollout.js +151 -0
  37. package/src/row-cache.js +148 -0
  38. package/src/schedule.js +294 -0
  39. package/src/session-approvals.js +86 -0
  40. package/src/session-hub.js +1 -0
  41. package/src/session.js +178 -8
  42. package/src/settings-schema.js +301 -0
  43. package/src/shell-safety.js +473 -0
  44. package/src/side-thread.js +240 -0
  45. package/src/skills.js +27 -0
  46. package/src/stream-buffer.js +350 -0
  47. package/src/subagent-control.js +160 -0
  48. package/src/teleport.js +260 -0
  49. package/src/term-caps.js +411 -0
  50. package/src/term.js +283 -8
  51. package/src/tool-result.js +264 -236
  52. package/src/tools/agent-control.js +98 -0
  53. package/src/tools/agent-swarm.js +11 -2
  54. package/src/tools/agent.js +35 -1
  55. package/src/tools/diagnostics.js +175 -0
  56. package/src/tools/edit.js +25 -10
  57. package/src/tools/grep.js +62 -20
  58. package/src/tools/ignore.js +38 -14
  59. package/src/tools/index.js +10 -0
  60. package/src/tools/notebook.js +113 -0
  61. package/src/tools/read-media-file.js +10 -3
  62. package/src/tools/shell.js +34 -6
  63. package/src/tui.js +5637 -746
  64. package/src/web-public/app.js +7 -0
  65. package/src/web.js +3 -1
package/src/clipboard.js CHANGED
@@ -7,6 +7,27 @@
7
7
  // on an already-running shell costs ~100-230ms instead of seconds.
8
8
  // If the helper is unusable, fall back to a one-shot PowerShell.
9
9
  // Non-Windows paths (pbcopy/pbpaste/xclip) are cheap enough to run per call.
10
+ //
11
+ // THREE THINGS MADE THE FIRST PASTE FAIL, all measured on the shipping code:
12
+ // * The helper was "ready" the instant `spawn` returned, but the shell needs
13
+ // ~2-3s before it can answer anything. A read in that window was sent to a
14
+ // shell that had not started reading stdin yet, so it burned `helperRun`'s
15
+ // full 3s deadline and then fell through to the cold one-shot.
16
+ // * Worse, the warm sequence's sentinel was written WITHOUT a queued resolver
17
+ // ("so it drains from the buffer"). When a read pushed its resolver before
18
+ // that sentinel came back, the SENTINEL consumed the read's slot and resolved
19
+ // it with its own empty output — so the paste reported "Clipboard empty or
20
+ // unavailable" while the real answer (with the actual clipboard content) was
21
+ // dropped on the floor. Reproduced deliberately: the read got "", and the
22
+ // payload arrived afterwards with nobody left to receive it.
23
+ // * The fallback ran `powershell` (5.1) with an 8s timeout. Loading
24
+ // WinForms + Drawing there measured 3.8s idle and 11.5s on a busy machine —
25
+ // past the timeout, so `execSync` threw and the paste reported "unavailable"
26
+ // for a clipboard that was fine. `pwsh` does the same work in 1.9s.
27
+ // The warm-up also used to spawn a THROWAWAY shell (`pwsh -Command exit`) through
28
+ // `execSync` to decide which exe to use, blocking the TUI's startup path for ~1.9s
29
+ // before its first paint. The exe is discovered from the spawn's own `error` event
30
+ // now, so nothing blocks.
10
31
 
11
32
  import cp from 'node:child_process';
12
33
  import fs from 'node:fs';
@@ -16,8 +37,23 @@ import path from 'node:path';
16
37
  const SENT = '@@HNCODE_CLIP@@';
17
38
  let helper = null; // long-lived PowerShell child
18
39
  let helperBuf = ''; // stdout accumulated since the last sentinel
19
- let helperQueue = []; // pending read() resolvers
40
+ let helperQueue = []; // pending read() resolvers, ONE per sentinel written
20
41
  let helperBusy = false; // one command in flight at a time
42
+ let helperReady = false; // the warm sequence has answered: the shell can take commands
43
+
44
+ /** How long a read will wait for the helper to finish warming. It is already coming
45
+ * up, and waiting for a ~10ms answer beats the multi-second cold fallback. */
46
+ const HELPER_READY_MS = 6000;
47
+
48
+ /** Deadline for ONE command on a warm shell. Only reached when the shell has already
49
+ * answered, so it is a hang detector, not a warm-up budget. */
50
+ const HELPER_CMD_MS = 3000;
51
+
52
+ /** Deadline for the cold one-shot fallback. Raised from 8s: loading WinForms and
53
+ * Drawing in powershell 5.1 measured 11.5s under load, and the old timeout turned
54
+ * that into a spurious "clipboard unavailable". This is the last resort, so it is
55
+ * allowed to be slow — failing here helps nobody. */
56
+ const ONESHOT_MS = 20000;
21
57
 
22
58
  function win() { return process.platform === 'win32'; }
23
59
 
@@ -39,26 +75,95 @@ export function copyText(text) {
39
75
  return true;
40
76
  }
41
77
 
78
+ // Which PowerShell the helper runs. `pwsh` (7) starts far faster than the built-in
79
+ // `powershell` (5.1) — measured 1.9s vs 11.5s for the same image probe — and the image
80
+ // path needs the WinForms/Drawing assemblies. This is only the PREFERRED name: it is
81
+ // resolved from the spawn's own `error` event (see startHelper), so discovering that a
82
+ // box has no pwsh costs nothing instead of blocking the TUI's startup path with a
83
+ // throwaway shell.
84
+ let _psExe = 'pwsh';
85
+
86
+ /** One-shot PowerShell, preferring the exe the helper resolved. Returns stdout, or
87
+ * null when neither exe could answer. A machine without `pwsh` has to be able to
88
+ * fall back WITHOUT the startup-time probe that used to decide this. */
89
+ function runPowershell(args, opts) {
90
+ const tried = [];
91
+ for (const exe of [_psExe, 'powershell']) {
92
+ if (tried.includes(exe)) continue;
93
+ tried.push(exe);
94
+ try { return cp.execSync(`${exe} ${args}`, opts); } catch { /* try the next exe */ }
95
+ }
96
+ return null;
97
+ }
98
+
42
99
  // ------------------------------------------------------- PowerShell helper
43
100
  export function warmClipboard() {
44
101
  if (!win() || helper) return;
102
+ startHelper(_psExe);
103
+ }
104
+
105
+ /** Spawn the long-lived shell. `exe` is tried first; if that exe does not exist,
106
+ * the spawn reports ENOENT on its `error` event and this retries with 5.1. */
107
+ function startHelper(exe) {
45
108
  try {
46
- helper = cp.spawn('powershell', ['-NoProfile', '-NoLogo', '-Command', '-'], { windowsHide: true, stdio: ['pipe', 'pipe', 'ignore'] });
47
- helper.stdout.on('data', (d) => {
48
- helperBuf += d.toString('binary');
49
- let i;
50
- while ((i = helperBuf.indexOf(SENT)) >= 0) {
51
- const chunk = helperBuf.slice(0, i);
52
- helperBuf = helperBuf.slice(i + SENT.length);
53
- const w = helperQueue.shift();
54
- helperBusy = false;
55
- if (w) w(chunk);
56
- }
57
- });
58
- helper.on('exit', () => { helper = null; helperBusy = false; });
59
- helper.on('error', () => { helper = null; helperBusy = false; });
60
- helper.stdin.write('$ProgressPreference = "SilentlyContinue"\n');
61
- } catch { helper = null; }
109
+ helper = cp.spawn(exe, ['-NoProfile', '-NoLogo', '-Command', '-'], { windowsHide: true, stdio: ['pipe', 'pipe', 'ignore'] });
110
+ } catch { helper = null; return; }
111
+ helperReady = false;
112
+ helper.on('error', () => {
113
+ const missing = helper;
114
+ dropHelper();
115
+ // `pwsh` absent (or unusable): fall back to the built-in shell once.
116
+ if (exe !== 'powershell' && missing) { _psExe = 'powershell'; warmClipboard(); }
117
+ });
118
+ helper.on('exit', () => dropHelper());
119
+ helper.stdout.on('data', (d) => {
120
+ helperBuf += d.toString('binary');
121
+ let i;
122
+ while ((i = helperBuf.indexOf(SENT)) >= 0) {
123
+ const chunk = helperBuf.slice(0, i);
124
+ helperBuf = helperBuf.slice(i + SENT.length);
125
+ const w = helperQueue.shift();
126
+ helperBusy = false;
127
+ if (w) w(chunk);
128
+ }
129
+ });
130
+ helper.stdin.write('$ProgressPreference = "SilentlyContinue"\n');
131
+ // Pre-load the image assemblies ONCE, at warm time. Loading them lazily inside
132
+ // the probe cost ~3.6s on 5.1 — longer than the probe timeout — so the FIRST
133
+ // image paste always failed. Doing it up front moves that cost off the paste
134
+ // path entirely (the shell is warmed at TUI start, not on a keypress).
135
+ //
136
+ // This sentinel is QUEUED like every other command answer, and that is the whole
137
+ // point: written without a resolver it used to be claimed by whatever read pushed
138
+ // its resolver first, which handed that read THIS empty output and dropped the
139
+ // read's own answer — the first paste after startup reported "Clipboard empty or
140
+ // unavailable" for a clipboard that had content. The queued resolver here is what
141
+ // marks the shell as ready, so one sentinel still has exactly one owner.
142
+ helperQueue.push(() => { helperReady = true; });
143
+ helper.stdin.write("try { Add-Type -AssemblyName System.Windows.Forms; Add-Type -AssemblyName System.Drawing } catch {}\n'" + SENT + "'\n");
144
+ }
145
+
146
+ /** Tear down the helper and release every waiter. A read must never be left hanging on
147
+ * a shell that has died — it has a cold fallback to get to. */
148
+ function dropHelper() {
149
+ helper = null;
150
+ helperBusy = false;
151
+ helperReady = false;
152
+ helperBuf = '';
153
+ const waiting = helperQueue;
154
+ helperQueue = [];
155
+ for (const w of waiting) w(null);
156
+ }
157
+
158
+ /** Resolve once the shell can take commands, or false when it never got there. */
159
+ async function waitHelperReady(ms) {
160
+ const deadline = Date.now() + ms;
161
+ while (Date.now() < deadline) {
162
+ if (helperReady) return true;
163
+ if (!helper) return false;
164
+ await new Promise((r) => setTimeout(r, 25));
165
+ }
166
+ return helperReady;
62
167
  }
63
168
 
64
169
  function helperRun(script, timeoutMs) {
@@ -78,10 +183,11 @@ function helperRun(script, timeoutMs) {
78
183
  // text we read base64 ASCII and decode it ourselves.
79
184
  function readOnce() {
80
185
  if (win()) {
81
- const b64 = cp.execSync(
82
- 'powershell -NoProfile -Command "[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw) + \'\'))"',
83
- { encoding: 'ascii', windowsHide: true, timeout: 8000 },
186
+ const b64 = runPowershell(
187
+ '-NoProfile -Command "[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw) + \'\'))"',
188
+ { encoding: 'ascii', windowsHide: true, timeout: ONESHOT_MS },
84
189
  );
190
+ if (b64 == null) throw new Error('no powershell');
85
191
  const s = String(b64).replace(/[^A-Za-z0-9+/=]/g, '');
86
192
  return Buffer.from(s, 'base64').toString('utf8');
87
193
  }
@@ -91,15 +197,20 @@ function readOnce() {
91
197
 
92
198
  // Async read: uses the warm helper when available. Returns { text, via } so the
93
199
  // caller can tell the user which path answered.
94
- export async function readText(timeoutMs = 3000) {
95
- if (win() && helper && !helperBusy) {
96
- helperBusy = true;
97
- const out = await helperRun('[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw) + ""))', timeoutMs);
98
- if (out != null) {
99
- const b64 = out.replace(/\r/g, '').split('\n').filter((l) => l.trim()).pop() || '';
100
- try { return { text: Buffer.from(b64.trim(), 'base64').toString('utf8'), via: 'helper' }; } catch {}
200
+ export async function readText(timeoutMs = HELPER_CMD_MS) {
201
+ if (win()) {
202
+ warmClipboard();
203
+ // Same readiness gate as the probe: a read sent to a shell that has not finished
204
+ // warming is answered by nothing, and the cold path is the expensive one.
205
+ if (helper && await waitHelperReady(HELPER_READY_MS)) {
206
+ helperBusy = true;
207
+ const out = await helperRun('[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes((Get-Clipboard -Raw) + ""))', timeoutMs);
208
+ if (out != null) {
209
+ const b64 = out.replace(/\r/g, '').split('\n').filter((l) => l.trim()).pop() || '';
210
+ try { return { text: Buffer.from(b64.trim(), 'base64').toString('utf8'), via: 'helper' }; } catch {}
211
+ }
212
+ helperBusy = false;
101
213
  }
102
- helperBusy = false;
103
214
  }
104
215
  try { return { text: readOnce(), via: 'oneshot' }; } catch { return { text: '', via: 'none' }; }
105
216
  }
@@ -125,13 +236,21 @@ export async function readText(timeoutMs = 3000) {
125
236
  // PowerShell expression (silently returned nothing), which made FILE pastes fall
126
237
  // back to the slow cold spawn. Emitting one F:: line per file is simpler and
127
238
  // also supports multi-file copies.
239
+ //
240
+ // The image branch uses the .NET WinForms clipboard, NOT `Get-Clipboard -Image`:
241
+ // that parameter only exists on some builds (and is absent even from the pwsh 7
242
+ // in use here), so it silently failed everywhere and image paste never worked.
243
+ // `[Windows.Forms.Clipboard]::GetImage()` exists on Windows PowerShell 5.1 AND
244
+ // pwsh 7 — verified on 5.1 — so it is the portable way to read a copied image.
128
245
  const CLIP_PROBE = [
129
246
  "$ErrorActionPreference='SilentlyContinue'",
130
247
  "$f=Get-Clipboard -Format FileDropList",
131
248
  "if($f -and $f.Count -gt 0){$f|ForEach-Object{'F::'+$_.FullName};return}",
132
249
  "$t=Get-Clipboard -Raw",
133
250
  "if($t){'T::'+[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($t));return}",
134
- "$i=Get-Clipboard -Image",
251
+ // The assemblies are loaded once by warmClipboard(); no Add-Type here, so the
252
+ // probe stays fast even the first time an image is on the clipboard.
253
+ "$i=[System.Windows.Forms.Clipboard]::GetImage()",
135
254
  "if($null -ne $i){$p=Join-Path $env:TEMP ('hncode-paste-'+[guid]::NewGuid().ToString('N')+'.png');$i.Save($p,[System.Drawing.Imaging.ImageFormat]::Png);'I::'+$p}",
136
255
  ].join('; ');
137
256
 
@@ -142,7 +261,7 @@ function parseClipResult(out) {
142
261
  const fileLines = lines.filter((l) => l.startsWith('F::'));
143
262
  if (fileLines.length) return { text: fileLines.map((l) => l.slice(3).trim()).join('\n'), via: 'files' };
144
263
  const imgLine = lines.find((l) => l.startsWith('I::'));
145
- if (imgLine) return { text: imgLine.slice(3).trim(), via: 'image' };
264
+ if (imgLine) { const p = imgLine.slice(3).trim(); trackPastedImage(p); return { text: p, via: 'image' }; }
146
265
  const txtLine = lines.find((l) => l.startsWith('T::'));
147
266
  if (txtLine) {
148
267
  try { return { text: Buffer.from(txtLine.slice(3).trim(), 'base64').toString('utf8'), via: 'text' }; }
@@ -151,18 +270,53 @@ function parseClipResult(out) {
151
270
  return null;
152
271
  }
153
272
 
273
+ // ---- pasted-image lifecycle ------------------------------------------------
274
+ // A pasted image is written to a temp .png (the model needs a PATH to hand to
275
+ // ReadMediaFile), so something has to remove it again or the temp dir fills up
276
+ // one screenshot at a time. Every path the probe reports is remembered here and
277
+ // the TUI deletes the whole set on exit — see cleanupPastedImages().
278
+ const pastedImages = new Set();
279
+
280
+ function trackPastedImage(p) {
281
+ if (p) pastedImages.add(p);
282
+ }
283
+
284
+ /**
285
+ * Delete every image this process wrote to a temp file. Safe to call from a
286
+ * `process.on('exit')` handler: it is fully synchronous and swallows errors (a
287
+ * locked file must not turn a clean exit into a failed one).
288
+ * Returns the number of files removed.
289
+ */
290
+ export function cleanupPastedImages() {
291
+ let removed = 0;
292
+ for (const p of pastedImages) {
293
+ try { fs.rmSync(p, { force: true }); removed++; } catch { /* locked/missing: ignore */ }
294
+ }
295
+ pastedImages.clear();
296
+ return removed;
297
+ }
298
+
299
+ /** How many pasted images are still on disk (diagnostics/tests). */
300
+ export function pastedImageCount() { return pastedImages.size; }
301
+
154
302
  // Run the probe on the warm helper (fast path). Resolves null if it is not ready.
155
303
  async function readViaHelper() {
156
304
  warmClipboard();
157
305
  if (!helper) return null;
158
- // Wait briefly for any in-flight command (e.g. a concurrent readText) to drain,
159
- // so we still hit the warm shell instead of falling back to a cold spawn.
306
+ // Wait for the shell to finish warming FIRST. The warm sequence is already in
307
+ // flight, so this waits for something that is going to happen anyway; sending the
308
+ // probe early meant it was answered by nobody, which cost the full command
309
+ // deadline and then fell through to the cold one-shot — several seconds for a
310
+ // clipboard the helper could have read in ~10ms.
311
+ if (!helperReady && !await waitHelperReady(HELPER_READY_MS)) return null;
312
+ // Then wait briefly for any in-flight command (e.g. a concurrent readText) to
313
+ // drain, so we still hit the warm shell instead of falling back to a cold spawn.
160
314
  for (let i = 0; i < 20 && helperBusy; i++) {
161
315
  await new Promise((r) => setTimeout(r, 25));
162
316
  }
163
- if (helperBusy) return null;
317
+ if (helperBusy || !helper) return null;
164
318
  helperBusy = true;
165
- const out = await helperRun(CLIP_PROBE, 3000);
319
+ const out = await helperRun(CLIP_PROBE, HELPER_CMD_MS);
166
320
  return out == null ? null : parseClipResult(out);
167
321
  }
168
322
 
@@ -171,16 +325,18 @@ export function readClipboardContent() {
171
325
  try { return { text: readOnce(), via: 'oneshot' }; } catch { return { text: '', via: 'none' }; }
172
326
  }
173
327
  // Synchronous API is required by the caller, so use a one-shot PowerShell here
174
- // and let the async path (readClipboardContentAsync) use the warm helper.
328
+ // and let the async path (readClipboardContentAsync) use the warm helper. The exe
329
+ // is the one the helper resolved — `powershell` 5.1 measured 11.5s on the same
330
+ // probe that `pwsh` did in 1.9s, and the old 8s deadline turned that into a
331
+ // spurious "clipboard unavailable".
175
332
  const scriptFile = clipScriptPath();
176
333
  if (!scriptFile) return { text: '', via: 'none' };
177
- try {
178
- const out = cp.execSync(
179
- `powershell -NoProfile -ExecutionPolicy Bypass -File "${scriptFile}"`,
180
- { encoding: 'utf8', windowsHide: true, timeout: 8000 },
181
- );
182
- return parseClipResult(out) || { text: '', via: 'none' };
183
- } catch { return { text: '', via: 'none' }; }
334
+ const out = runPowershell(
335
+ `-NoProfile -ExecutionPolicy Bypass -File "${scriptFile}"`,
336
+ { encoding: 'utf8', windowsHide: true, timeout: ONESHOT_MS },
337
+ );
338
+ if (out == null) return { text: '', via: 'none' };
339
+ return parseClipResult(out) || { text: '', via: 'none' };
184
340
  }
185
341
 
186
342
  // Async version that prefers the warm helper (~10ms) over a cold spawn (~2.9s).
@@ -206,7 +362,10 @@ function clipScriptPath() {
206
362
  'if ($files -and $files.Count -gt 0) { $files | ForEach-Object { "F::" + $_.FullName }; exit }',
207
363
  '$txt = Get-Clipboard -Raw',
208
364
  'if ($txt) { "T::" + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($txt)); exit }',
209
- '$img = Get-Clipboard -Image',
365
+ // .NET clipboard, not `Get-Clipboard -Image` (see the CLIP_PROBE note).
366
+ 'Add-Type -AssemblyName System.Windows.Forms',
367
+ 'Add-Type -AssemblyName System.Drawing',
368
+ '$img = [System.Windows.Forms.Clipboard]::GetImage()',
210
369
  'if ($null -ne $img) {',
211
370
  ' $path = Join-Path $env:TEMP ("hncode-paste-" + [guid]::NewGuid().ToString("N") + ".png")',
212
371
  ' $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png)',