@hncode/hncode 0.4.2 → 0.5.0

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/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 +145 -33
  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 +175 -30
  13. package/src/config.js +376 -34
  14. package/src/context-bar.js +237 -0
  15. package/src/doctor.js +320 -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/notebook.js +232 -0
  27. package/src/output-styles.js +183 -0
  28. package/src/patch.js +248 -0
  29. package/src/permissions.js +130 -1
  30. package/src/plan.js +3 -2
  31. package/src/plugin.js +611 -58
  32. package/src/prompt-presets.js +1 -1
  33. package/src/registry-browser.js +31 -13
  34. package/src/remote.js +20 -2
  35. package/src/rollout.js +151 -0
  36. package/src/row-cache.js +143 -0
  37. package/src/schedule.js +294 -0
  38. package/src/session-approvals.js +86 -0
  39. package/src/session.js +178 -8
  40. package/src/shell-safety.js +473 -0
  41. package/src/side-thread.js +240 -0
  42. package/src/skills.js +27 -0
  43. package/src/stream-buffer.js +350 -0
  44. package/src/subagent-control.js +160 -0
  45. package/src/teleport.js +260 -0
  46. package/src/term-caps.js +411 -0
  47. package/src/term.js +283 -8
  48. package/src/tools/agent-control.js +98 -0
  49. package/src/tools/agent-swarm.js +11 -2
  50. package/src/tools/agent.js +35 -1
  51. package/src/tools/diagnostics.js +175 -0
  52. package/src/tools/edit.js +25 -10
  53. package/src/tools/grep.js +62 -20
  54. package/src/tools/ignore.js +38 -14
  55. package/src/tools/index.js +10 -0
  56. package/src/tools/notebook.js +113 -0
  57. package/src/tools/read-media-file.js +10 -3
  58. package/src/tools/shell.js +34 -6
  59. package/src/tui.js +5219 -792
  60. package/src/web-public/app.js +7 -0
