@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
@@ -71,6 +71,7 @@ Each of these is enforced — a violation is rejected before any subagent starts
71
71
  Use enough subagents to keep the work focused and parallel. AgentSwarm supports up to ${MAX_AGENT_SWARM_SUBAGENTS} subagents, and launches are queued automatically, so it is safe to split large tasks into many clear, independent items.
72
72
 
73
73
  When NOT to use AgentSwarm:
74
+ - It only exists while swarm mode is ON (\`/swarm\`). With the mode off the tool is not offered at all and calling it is refused. If a fan-out would genuinely help, say so and ask the user to turn the mode on.
74
75
  - Never call this on your own initiative. It requires the same explicit permission as \`Agent\`: the user, or the applicable AGENTS.md / project instructions, must have asked for sub-agents, delegation, or parallel agent work. Requests for depth, thoroughness, research, investigation, or detailed codebase analysis do NOT count as permission.
75
76
  - Do not use it for two or three differently-shaped tasks: use separate \`Agent\` calls instead, which can run in parallel in one message.
76
77
  - Do not use it to fan out work you could finish yourself in a few steps.
@@ -151,9 +152,17 @@ If \`AgentSwarm\` is called, that call must be the only tool call in the respons
151
152
  } catch (e) {
152
153
  results[i] = `--- #${i + 1} ---\n[error: ${e.message}]`;
153
154
  }
154
- // Stream progress into the task output so a long swarm is inspectable.
155
+ // Report progress down BOTH channels, and they are not interchangeable:
156
+ // * the task buffer feeds TaskOutput and survives a detach;
157
+ // * `ctx.onOutput` is the LIVE sink. The progress block in the transcript
158
+ // reads the cells off it (see the tool_output handler's `[n/m] finished`
159
+ // match), so writing only to the task buffer left every cell but the first
160
+ // stuck on "Queued…" for the whole run — the TUI was told to expect these
161
+ // lines on this channel and never received one.
155
162
  const done = results.filter((r) => r !== null).length;
156
- appendTaskOutput(task, `[${done}/${jobs.length}] finished\n`);
163
+ const line = `[${done}/${jobs.length}] finished\n`;
164
+ appendTaskOutput(task, line);
165
+ if (typeof ctx.onOutput === 'function') ctx.onOutput(line);
157
166
  }
158
167
  };
159
168
  const runAll = Promise.all(Array.from({ length: Math.min(SWARM_CONCURRENCY, jobs.length) }, worker));
@@ -17,6 +17,7 @@
17
17
  // summarise it.
18
18
 
19
19
  import { createTask, appendTaskOutput, settleTask } from '../agent-task.js';
20
+ import { registerRun, endRun } from '../subagent-control.js';
20
21
  import { SUBAGENT_TYPES, DEFAULT_SUBAGENT_TYPE, availableTypes, toolsForSubagent } from '../subagent-types.js';
21
22
  import { runShellHooks } from '../hooks.js';
22
23
 
