@hncode/hncode 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tools/glob.js CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
- import { normalizeInput } from './utils.js';
7
+ import { resolvePath } from './utils.js';
8
8
  import { walkFiles } from './ignore.js';
9
9
  import { globToRegexSource, expandBraces } from './matchers.js';
10
10
 
@@ -29,9 +29,11 @@ export const spec = {
29
29
  return 'Error: `pattern` is required (e.g. "src/**/*.ts").';
30
30
  }
31
31
  const fromDir = typeof args.path === 'string' && args.path ? args.path : '.';
32
- const p = normalizeInput(fromDir);
33
32
  let base;
34
- try { base = path.resolve(ctx.cwd || ctx.workspace, p); } catch (e) { return `Error: ${e.message}`; }
33
+ // Same workspace boundary as Read/Write/Edit. Glob resolved `path` directly,
34
+ // so `Glob { pattern: '**', path: 'C:\\Users\\...' }` walked any directory on
35
+ // disk with no permission gate at all.
36
+ try { base = resolvePath(fromDir, ctx); } catch (e) { return `Error: ${e.message}`; }
35
37
  if (!fs.existsSync(base)) return `Error: path does not exist: ${fromDir}`;
36
38
  if (!fs.statSync(base).isDirectory()) return `Error: --path is not a directory: ${fromDir}`;
37
39
 
package/src/tools/grep.js CHANGED
@@ -5,7 +5,7 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import cp from 'node:child_process';
8
- import { normalizeInput, isProbablyTextFile, truncateBuf } from './utils.js';
8
+ import { isProbablyTextFile, truncateBuf, resolvePath } from './utils.js';
9
9
  import { walkFiles } from './ignore.js';
10
10
  import { matchGlob } from './matchers.js';
11
11
 
@@ -33,8 +33,11 @@ export const spec = {
33
33
  required: ['pattern'],
34
34
  },