@@ -0,0 +1,175 @@
1
+ // LSP tool — real compiler diagnostics and symbol lookup, from the project's own language
2
+ // server.
3
+ //
4
+ // WHY THIS IS WORTH A TOOL
5
+ // -----------------------
6
+ // Every other check hncode can make about an edit is textual: does the string match, does
7
+ // the file parse as JSON, does grep find the symbol elsewhere. None of them can say "this
8
+ // does not compile", which is the one thing a developer most wants to know after an edit.
9
+ // A language server can, and it is already installed on many machines.
10
+ //
11
+ // DIAGNOSTICS ARE PUSHED, NOT PULLED
12
+ // ----------------------------------
13
+ // LSP has no "give me the problems in this file" request. A server sends
14
+ // `publishDiagnostics` when it has computed them, which for a cold server is after it has
15
+ // indexed the project — seconds, not milliseconds. So `diagnostics` waits for that
16
+ // notification, and reports whether it ARRIVED. An empty list from a settled server means
17
+ // the file is clean; an empty list from a timeout means "not known yet", and the two must
18
+ // not be reported the same way, or the tool becomes a source of false confidence.
19
+ //
20
+ // NO SERVER IS NOT A FAILURE
21
+ // --------------------------
22
+ // Most machines have no server for most languages. The tool says which one it wanted and
23
+ // what to install, and the turn carries on — a missing language server must never fail an
24
+ // edit.
25
+
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import {
29
+ findServer, availableServers, startClient, openDocument, waitForDiagnostics,
30
+ formatDiagnostic, languageFor, fileUri,
31
+ } from '../lsp.js';
32
+ import { resolvePath } from './utils.js';
33
+
34
+ // One client per (server, root), reused across calls in a turn. Starting rust-analyzer is
35
+ // seconds of indexing; doing it per call would make the tool useless.
36
+ const clients = new Map();
37
+ const clientKey = (serverId, root) => `${serverId}\u0000${root}`;
38
+
39
+ async function clientFor(server, root) {
40
+ const key = clientKey(server.id, root);
41
+ const existing = clients.get(key);
42
+ if (existing && !existing.dead()) return existing;
43
+ if (existing) clients.delete(key);
44
+ const started = await startClient(server, root, {});
45
+ if (!started.ok) return { error: started.error };
46
+ clients.set(key, started.client);
47
+ return started.client;
48
+ }
49
+
50
+ /** Stop every running server. Called on exit so no orphan process is left behind. */
51
+ export async function stopLspClients() {
52
+ const all = [...clients.values()];
53
+ clients.clear();
54
+ for (const c of all) { try { await c.stop(); } catch { /* going away anyway */ } }
55
+ }
56
+
57
+ /** For tests and for the TUI: which servers are live right now. */
58
+ export function liveLspClients() {
59
+ return [...clients.entries()].map(([k, c]) => ({ key: k, serverId: c.serverId, root: c.root }));
60
+ }
61
+
62
+ export const spec = {
63
+ name: 'Diagnostics',
64
+ description: `Ask the project's language server for the REAL compiler errors in a file — the answer a build would give, without running one.
65
+
66
+ Use it after a change that could break the build (a rename, a signature change, a new import), instead of guessing from reading the code. The file is opened in the server, so results include errors the server only knows about AFTER indexing, which can take a moment on a cold project.
67
+
68
+ Supported when the matching server is installed: rust-analyzer (.rs), typescript-language-server (.ts/.js), pyright-langserver (.py), gopls (.go), clangd (.c/.cpp). If none is installed the tool says which one it wanted.
69
+
70
+ The result distinguishes "no problems" from "the server did not answer in time" — never treat the second as clean.`,
71
+ parameters: {
72
+ type: 'object',
73
+ properties: {
74
+ path: { type: 'string', description: 'File to check.' },
75
+ wait_seconds: { type: 'integer', minimum: 1, maximum: 120, description: 'How long to wait for the server to report. Default 20. A cold project needs longer.' },
76
+ },
77
+ required: ['path'],
78
+ },
79
+
80
+ async execute(args, ctx) {
81
+ if (typeof args.path !== 'string' || !args.path.trim()) return 'Error: `path` is required.';
82
+ let abs;
83
+ try { abs = resolvePath(args.path, ctx); } catch (e) { return e.message; }
84
+ if (!fs.existsSync(abs)) return `Error: ${args.path} does not exist.`;
85
+
86
+ const server = findServer(abs);
87
+ if (!server) {
88
+ const installed = availableServers();
89
+ const ext = path.extname(abs) || '(no extension)';
90
+ return `No language server for ${ext}. `
91
+ + (installed.length
92
+ ? `Installed here: ${installed.map((s) => `${s.id} (${s.command})`).join(', ')}.`
93
+ : 'None of rust-analyzer, typescript-language-server, pyright-langserver, gopls or clangd is installed.')
94
+ + ' Install one to get diagnostics; nothing else is affected.';
95
+ }
96
+
97
+ const root = ctx.workspace || ctx.cwd || process.cwd();
98
+ const client = await clientFor(server, root);
99
+ if (client.error) return `Error: could not start ${server.command}: ${client.error}`;
100
+
101
+ let text;
102
+ try { text = fs.readFileSync(abs, 'utf8'); }
103
+ catch (e) { return `Error reading ${args.path}: ${e.message}`; }
104
+
105
+ // Clear the previous answer for THIS file first, so a fresh didOpen result cannot be
106
+ // confused with a stale one from the same server. The URI comes from `fileUri`, the
107
+ // same function `openDocument` uses — building it by hand here drifted on Windows,
108
+ // where a backslash path and a `file://` URI are not interchangeable.
109
+ const uri = fileUri(abs);
110
+ for (let i = client.notifications.length - 1; i >= 0; i--) {
111
+ const n = client.notifications[i];
112
+ if (n.method === 'textDocument/publishDiagnostics' && n.params && n.params.uri === uri) {
113
+ client.notifications.splice(i, 1);
114
+ }
115
+ }
116
+ openDocument(client, abs, text, languageFor(abs));
117
+
118
+ const waitMs = Math.min(120, Math.max(1, Number(args.wait_seconds) || 20)) * 1000;
119
+ const { diagnostics, settled } = await waitForDiagnostics(client, abs, waitMs);
120
+
121
+ if (!settled) {
122
+ return `The ${server.id} server did not report on ${args.path} within ${waitMs / 1000}s.`
123
+ + `\nThis is NOT a clean result — it means the answer is unknown. A large project can take`
124
+ + `\nlonger to index the first time; retry with a larger wait_seconds.`
125
+ + (client.stderrTail() ? `\n\nServer output:\n${client.stderrTail().slice(-600)}` : '');
126
+ }
127
+
128
+ if (!diagnostics.length) return `${args.path}: no problems reported by ${server.id}.`;
129
+
130
+ // Worst first: an error is what blocks a build, ahead of a warning or a hint.
131
+ const order = { error: 0, warning: 1, information: 2, hint: 3 };
132
+ const sorted = diagnostics.slice().sort((a, b) => {
133
+ const sa = order[(['', 'error', 'warning', 'information', 'hint'][a.severity || 1])] ?? 9;
134
+ const sb = order[(['', 'error', 'warning', 'information', 'hint'][b.severity || 1])] ?? 9;
135
+ if (sa !== sb) return sa - sb;
136
+ const la = a.range && a.range.start ? a.range.start.line : 0;
137
+ const lb = b.range && b.range.start ? b.range.start.line : 0;
138
+ return la - lb;
139
+ });
140
+ for (const d of sorted) {
141
+ d._file = abs;
142
+ // A diagnostic can point at ANOTHER file; report that file rather than this one, or
143
+ // an error about a type defined elsewhere reads as if it were in the file asked about.
144
+ if (d.relatedInformation) {
145
+ for (const ri of d.relatedInformation) {
146
+ if (ri.location && ri.location.uri) d._file = abs;
147
+ }
148
+ }
149
+ }
150
+
151
+ const counts = { error: 0, warning: 0, other: 0 };
152
+ for (const d of sorted) {
153
+ const name = ['', 'error', 'warning', 'information', 'hint'][d.severity || 1] || 'info';
154
+ if (name === 'error') counts.error += 1;
155
+ else if (name === 'warning') counts.warning += 1;
156
+ else counts.other += 1;
157
+ }
158
+ const summary = [
159
+ counts.error ? `${counts.error} error(s)` : '',
160
+ counts.warning ? `${counts.warning} warning(s)` : '',
161
+ counts.other ? `${counts.other} more` : '',
162
+ ].filter(Boolean).join(', ');
163
+
164
+ const shown = sorted.slice(0, 50);
165
+ const header = `${args.path}: ${summary} — from ${server.id}`;
166
+ const body = shown.map((d) => formatDiagnostic(d, root)).join('\n');
167
+ const more = sorted.length > shown.length
168
+ ? `\n… and ${sorted.length - shown.length} more`
169
+ : '';
170
+ return `${header}\n\n${body}${more}`;
171
+ },
172
+ };
173
+
174
+ /** Exported for tests. */
175
+ export { fileUri };
package/src/tools/edit.js CHANGED
@@ -28,13 +28,14 @@ import { hashRegion as hashLines } from './read.js';
28
28
 