@@ -53,7 +54,7 @@ function systemPromptFor(type, cfg, systemPrompt) {
53
54
  // stream, and each tool it runs) so the TUI can show it under the `Using Agent`
54
55
  // row the way Bash's output is shown. `agentId` labels the lines when several
55
56
  // subagents run at once (AgentSwarm), so the reader can tell them apart.
56
- async function runSubagent({ cfg, type, prompt, history, signal, onEvent, onProgress, agentId }) {
57
+ async function runSubagent({ cfg, type, prompt, history, signal, onEvent, onProgress, agentId, onAgent }) {
57
58
  const { Agent, SYSTEM_PROMPT } = await import('../agent.js');
58
59
  const subCfg = {
59
60
  ...cfg,
@@ -105,6 +106,10 @@ async function runSubagent({ cfg, type, prompt, history, signal, onEvent, onProg
105
106
  if (signal.aborted) throw new Error('Interrupted by user');
106
107
  signal.addEventListener('abort', () => agent.interrupt(), { once: true });
107
108
  }
109
+ // Hand the LIVE agent back to the caller so a control tool can steer or interrupt it
110
+ // while it runs. `subagents` only learns about this run when it finishes, so without
111
+ // this the only way to reach a running subagent would be to interrupt the whole turn.
112
+ if (typeof onAgent === 'function') onAgent(agent);
108
113
  await agent.run();
109
114
  // SHELL HOOK: SubagentStop. The subagent finished (or was interrupted); the
110
115
  // parent-side hook can log/archive it. Never blocks.
@@ -237,12 +242,20 @@ ${availableTypes()}`,
237
242
  const agentId = existing ? existing.id : newAgentId();
238
243
  task.agentId = agentId;
239
244
 
245
+ // Register the run so a control tool can reach it while it is in flight. The handle
246
+ // is created BEFORE the run so both `run_in_background` and the detached-foreground
247
+ // path behave the same, and cleared in `finally` so a settled run can never be
248
+ // listed as controllable.
249
+ let runId = '';
250
+ const early = { agent: null };
251
+
240
252
  // Fire and forget: the task record carries the result when it lands.
241
253
  void (async () => {
242
254
  try {
243
255
  const run = await runSubagent({
244
256
  cfg: subCfg, type, prompt, history,
245
257
  signal: controller.signal,
258
+ onAgent: (agent) => { early.agent = agent; },
246
259
  // No live streaming into the task output: that should read as the
247
260
  // subagent's CONCLUSION. `run.text` below is the cleaned final message.
248
261
  });
@@ -258,8 +271,15 @@ ${availableTypes()}`,
258
271
  settleTask(task, stopped ? 'killed' : 'failed', { stopReason: stopped ? 'Stopped by user' : e.message });
259
272
  appendTaskOutput(task, `\n[error: ${e.message}]`);
260
273
  if (typeof ctx._onBackgroundTaskDone === 'function') ctx._onBackgroundTaskDone(task);
274
+ } finally {
275
+ if (runId) endRun(runId);
261
276
  }
262
277
  })();
278
+ runId = registerRun({
279
+ agentId, taskId: task.taskId, type, description,
280
+ steer: (text) => { if (early.agent) early.agent.steer(text); },
281
+ interrupt: () => controller.abort(),
282
+ });
263
283
 
264
284
  return `agent_id: ${agentId}\ntask_id: ${task.taskId}\nstatus: running\nnext_step: Continue your work; the result arrives in a later message. Use TaskOutput {task_id: "${task.taskId}"} for a non-blocking check or TaskStop to cancel.`;
265
285
  }
@@ -286,8 +306,13 @@ ${availableTypes()}`,
286
306
  // `onProgress` feeds the parent's live-output sink (ctx.onOutput), which the
287
307
  // agent tags with THIS tool call's id — the same path Bash uses, so the TUI
288
308
  // renders the subagent's stream under the `Using Agent` row and Ctrl+O folds it.
309
+ // The live Agent is captured as soon as `runSubagent` builds it, which is what makes
310
+ // this run controllable from another tool call (or the TUI) without waiting for it to
311
+ // finish. `live` is a box because the handle arrives asynchronously.
312
+ const live = { agent: null };
289
313
  const runPromise = runSubagent({
290
314
  cfg: subCfg, type, prompt, history, signal: bgController.signal,
315
+ onAgent: (agent) => { live.agent = agent; },
291
316
  // `_progressTag` is set by AgentSwarm to the item index, so a swarm's
292
317
  // concurrently-running subagents are distinguishable in the live stream.
293
318
  // A plain Agent call has no tag and prints its stream unlabelled.
@@ -297,6 +322,15 @@ ${availableTypes()}`,
297
322
  ctx.onOutput(tag ? text.split('\n').map((l) => (l ? tag + l : l)).join('\n') : text);
298
323
  },
299
324
  });
325
+ // Registered here so a foreground subagent is reachable by AgentMessage / AgentInterrupt
326
+ // too — that is precisely the case where a user wants to redirect one: it is the run
327
+ // they are watching. Cleared when the run settles, however it settles.
328
+ let runId = registerRun({
329
+ agentId, type, description,
330
+ steer: (text) => { if (live.agent) live.agent.steer(text); },
331
+ interrupt: () => bgController.abort(),
332
+ });
333
+ void runPromise.catch(() => {}).finally(() => { if (runId) { endRun(runId); runId = ''; } });
300
334
  if (ctx) {
301
335
  ctx._foreground = {
302
336
  kind: 'Agent',
@@ -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 — nothing between the quote and `return`, so the line lands at column 0 inside an indented block. Look at the SHAPE as well: the quote touches the first character, and spaces only turn up after the first newline.\nnew_string: "return total;\n total += 1;"\nRIGHT — four spaces, then `return`. The opening quote is followed by the indent as well, so every line carries its own indentation INCLUDING the first:\nnew_string: " return total;\n total += 1;"\nSame call, same words. The only difference is the spaces at the START of the first line, and that is the whole point — in the RIGHT form the indent sits between the quote and the code, exactly where it sits in the file you copied it from.\nThe 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