35
35
  async execute(args, ctx) {
36
- const p = normalizeInput(args.path || '.');
37
- const base = path.resolve(ctx.cwd || ctx.workspace, p);
36
+ // Same workspace boundary as Read/Write/Edit. Grep resolved `path` directly,
37
+ // so `Grep { pattern: 'KEY', path: 'C:/Users/...' }` searched any directory
38
+ // on disk — the search tools had no gate at all.
39
+ let base;
40
+ try { base = resolvePath(args.path || '.', ctx); } catch (e) { return `Error: ${e.message}`; }
38
41
  if (!fs.existsSync(base)) return `Error: path does not exist: ${args.path}`;
39
42
  if (!args.multiline && hasRg()) return await grepRg(args, base, ctx);
40
43
  return grepJs(args, base, ctx);
@@ -58,7 +61,11 @@ function hasRg() {
58
61
  }
59
62
 
60
63
  function buildRgArgs(args, base) {
61
- const a = ['--no-heading', '--line-number', '--no-config', '-e', args.pattern];
64
+ // --crlf: an end anchor (`;$`) must match a line that ends `\r\n`, exactly as
65
+ // it does in an LF file — and as the JS fallback already does. Without it the
66
+ // rg path reported "No matches found" for patterns the user could SEE ending
67
+ // the line, on this repo's own CRLF files.
68
+ const a = ['--no-heading', '--line-number', '--no-config', '--crlf', '-e', args.pattern];
62
69
  if (args['-i']) a.push('-i');
63
70
  const before = args['-B'] || (args['-C'] || 0);
64
71
  const after = args['-A'] || (args['-C'] || 0);
@@ -99,6 +106,13 @@ function grepRg(args, base, ctx) {
99
106
  child.on('error', () => done(() => resolve(grepJs(args, base, ctx))));
100
107
  child.on('close', (code) => done(() => {
101
108
  if (code > 1 && stderr) resolve(`rg error: ${stderr.trim()}`);
109
+ else if (!stdout.trim() && args.pattern.includes('$')) {
110
+ // A zero-match END-ANCHORED query may be a LONE-CR file (old-Mac endings),
111
+ // which rg cannot treat as line breaks at all. The JS walker handles every
112
+ // ending, so it gets the final word — only for `$` patterns that found
113
+ // nothing, so ordinary searches never pay for the extra pass.
114
+ resolve(grepJs(args, base, ctx));
115
+ }
102
116
  else resolve(paginate(stdout, args));
103
117
  }));
104
118
  // Honour an aborted turn (Esc): kill rg instead of leaving it running.
@@ -113,7 +127,7 @@ function grepRg(args, base, ctx) {
113
127
  function grepJs(args, base, ctx) {
114
128
  const flags = 'g' + (args['-i'] ? 'i' : '') + (args.multiline ? 's' : '');
115
129
  const re = new RegExp(args.pattern, flags);
116
- const files = walkFiles(base, { includeIgnored: !!args.include_ignored, includeDirs: false });
130
+ const files = walkFiles(base, { includeIgnored: !!args.include_ignored, includeDirs: false, limit: 0 });
117
131
  const filtered = files.filter((abs) => {
118
132
  if (args.glob) {
119
133
  const rel = path.relative(base, abs).split(path.sep).join('/');
@@ -77,17 +77,29 @@ export function matchPattern(pat, relPath, isDir) {
77
77
  // duplication, since the information was already available as each entry was
78
78
  // visited. One stat per file now, reused for both.
79
79
  export function walkFiles(base, opts = {}) {
80
- const { includeIgnored = false, includeDirs = false } = opts;
80
+ // `limit` caps the RESULT. Glob keeps the default (newest 100); Grep passes 0 —
81
+ // truncating the WALK by mtime made the search silently skip older files and
82
+ // report "No matches found" for text that was right there.
83
+ const { includeIgnored = false, includeDirs = false, limit = 1000 } = opts;
81
84
  const baseAbs = path.resolve(base);
82
85
  /** @type {{path:string, mtime:number, isFile:boolean}[]} */
83
86
  const found = [];
84
87
  _walk(baseAbs, '', [], found, { includeIgnored, includeDirs });
85
- // Newest first. Sort before truncating so the newest 1000 survive the cap.
88
+ // Newest first. Sort before truncating so the newest entries survive the cap.
86
89
  found.sort((a, b) => b.mtime - a.mtime);
87
- if (found.length > 1000) found.length = 1000;
90
+ if (limit > 0 && found.length > limit) found.length = limit;
88
91
  return found.filter((e) => includeDirs || e.isFile).map((e) => e.path);
89
92
  }
90
93
 
94
+ // Directories that are never worth walking. `.git` holds the object database —
95
+ // hundreds of thousands of files on an old repo, none of them source, and no
96
+ // .gitignore ever lists it. `node_modules` is pruned for the JS walker unless the
97
+ // caller explicitly asked for ignored files: walking it was a multi-second stall
98
+ // of the event loop (everything here is readdirSync/statSync) for results nobody
99
+ // wanted.
100
+ const PRUNED_DIRS = new Set(['.git']);
101
+ const PRUNED_UNLESS_IGNORED = new Set(['node_modules']);
102
+
91
103
  function _walk(dir, relPrefix, parentPatterns, found, opts) {
92
104
  const { includeIgnored, includeDirs } = opts;
93
105
  let entries;
@@ -98,6 +110,8 @@ function _walk(dir, relPrefix, parentPatterns, found, opts) {
98
110
  const abs = path.join(dir, ent.name);
99
111
  let isDir;
100
112
  try { isDir = ent.isDirectory(); } catch { try { isDir = fs.statSync(abs).isDirectory(); } catch { isDir = false; } }
113
+ if (isDir && (PRUNED_DIRS.has(ent.name)
114
+ || (!includeIgnored && PRUNED_UNLESS_IGNORED.has(ent.name)))) continue;
101
115
  // ignore decision
102
116
  let ignored = false;
103
117
  for (const pat of acc) {
@@ -16,13 +16,13 @@ import * as gitMod from './git.js';
16
16
  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
- import { TaskListSpec, TaskOutputSpec, TaskStopSpec, TaskWaitSpec } from './tasks.js';
19
+ import { TaskListSpec, TaskOutputSpec, TaskStopSpec, TaskWaitSpec, TaskInputSpec } from './tasks.js';
20
20
  import { AgentListSpec, AgentMessageSpec, AgentInterruptSpec, AgentCloseSpec } from './agent-control.js';
21
21
  import { TEAM_TOOL_SPECS } from './team.js';
22
22
  import { spec as notebookSpec } from './notebook.js';
23
23
  import { spec as diagnosticsSpec } from './diagnostics.js';
24
24
  import * as memoryMod from './memory.js';
25
- import { pluginTools } from '../plugin.js';
25
+ import { pluginTools, pluginToolGeneration } from '../plugin.js';
26
26
 
27
27
 
28
28
 
@@ -47,6 +47,7 @@ const builtinTools = [
47
47
  TaskOutputSpec,
48
48
  TaskStopSpec,
49
49
  TaskWaitSpec,
50
+ TaskInputSpec,
50
51
  AgentListSpec,
51
52
  AgentMessageSpec,
52
53
  AgentInterruptSpec,
@@ -72,11 +73,24 @@ export { builtinTools };
72
73
  // tools are resolvable. The previous Map was built from builtinTools only,
73
74
  // so `llmTools()` advertised a plugin tool to the model but `getTool(name)`
74
75
  // returned undefined and the call failed with `unknown tool`.
76
+ //
77
+ // The map is keyed to a REGISTRATION GENERATION: plugin.js bumps the counter
78
+ // whenever a tool registers or the plugin set is reloaded. Without that, a
79
+ // server connected in the background (MCP connects after first paint) stayed
80
+ // "unknown tool" forever, and /reload served the OLD plugin instances while
81
+ // fresh modules were imported under new spec objects.
75
82
  let _byName = null;
83
+ let _byNameGeneration = -1;
84
+ function toolGeneration() {
85
+ // Read live from plugin.js so any registration source bumps the same counter.
86
+ return pluginToolGeneration();
87
+ }
76
88
  function byNameMap() {
77
- if (!_byName) {
89
+ const gen = toolGeneration();
90
+ if (!_byName || _byNameGeneration !== gen) {
78
91
  _byName = new Map();
79
92
  for (const t of combinedTools()) if (!_byName.has(t.name)) _byName.set(t.name, t);
93
+ _byNameGeneration = gen;
80
94
  }
81
95
  return _byName;
82
96
  }
package/src/tools/read.js CHANGED
@@ -64,7 +64,8 @@ export const spec = {
64
64
  if (!isProbablyTextFile(buf)) {
65
65
  return `Cannot read: ${args.path} appears to be a binary file (use a tool suited for binary).`;
66
66
  }
67
- let st;
67
+ let st = null;
68
+ try { st = fs.statSync(p); } catch { /* stat is advisory only */ }
68
69
  // NO truncation: the caller asked for the file, so the whole file is returned
69
70
  // (line ranges still work via line_offset / n_lines). The old 1000-line and
70
71
  // 128 KB caps silently cut the content, which also corrupted the
@@ -1,134 +1,145 @@
1
- // Shell discovery for the Bash tool.
2
- //
3
- // The tool used to hardcode `spawn('pwsh', …)`, which relies on `pwsh` being on
4
- // PATH. On a Windows box with only the built-in Windows PowerShell that name does
5
- // not resolve at all, so the spawn failed with ENOENT and the whole tool was dead —
6
- // no fallback, and nothing in the message said why. It only ever worked where the
7
- // user had installed PowerShell 7 and added it to PATH by hand.
8
- //
9
- // Mirrors Claude Code's findPowerShell (src/utils/shell/powershellDetection.ts):
10
- // prefer pwsh (Core 7+), fall back to powershell (Desktop 5.1), and cache the
11
- // answer — the probe shells out, so it must not run per command.
12
- //
13
- // The EDITION matters beyond picking a binary: 7+ supports the pipeline chain
14
- // operators (`&&`, `||`) and 5.1 does not, so the prompt's syntax guidance depends
15
- // on which one was found.
16
-
17
- import cp from 'node:child_process';
18
- import fs from 'node:fs';
19
-
20
- // The two PowerShell families, by the name they are invoked as.
21
- export const SHELL_CANDIDATES = ['pwsh', 'powershell'];
22
-
23
- // Last-resort Windows fallback. A Windows box always has cmd.exe (%COMSPEC%),
24
- // so when neither PowerShell exists the Bash tool must still run SOMETHING
25
- // rather than failing outright. `cmd` is not a PowerShell, so the syntax
26
- // guidance and the spawn args differ (see cmdSpawnArgs).
27
- export function findCmdExe() {
28
- const fromEnv = process.env.COMSPEC || process.env.ComSpec;
29
- if (fromEnv) {
30
- try { if (fs.existsSync(fromEnv)) return fromEnv; } catch { /* fall through to the PATH probe */ }
31
- }
32
- if (process.platform === 'win32') {
33
- return whichSync('cmd') || whichSync('cmd.exe') || 'cmd.exe';
34
- }
35
- return null;
36
- }
37
-
38
- /**
39
- * Resolve an executable name through PATH without spawning it.
40
- *
41
- * `where` on Windows / `which` on POSIX are the platform's own resolvers, so the
42
- * answer matches what a shell would do. Returns the first absolute path or null.
43
- */
44
- function whichSync(name) {
45
- const probe = process.platform === 'win32' ? 'where' : 'which';
46
- try {
47
- const out = cp.execFileSync(probe, [name], {
48
- stdio: ['ignore', 'pipe', 'ignore'],
49
- encoding: 'utf8',
50
- windowsHide: true,
51
- });
52
- // `where` can print several matches; take the first non-empty line.
53
- const first = String(out).split(/\r?\n/).map((l) => l.trim()).filter(Boolean)[0];
54
- return first || null;
55
- } catch {
56
- return null;
57
- }
58
- }
59
-
60
- /**
61
- * Find a usable shell: pwsh, then the built-in powershell, then (on Windows)
62
- * cmd.exe as a last resort. Returns { path, name, edition } or null.
63
- *
64
- * `edition` drives syntax guidance — see the header. It is inferred from the NAME
65
- * rather than by running the binary (a spawn per probe is not worth it, and
66
- * PowerShell 6, also named `pwsh`, is EOL and not a realistic target, so `pwsh`
67
- * safely implies 7+). `edition: 'cmd'` means neither PowerShell exists and the
68
- * Bash tool is running through cmd.exe: it still works, but the command syntax is
69
- * cmd's, not PowerShell's.
70
- */
71
- export function detectShell() {
72
- for (const name of SHELL_CANDIDATES) {
73
- const path = whichSync(name);
74
- if (path) {
75
- return { path, name, edition: name === 'pwsh' ? 'core' : 'desktop' };
76
- }
77
- }
78
- // No PowerShell at all: on Windows, guarantee cmd.exe so the tool is not dead.
79
- const cmd = findCmdExe();
80
- if (cmd) return { path: cmd, name: 'cmd', edition: 'cmd' };
81
- return null;
82
- }
83
-
84
- let cached; // undefined = not probed yet; null = probed, none found
85
- let cachedEnvKey; // re-probe if PATH changes (a /env PATH=… must take effect)
86
-
87
- /**
88
- * Cached detectShell(). The probe spawns a process, so it runs once per PATH.
89
- * Re-probing when PATH changes means an in-session PATH fix (`/env PATH=…`, which
90
- * is exactly what a user with a stripped PATH would do) can rescue the tool.
91
- */
92
- export function getShell() {
93
- const key = String(process.env.PATH || '');
94
- if (cached === undefined || cachedEnvKey !== key) {
95
- cached = detectShell();
96
- cachedEnvKey = key;
97
- }
98
- return cached;
99
- }
100
-
101
- /** Test seam: forget the cached probe. */
102
- export function resetShellCache() {
103
- cached = undefined;
104
- cachedEnvKey = undefined;
105
- }
106
-
107
- /**
108
- * The command + args to run `command` through the detected shell.
109
- *
110
- * `-NoProfile` and `-NonInteractive` are not cosmetic: a user profile can print a
111
- * banner into stdout (polluting the captured result) and can take seconds to load
112
- * on every single tool call, and `-NonInteractive` stops a command that prompts
113
- * from hanging the tool forever with no way to answer.
114
- *
115
- * When neither PowerShell exists, `edition === 'cmd'` selects cmd.exe args instead
116
- * (see detectShell). Throws a message naming the fix only when no shell at all
117
- * was found, instead of letting the caller hit a bare ENOENT.
118
- */
119
- export function shellSpawnArgs(command) {
120
- const shell = getShell();
121
- if (!shell) {
122
- throw new Error(
123
- 'No shell found (tried pwsh, powershell, and cmd.exe). '
124
- + 'Install PowerShell 7+ or add it to PATH, then retry.',
125
- );
126
- }
127
- if (shell.edition === 'cmd') {
128
- // cmd.exe: `/d` skips AutoRun commands, `/s` makes `/c` quoting behave
129
- // predictably. The command is passed as ONE argv element, so Node does the
130
- // quoting and we do not wrap it ourselves.
131
- return { bin: shell.path, args: ['/d', '/s', '/c', command], shell };
132
- }
133
- return { bin: shell.path, args: ['-NoProfile', '-NonInteractive', '-Command', command], shell };
134
- }
1
+ // Shell discovery for the Bash tool.
2
+ //
3
+ // The tool used to hardcode `spawn('pwsh', …)`, which relies on `pwsh` being on
4
+ // PATH. On a Windows box with only the built-in Windows PowerShell that name does
5
+ // not resolve at all, so the spawn failed with ENOENT and the whole tool was dead —
6
+ // no fallback, and nothing in the message said why. It only ever worked where the
7
+ // user had installed PowerShell 7 and added it to PATH by hand.
8
+ //
9
+ // Mirrors Claude Code's findPowerShell (src/utils/shell/powershellDetection.ts):
10
+ // prefer pwsh (Core 7+), fall back to powershell (Desktop 5.1), and cache the
11
+ // answer — the probe shells out, so it must not run per command.
12
+ //
13
+ // The EDITION matters beyond picking a binary: 7+ supports the pipeline chain
14
+ // operators (`&&`, `||`) and 5.1 does not, so the prompt's syntax guidance depends
15
+ // on which one was found.
16
+
17
+ import cp from 'node:child_process';
18
+ import fs from 'node:fs';
19
+
20
+ // The two PowerShell families, by the name they are invoked as.
21
+ export const SHELL_CANDIDATES = ['pwsh', 'powershell'];
22
+
23
+ // Last-resort Windows fallback. A Windows box always has cmd.exe (%COMSPEC%),
24
+ // so when neither PowerShell exists the Bash tool must still run SOMETHING
25
+ // rather than failing outright. `cmd` is not a PowerShell, so the syntax
26
+ // guidance and the spawn args differ (see cmdSpawnArgs).
27
+ export function findCmdExe() {
28
+ const fromEnv = process.env.COMSPEC || process.env.ComSpec;
29
+ if (fromEnv) {
30
+ try { if (fs.existsSync(fromEnv)) return fromEnv; } catch { /* fall through to the PATH probe */ }
31
+ }
32
+ if (process.platform === 'win32') {
33
+ return whichSync('cmd') || whichSync('cmd.exe') || 'cmd.exe';
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * Resolve an executable name through PATH without spawning it.
40
+ *
41
+ * `where` on Windows / `which` on POSIX are the platform's own resolvers, so the
42
+ * answer matches what a shell would do. Returns the first absolute path or null.
43
+ */
44
+ function whichSync(name) {
45
+ const probe = process.platform === 'win32' ? 'where' : 'which';
46
+ try {
47
+ const out = cp.execFileSync(probe, [name], {
48
+ stdio: ['ignore', 'pipe', 'ignore'],
49
+ encoding: 'utf8',
50
+ windowsHide: true,
51
+ });
52
+ // `where` can print several matches; take the first non-empty line.
53
+ const first = String(out).split(/\r?\n/).map((l) => l.trim()).filter(Boolean)[0];
54
+ return first || null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Find a usable shell: pwsh, then the built-in powershell, then (on Windows)
62
+ * cmd.exe as a last resort. Returns { path, name, edition } or null.
63
+ *
64
+ * `edition` drives syntax guidance — see the header. It is inferred from the NAME
65
+ * rather than by running the binary (a spawn per probe is not worth it, and
66
+ * PowerShell 6, also named `pwsh`, is EOL and not a realistic target, so `pwsh`
67
+ * safely implies 7+). `edition: 'cmd'` means neither PowerShell exists and the
68
+ * Bash tool is running through cmd.exe: it still works, but the command syntax is
69
+ * cmd's, not PowerShell's.
70
+ */
71
+ export function detectShell() {
72
+ for (const name of SHELL_CANDIDATES) {
73
+ const path = whichSync(name);
74
+ if (path) {
75
+ return { path, name, edition: name === 'pwsh' ? 'core' : 'desktop' };
76
+ }
77
+ }
78
+ // No PowerShell at all: on Windows, guarantee cmd.exe so the tool is not dead.
79
+ const cmd = findCmdExe();
80
+ if (cmd) return { path: cmd, name: 'cmd', edition: 'cmd' };
81
+ return null;
82
+ }
83
+
84
+ let cached; // undefined = not probed yet; null = probed, none found
85
+ let cachedEnvKey; // re-probe if PATH changes (a /env PATH=… must take effect)
86
+
87
+ /**
88
+ * Cached detectShell(). The probe spawns a process, so it runs once per PATH.
89
+ * Re-probing when PATH changes means an in-session PATH fix (`/env PATH=…`, which
90
+ * is exactly what a user with a stripped PATH would do) can rescue the tool.
91
+ */
92
+ export function getShell() {
93
+ const key = String(process.env.PATH || '');
94
+ if (cached === undefined || cachedEnvKey !== key) {
95
+ cached = detectShell();
96
+ cachedEnvKey = key;
97
+ }
98
+ return cached;
99
+ }
100
+
101
+ /** Test seam: forget the cached probe. */
102
+ export function resetShellCache() {
103
+ cached = undefined;
104
+ cachedEnvKey = undefined;
105
+ }
106
+
107
+ /**
108
+ * The command + args to run `command` through the detected shell.
109
+ *
110
+ * `-NoProfile` and `-NonInteractive` are not cosmetic: a user profile can print a
111
+ * banner into stdout (polluting the captured result) and can take seconds to load
112
+ * on every single tool call, and `-NonInteractive` stops a command that prompts
113
+ * from hanging the tool forever with no way to answer.
114
+ *
115
+ * When neither PowerShell exists, `edition === 'cmd'` selects cmd.exe args instead
116
+ * (see detectShell). Throws a message naming the fix only when no shell at all
117
+ * was found, instead of letting the caller hit a bare ENOENT.
118
+ */
119
+ /**
120
+ * The spawn argv for a command.
121
+ *
122
+ * `interactive` drops `-NonInteractive`, and that is not cosmetic: PowerShell in non-interactive mode
123
+ * treats stdin as an already-finished stream and closes it, so a child started that way reads EOF
124
+ * immediately and exits — before anything can be written to it. The flag has to come off for
125
+ * `Bash {interactive: true}` to mean anything.
126
+ */
127
+ export function shellSpawnArgs(command, opts = {}) {
128
+ const shell = getShell();
129
+ if (!shell) {
130
+ throw new Error(
131
+ 'No shell found (tried pwsh, powershell, and cmd.exe). '
132
+ + 'Install PowerShell 7+ or add it to PATH, then retry.',
133
+ );
134
+ }
135
+ if (shell.edition === 'cmd') {
136
+ // cmd.exe: `/d` skips AutoRun commands, `/s` makes `/c` quoting behave
137
+ // predictably. The command is passed as ONE argv element, so Node does the
138
+ // quoting and we do not wrap it ourselves.
139
+ return { bin: shell.path, args: ['/d', '/s', '/c', command], shell };
140
+ }
141
+ const args = ['-NoProfile'];
142
+ if (!opts.interactive) args.push('-NonInteractive');
143
+ args.push('-Command', command);
144
+ return { bin: shell.path, args, shell };
145
+ }