29
29
  export const spec = {
30
30
  name: 'Edit',
31
- description: 'Edit a file. THE TWO MODES ARE MUTUALLY EXCLUSIVE — NEVER combine them:\n - LINE-RANGE mode: path + start_line + end_line + new_string. Replaces lines [start_line, end_line] with new_string. new_string is written VERBATIM: nothing is re-indented or auto-indented, so EVERY line you send — including the FIRST one — must already carry the exact leading spaces/tabs it should have. Copy that whitespace straight from the Read output, after its `N\\t` line-number prefix (Read prints `12\\t return x`, so you send ` return x`). PREFERRED — use it whenever you have Read the file and know the line numbers.\n - EXACT-SUBSTRING mode: path + old_string + new_string. Replaces text exactly matching old_string. Use ONLY when the exact text is known and a line range is not convenient.\nDo NOT pass start_line/end_line together with old_string/new_string in one call, and do NOT pass any of these four keys when you are not using that mode. Omitting new_string is an error, not an empty replacement (in line-range mode pass new_string; in substring mode pass old_string + new_string).',
31
+ description: 'Edit a file. THE TWO MODES ARE MUTUALLY EXCLUSIVE — NEVER combine them:\n- LINE-RANGE mode: path + start_line + end_line + new_string. Replaces lines [start_line, end_line] with new_string.\nPREFERRED — use it whenever you have Read the file and know the line numbers.\n- EXACT-SUBSTRING mode: path + old_string + new_string. Replaces text exactly matching old_string. Use ONLY when the exact text is known and a line range is not convenient.\nLEADING WHITESPACE — SPELL IT OUT ON EVERY LINE, INCLUDING THE FIRST. new_string is written VERBATIM in both modes: nothing is re-indented, auto-indented, or trimmed, and there is no tolerance for a missing first-line indent. Reproduce the indentation the code SHOULD have; it need not match the surrounding block. Copy it from the Read output, after its `N\\t` line-number prefix (Read prints `12\\t return x`, so you send ` return x`).\nWRONG — the first line has no indentation, so it lands at column 0 inside an indented block:\nnew_string: "return total;\\n total += 1;"\nRIGHT — every line carries the indentation it needs:\nnew_string: " return total;\\n total += 1;"\nThose are the same call; only the leading spaces on the FIRST line differ. The same applies to tabs, and to a line whose indentation changes partway through.\nRANGE HYGIENE — WRITE ONLY THE LINES YOU ARE REPLACING. Lines [start_line, end_line] are deleted and new_string is inserted in their place, so a line you leave OUTSIDE that range survives — and if you also restated it inside new_string you get TWO copies. Check what sits on end_line + 1 before deciding where the range ends.\nWRONG — end_line: 3 deletes lines 2-3, but line 4 is repeated because its content was also written into new_string:\nnew_string: "const a = 1;\\nconst b = 20;\\nconst c = 3;"\nRIGHT — the range covers exactly what is being replaced, and nothing beyond it is restated:\nnew_string: "const a = 1;\\nconst b = 20;"\nTo touch line 4 as well, set end_line: 4 and include its new content in new_string.\nIn SUBSTRING mode old_string is matched EXACTLY, including the indentation of its own first line.\nDo NOT pass start_line/end_line together with old_string/new_string in one call, and do NOT pass any of these four keys when you are not using that mode. Omitting new_string is an error, not an empty replacement (in line-range mode pass new_string; in substring mode pass old_string + new_string).\nMis-indented lines often still parse, so a green test run is not proof that an edit landed correctly — re-Read the region before editing it again.\',',
32
32
  parameters: {
33
33
  type: 'object',
34
34
  properties: {
35
35
  path: { type: 'string', description: 'Path to the file to edit.' },
36
36
  old_string: { type: 'string', description: 'Exact text to replace, including whitespace and newlines. Omit when using start_line/end_line.' },
37
- new_string: { type: 'string', description: 'Replacement text, written VERBATIM. In SUBSTRING mode it replaces old_string. In LINE-RANGE mode it replaces lines [start_line, end_line], and its leading whitespace is used exactly as sent — no indentation is added, and the FIRST line needs its own. Copy it from the Read output after the `N\\t` line-number prefix (Read prints `12\\t return x`, so you send ` return x`).' },
37
+ new_string: { type: 'string', description: 'Replacement text, written VERBATIM in BOTH modes — no indentation is added, removed, or re-derived. Write the leading spaces on EVERY line yourself, including the FIRST one, copying them from the Read output after its `N\\t` line-number prefix (Read prints `12\\t return x`, so you send ` return x`). In LINE-RANGE mode it must contain EXACTLY the lines being replaced: a line that sits outside [start_line, end_line] is not deleted, so restating it here leaves a duplicate. In SUBSTRING mode it replaces old_string, which is itself matched exactly including its own indentation.' },
38
+
38
39
  replace_all: { type: 'boolean', default: false, description: 'Replace all occurrences of old_string.' },
39
40
  start_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based first line to replace (requires end_line and new_string). Use this line-range mode first.' },
40
41
  end_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based last line to replace (inclusive; requires start_line).' },
@@ -62,9 +63,9 @@ export const spec = {
62
63
  let p;
63
64
  try { p = resolvePath(args.path, ctx); } catch (e) { return e.message; }
64
65
  // A Jupyter notebook is JSON, not text: a snippet edit corrupts it. Point the
65
- // model at the notebook-aware path instead (mirrors Claude Code errorCode 5).
66
+ // model at the notebook-aware tool instead (mirrors Claude Code errorCode 5).
66
67
  if (String(p).toLowerCase().endsWith('.ipynb')) {
67
- return `Error: ${args.path} is a Jupyter notebook. Its .ipynb is JSON — edit the cells through a notebook-aware tool (or edit the JSON carefully), not a plain text Edit.`;
68
+ return `Error: ${args.path} is a Jupyter notebook, and its .ipynb is JSON — a text replacement would corrupt it. Use NotebookEdit instead: \`{ path: ${JSON.stringify(args.path)}, action: "list" }\` to see the cells, then action "replace" with the cell index.`;
68
69
  }
69
70
  let content;
70
71
  try {
@@ -388,12 +389,26 @@ function checkStaleRange(p, curLines, start, end, ctx) {
388
389
  const normContent = curLines.join('\n');
389
390
  const curFileHash = hashStr(normContent);
390
391
 
391
- // A region covers the range when it fully contains it AND still hashes the
392
- // same as when it was Read.
393
- const covered = entry.regions.some(
394
- (r) => start >= r.start && end <= r.end && hashLines(curLines, r.start, r.end) === r.hash,
395
- );
396
- if (covered) return null;
392
+ // A range is "read" when the regions the model has SEEN cover it — not only when
393
+ // a single region spans it. A file read in two passes (5-10 then 11-16) must
394
+ // allow editing 5-16: the two slices are contiguous, both still intact, so the
395
+ // written range is fully known. Requiring one enclosing region rejected that
396
+ // perfectly safe edit and forced a third Read.
397
+ //
398
+ // Only regions whose hash STILL matches count: a region whose text changed is no
399
+ // longer what the model saw, so it cannot vouch for anything. The cover must also
400
+ // be SEAMLESS — a gap (5-10 and 12-16 with 11 unread) leaves 11 unknown, so it
401
+ // does NOT count, and the edit is correctly refused.
402
+ const intact = entry.regions
403
+ .filter((r) => hashLines(curLines, r.start, r.end) === r.hash)
404
+ .sort((a, b) => a.start - b.start);
405
+ let reach = start - 1; // highest line known to be covered
406
+ for (const r of intact) {
407
+ if (r.end < start) continue; // entirely before the target
408
+ if (r.start > reach + 1) break; // a gap opens before this region: stop
409
+ if (r.end > reach) reach = r.end;
410
+ if (reach >= end) return null; // reached the end: fully covered
411
+ }
397
412
 
398
413
  if (curFileHash === entry.fileHash) {
399
414
  // No "(read so far: …)" listing: the range is what the model must act on, and
package/src/tools/grep.js CHANGED
@@ -36,20 +36,25 @@ export const spec = {
36
36
  const p = normalizeInput(args.path || '.');
37
37
  const base = path.resolve(ctx.cwd || ctx.workspace, p);
38
38
  if (!fs.existsSync(base)) return `Error: path does not exist: ${args.path}`;
39
- if (!args.multiline && hasRg()) return grepRg(args, base, ctx);
39
+ if (!args.multiline && hasRg()) return await grepRg(args, base, ctx);
40
40
  return grepJs(args, base, ctx);
41
41
  },
42
42
  };
43
43
 
44
+ // Is ripgrep on PATH? Probed once and memoised: the probe itself is a spawn, and
45
+ // running it on every Grep call was a pointless synchronous stall.
46
+ let _hasRg = null;
44
47
  function hasRg() {
48
+ if (_hasRg !== null) return _hasRg;
45
49
  try {
46
50
  // spawnSync does NOT throw when the binary is missing — it returns
47
51
  // { error: ENOENT, status: null }. The old try/catch therefore always
48
52
  // reported "rg available", and grepRg() then returned an empty stdout as
49
53
  // "No matches found" instead of falling back to the JS walker.
50
54
  const r = cp.spawnSync('rg', ['--version'], { stdio: 'ignore' });
51
- return !r.error && r.status === 0;
52
- } catch { return false; }
55
+ _hasRg = !r.error && r.status === 0;
56
+ } catch { _hasRg = false; }
57
+ return _hasRg;
53
58
  }
54
59
 
55
60
  function buildRgArgs(args, base) {
@@ -68,23 +73,41 @@ function buildRgArgs(args, base) {
68
73
  return a;
69
74
  }
70
75
 
76
+ // Run ripgrep ASYNCHRONOUSLY. `spawnSync` blocked the event loop for the whole
77
+ // search — on a large repo that froze the TUI (the spinner stopped, input lagged)
78
+ // for as long as rg ran, which is exactly the "working indicator stalls" symptom.
79
+ // Streaming the output with an async spawn keeps the UI responsive.
71
80
  function grepRg(args, base, ctx) {
72
- let out;
73
- try {
74
- const r = cp.spawnSync('rg', buildRgArgs(args, base), {
75
- cwd: ctx.cwd || ctx.workspace,
76
- maxBuffer: 64 * 1024 * 1024,
77
- encoding: 'utf8',
78
- });
79
- // spawn failure: `r.error` set, status null. Must fall back to JS, not
80
- // return an empty result as "No matches found".
81
- if (r.error) return grepJs(args, base, ctx);
82
- out = r.stdout || '';
83
- if (r.status > 1 && r.stderr) return `rg error: ${r.stderr.trim()}`;
84
- } catch (e) {
85
- return grepJs(args, base, ctx);
86
- }
87
- return paginate(out, args);
81
+ return new Promise((resolve) => {
82
+ let child;
83
+ try {
84
+ child = cp.spawn('rg', buildRgArgs(args, base), { cwd: ctx.cwd || ctx.workspace });
85
+ } catch {
86
+ resolve(grepJs(args, base, ctx));
87
+ return;
88
+ }
89
+ let stdout = '';
90
+ let stderr = '';
91
+ let settled = false;
92
+ const done = (fn) => { if (!settled) { settled = true; fn(); } };
93
+ child.stdout.setEncoding('utf8');
94
+ child.stderr.setEncoding('utf8');
95
+ child.stdout.on('data', (d) => { stdout += d; });
96
+ child.stderr.on('data', (d) => { stderr += d; });
97
+ // spawn failure (ENOENT, etc.): fall back to the JS walker rather than
98
+ // reporting an empty result as "No matches found".
99
+ child.on('error', () => done(() => resolve(grepJs(args, base, ctx))));
100
+ child.on('close', (code) => done(() => {
101
+ if (code > 1 && stderr) resolve(`rg error: ${stderr.trim()}`);
102
+ else resolve(paginate(stdout, args));
103
+ }));
104
+ // Honour an aborted turn (Esc): kill rg instead of leaving it running.
105
+ if (ctx && ctx.signal) {
106
+ const onAbort = () => { try { child.kill(); } catch { /* already gone */ } done(() => resolve(paginate(stdout, args))); };
107
+ if (ctx.signal.aborted) onAbort();
108
+ else ctx.signal.addEventListener('abort', onAbort, { once: true });
109
+ }
110
+ });
88
111
  }
89
112
 
90
113
  function grepJs(args, base, ctx) {
@@ -109,14 +132,33 @@ function grepJs(args, base, ctx) {
109
132
  try {
110
133
  const buf = fs.readFileSync(abs);
111
134
  if (!isProbablyTextFile(buf)) continue;
112
- txt = truncateBuf(buf);
135
+ // The WHOLE file, not `truncateBuf(buf)`. That helper caps a tool RESULT at
136
+ // 128 KB, which is right for what is handed back to the model and wrong as the
137
+ // haystack: on this repo it cut src/tui.js (878 KB) to its first 15%, so any
138
+ // pattern appearing later in the file reported "No matches found" — a silent
139
+ // false negative on the biggest, most-edited file there is. Real ripgrep
140
+ // searches everything and caps only its OUTPUT, which is what happens here now:
141
+ // the rows are collected in full and `paginate` + `head_limit` bound the result.
142
+ txt = buf.toString('utf8');
113
143
  } catch { continue; }
114
144
  const mode = args.output_mode;
115
145
 
146
+ // Normalise CRLF -> LF BEFORE anything else. Splitting on '\n' alone left a
147
+ // '\r' at the end of every line in a CRLF file (which every file in this repo
148
+ // is), and a '\r' is a real character to a regex:
149
+ // * `foo$` and `^foo$` matched NOTHING, because the line really ends "\r";
150
+ // * every returned match line carried a stray '\r', cleaned up by paginate()
151
+ // only for the final output — after the matching had already gone wrong.
152
+ // One normalisation here is what `rg` does internally, and it makes the JS
153
+ // path agree with it.
154
+ const normalized = txt.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
155
+ txt = normalized;
156
+
116
157
  if (args.multiline) {
117
158
  const ms = Array.from(txt.matchAll(re));
118
159
  if (ms.length === 0) continue;
119
160
  if (mode === 'files_with_matches') { out.push(abs); continue; }
161
+ if (mode === 'files_with_matches') { out.push(abs); continue; }
120
162
  if (mode === 'count_matches') { out.push(`${abs}:${ms.length}`); continue; }
121
163
  const lines = txt.split('\n');
122
164
  for (const mm of ms) {
@@ -38,8 +38,20 @@ export function ignorePatternsFor(dir) {
38
38
  return arr;
39
39
  }
40
40
 
41
+ // Compiled glob → RegExp, memoised. `matchPattern` runs for EVERY entry against
42
+ // EVERY accumulated ignore pattern, so building a fresh RegExp each time was the
43
+ // single largest cost of a directory walk (measured: 73ms of a 228ms walk on a
44
+ // 354-file repo, purely in `new RegExp`). Patterns are immutable strings, so a
45
+ // process-lifetime cache is safe.
46
+ const reCache = new Map();
41
47
  function globToRegex(pattern) {
42
- return new RegExp(globToRegexSource(pattern));
48
+ let re = reCache.get(pattern);
49
+ if (re === undefined) {
50
+ re = new RegExp(globToRegexSource(pattern));
51
+ if (reCache.size >= 4096) reCache.clear(); // bounded; patterns are few in practice
52
+ reCache.set(pattern, re);
53
+ }
54
+ return re;
43
55
  }
44
56
 
45
57
  export function matchPattern(pat, relPath, isDir) {
@@ -56,21 +68,27 @@ export function matchPattern(pat, relPath, isDir) {
56
68
 
57
69
  // Walk dir applying ignore patterns from root..leaf. Returns list of absolute files
58
70
  // (and dirs if includeDirs), honoring ignore unless includeIgnored.
71
+ // Walk dir applying ignore patterns from root..leaf. Returns list of absolute files
72
+ // (and dirs if includeDirs), honoring ignore unless includeIgnored.
73
+ //
74
+ // mtime and "is a file" are captured DURING the walk. The previous version stat'ed
75
+ // every result TWICE more afterwards (once per comparison in the sort, once in the
76
+ // file filter), which on a 354-file repo cost ~24ms of a 228ms walk — pure
77
+ // duplication, since the information was already available as each entry was
78
+ // visited. One stat per file now, reused for both.
59
79
  export function walkFiles(base, opts = {}) {
60
- const results = [];
61
80
  const { includeIgnored = false, includeDirs = false } = opts;
62
81
  const baseAbs = path.resolve(base);
63
- _walk(baseAbs, '', [], results, { includeIgnored, includeDirs });
64
- // sort by mtime desc for a "most recent first" feel
65
- results.sort((a, b) => {
66
- try { return fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs; } catch { return b < a ? 1 : -1; }
67
- });
68
- if (results.length > 1000) results.length = 1000;
69
- try { if (!includeDirs) return results.filter(p => { try { return fs.statSync(p).isFile(); } catch { return false; } }); } catch { return results; }
70
- return results;
82
+ /** @type {{path:string, mtime:number, isFile:boolean}[]} */
83
+ const found = [];
84
+ _walk(baseAbs, '', [], found, { includeIgnored, includeDirs });
85
+ // Newest first. Sort before truncating so the newest 1000 survive the cap.
86
+ found.sort((a, b) => b.mtime - a.mtime);
87
+ if (found.length > 1000) found.length = 1000;
88
+ return found.filter((e) => includeDirs || e.isFile).map((e) => e.path);
71
89
  }
72
90
 
73
- function _walk(dir, relPrefix, parentPatterns, results, opts) {
91
+ function _walk(dir, relPrefix, parentPatterns, found, opts) {
74
92
  const { includeIgnored, includeDirs } = opts;
75
93
  let entries;
76
94
  try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
@@ -90,10 +108,16 @@ function _walk(dir, relPrefix, parentPatterns, results, opts) {
90
108
  continue;
91
109
  }
92
110
  if (isDir) {
93
- if (includeDirs) results.push(abs);
94
- _walk(abs, rel, acc, results, opts);
111
+ if (includeDirs) {
112
+ let mtime = 0;
113
+ try { mtime = fs.statSync(abs).mtimeMs; } catch { /* keep 0 */ }
114
+ found.push({ path: abs, mtime, isFile: false });
115
+ }
116
+ _walk(abs, rel, acc, found, opts);
95
117
  } else {
96
- results.push(abs);
118
+ let mtime = 0;
119
+ try { mtime = fs.statSync(abs).mtimeMs; } catch { /* keep 0 */ }
120
+ found.push({ path: abs, mtime, isFile: ent.isFile() });
97
121
  }
98
122
  }
99
123
  }
@@ -17,10 +17,14 @@ import * as askUserQuestionMod from './ask-user-question.js';
17
17
  import { spec as agentSpec } from './agent.js';
18
18
  import { spec as agentSwarmSpec } from './agent-swarm.js';
19
19
  import { TaskListSpec, TaskOutputSpec, TaskStopSpec, TaskWaitSpec } from './tasks.js';
20
+ import { AgentListSpec, AgentMessageSpec, AgentInterruptSpec, AgentCloseSpec } from './agent-control.js';
21
+ import { spec as notebookSpec } from './notebook.js';
22
+ import { spec as diagnosticsSpec } from './diagnostics.js';
20
23
  import * as memoryMod from './memory.js';
21
24
  import { pluginTools } from '../plugin.js';
22
25
 
23
26
 
27
+
24
28
  // Built-in tools.
25
29
  const builtinTools = [
26
30
  readMod.spec,
@@ -42,6 +46,12 @@ const builtinTools = [
42
46
  TaskOutputSpec,
43
47
  TaskStopSpec,
44
48
  TaskWaitSpec,
49
+ AgentListSpec,
50
+ AgentMessageSpec,
51
+ AgentInterruptSpec,
52
+ AgentCloseSpec,
53
+ notebookSpec,
54
+ diagnosticsSpec,
45
55
  memoryMod.spec,
46
56
  ];
47
57
 
@@ -0,0 +1,113 @@
1
+ // NotebookEdit — read and edit a Jupyter notebook by CELL.
2
+ //
3
+ // WHY A TOOL AND NOT A BRANCH IN `Edit`
4
+ // ------------------------------------
5
+ // `Edit`'s contract is "replace this text with that text", and a notebook has no text to
6
+ // match: the source lives inside a JSON array, so a `old_string` would match in one place in
7
+ // the .ipynb and mean something different in the cell. Expressing the operation as CELL
8
+ // indices removes the ambiguity entirely — there is nothing to match, so nothing to match
9
+ // wrongly.
10
+ //
11
+ // `tools/edit.js` still refuses a .ipynb, and now says to use THIS instead. That refusal is
12
+ // the safety property: a notebook can only be changed through an operation that knows its
13
+ // structure, so the corruption the refusal prevented remains impossible.
14
+ //
15
+ // Everything outside the edited `source` is preserved: outputs, metadata, cell ids, the
16
+ // top-level metadata. See src/notebook.js for why that is an invariant rather than a nicety.
17
+
18
+ import {
19
+ loadNotebook, listCells, describeCells, replaceSource, insertCell,
20
+ deleteCell, checkIndex, cellSource, saveNotebook,
21
+ } from '../notebook.js';
22
+ import { resolvePath } from './utils.js';
23
+ import { checkpointBeforeWrite } from './utils.js';
24
+
25
+ export const spec = {
26
+ name: 'NotebookEdit',
27
+ description: `Read and change a Jupyter notebook (.ipynb) by CELL. Plain Edit refuses .ipynb because a notebook is JSON — use this instead.
28
+
29
+ Actions:
30
+ - \`list\`: every cell with its index, type, line count and a one-line preview. Do this FIRST unless you already know the indices.
31
+ - \`read\`: one cell's source in full.
32
+ - \`replace\`: replace one cell's source. Everything else about the cell (its outputs, metadata, id) is preserved.
33
+ - \`insert\`: add a cell BEFORE an index; use index = cell count to append.
34
+ - \`delete\`: remove one cell.
35
+
36
+ Indices are 0-based and refer to the list \`list\` prints. Recent output is kept when a cell's source is replaced, exactly as Jupyter does when you edit without re-running.`,
37
+ parameters: {
38
+ type: 'object',
39
+ properties: {
40
+ path: { type: 'string', description: 'Path to the .ipynb file.' },
41
+ action: { type: 'string', enum: ['list', 'read', 'replace', 'insert', 'delete'], description: 'What to do.' },
42
+ index: { type: 'integer', description: '0-based cell index. Required for read/replace/insert/delete. For insert, the new cell goes BEFORE this index (cell count to append).' },
43
+ source: { type: 'string', description: 'The cell source. Required for replace and insert.' },
44
+ cell_type: { type: 'string', enum: ['code', 'markdown', 'raw'], description: 'Type for an inserted cell; defaults to code.' },
45
+ },
46
+ required: ['path', 'action'],
47
+ },
48
+
49
+ async execute(args, ctx) {
50
+ if (typeof args.path !== 'string' || !args.path.trim()) return 'Error: `path` is required.';
51
+ const action = String(args.action || '').toLowerCase();
52
+ if (!['list', 'read', 'replace', 'insert', 'delete'].includes(action)) {
53
+ return `Error: \`action\` must be one of list, read, replace, insert, delete (got ${JSON.stringify(args.action)}).`;
54
+ }
55
+ let abs;
56
+ try { abs = resolvePath(args.path, ctx); } catch (e) { return e.message; }
57
+ if (!String(abs).toLowerCase().endsWith('.ipynb')) {
58
+ return `Error: ${args.path} is not a .ipynb file. Use Edit for ordinary text files.`;
59
+ }
60
+ const loaded = loadNotebook(abs);
61
+ if (!loaded.ok) return `Error: ${loaded.error}`;
62
+
63
+ if (action === 'list') {
64
+ return describeCells(loaded.doc, args.path).join('\n');
65
+ }
66
+
67
+ const idx = checkIndex(loaded.doc, args.index);
68
+ if (!idx.ok) return `Error: ${idx.error}`;
69
+
70
+ if (action === 'read') {
71
+ const cell = loaded.doc.cells[args.index];
72
+ const type = cell.cell_type || 'unknown';
73
+ const src = cellSource(cell);
74
+ return `cell ${args.index} (${type}, ${src ? src.split('\n').length : 0} line(s)):\n\n${src}`;
75
+ }
76
+
77
+ if (action === 'replace') {
78
+ if (typeof args.source !== 'string') return 'Error: `source` is required for replace.';
79
+ const before = cellSource(loaded.doc.cells[args.index]);
80
+ const r = replaceSource(loaded.doc, args.index, args.source);
81
+ if (!r.ok) return `Error: ${r.error}`;
82
+ // A checkpoint BEFORE the write, like Edit does, so /checkpoints and /undo can put
83
+ // the previous cell content back.
84
+ try { checkpointBeforeWrite(abs, ctx); } catch { /* best effort */ }
85
+ const saved = saveNotebook(abs, loaded.doc, loaded.indent);
86
+ if (!saved.ok) return `Error: saving ${args.path}: ${saved.error}`;
87
+ const beforeLines = before ? before.split('\n').length : 0;
88
+ const afterLines = args.source ? args.source.split('\n').length : 0;
89
+ return `Replaced the source of ${args.path} cell ${args.index} (${beforeLines} -> ${afterLines} line(s)). The cell's outputs and metadata are unchanged.`;
90
+ }
91
+
92
+ if (action === 'insert') {
93
+ if (typeof args.source !== 'string') return 'Error: `source` is required for insert.';
94
+ const r = insertCell(loaded.doc, args.index, args.source, args.cell_type);
95
+ if (!r.ok) return `Error: ${r.error}`;
96
+ try { checkpointBeforeWrite(abs, ctx); } catch { /* best effort */ }
97
+ const saved = saveNotebook(abs, loaded.doc, loaded.indent);
98
+ if (!saved.ok) return `Error: saving ${args.path}: ${saved.error}`;
99
+ return `Inserted a ${r.cell.cell_type} cell at index ${args.index} in ${args.path}. The notebook now has ${loaded.doc.cells.length} cell(s).`;
100
+ }
101
+
102
+ // delete
103
+ const r = deleteCell(loaded.doc, args.index);
104
+ if (!r.ok) return `Error: ${r.error}`;
105
+ try { checkpointBeforeWrite(abs, ctx); } catch { /* best effort */ }
106
+ const saved = saveNotebook(abs, loaded.doc, loaded.indent);
107
+ if (!saved.ok) return `Error: saving ${args.path}: ${saved.error}`;
108
+ return `Deleted cell ${args.index} from ${args.path}. The notebook now has ${loaded.doc.cells.length} cell(s).`;
109
+ },
110
+ };
111
+
112
+ /** Exported for tests: the cell list without touching the filesystem. */
113
+ export { listCells };
@@ -52,8 +52,12 @@ export const spec = {
52
52
  let p;
53
53
  try { p = resolvePath(args.path, ctx); } catch (e) { return e.message; }
54
54
 
55
- if (!fs.existsSync(p)) return `Error: file not found: ${args.path}`;
56
- const stat = fs.statSync(p);
55
+ // Async fs throughout. These calls used to be sync, and an image may be tens
56
+ // of MB — reading it (plus the base64 encode) blocked the event loop long
57
+ // enough to freeze the TUI while the Working spinner was animating.
58
+ let stat;
59
+ try { stat = await fs.promises.stat(p); }
60
+ catch { return `Error: file not found: ${args.path}`; }
57
61
  if (!stat.isFile()) return `Error: not a file: ${args.path}`;
58
62
 
59
63
  const ext = path.extname(p).toLowerCase();
@@ -67,7 +71,10 @@ export const spec = {
67
71
  + 'Resize it, or raise the limit with HNCODE_MEDIA_MAX_BYTES.';
68
72
  }
69
73
  const mimeType = IMAGE_MIME[ext];
70
- const data = fs.readFileSync(p).toString('base64');
74
+ let buf;
75
+ try { buf = await fs.promises.readFile(p); }
76
+ catch (e) { return `Error: could not read ${args.path}: ${e.message}`; }
77
+ const data = buf.toString('base64');
71
78
  const approxKB = Math.round(data.length / 1024);
72
79
  return {
73
80
  text: `[image] ${args.path} (${sizeKB}KB, ${mimeType})`,