@hncode/hncode 0.5.2 → 0.5.5

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 (45) hide show
  1. package/package.json +38 -38
  2. package/src/agent.js +172 -19
  3. package/src/cache.js +175 -130
  4. package/src/clipboard.js +69 -2
  5. package/src/config.js +10 -3
  6. package/src/index.js +29 -5
  7. package/src/keybindings.js +25 -3
  8. package/src/llm.js +54 -6
  9. package/src/mcp.js +419 -384
  10. package/src/plugin.js +95 -10
  11. package/src/remote.js +307 -289
  12. package/src/session-hub.js +37 -0
  13. package/src/settings-schema.js +2 -0
  14. package/src/shell-safety.js +610 -562
  15. package/src/swarm-progress.js +1 -1
  16. package/src/team/mailbox.js +216 -139
  17. package/src/team/permission.js +121 -0
  18. package/src/team/protocol.js +167 -0
  19. package/src/team/runner.js +127 -12
  20. package/src/team/spawn.js +63 -5
  21. package/src/team/store.js +31 -4
  22. package/src/team/tasks.js +15 -2
  23. package/src/team/team.js +51 -16
  24. package/src/team-panel.js +418 -20
  25. package/src/term-caps.js +457 -410
  26. package/src/term.js +142 -26
  27. package/src/tool-result.js +50 -2
  28. package/src/tools/agent.js +464 -436
  29. package/src/tools/bash.js +53 -14
  30. package/src/tools/diagnostics.js +13 -0
  31. package/src/tools/edit.js +727 -577
  32. package/src/tools/fetch-url.js +118 -5
  33. package/src/tools/glob.js +5 -3
  34. package/src/tools/grep.js +19 -5
  35. package/src/tools/ignore.js +17 -3
  36. package/src/tools/index.js +17 -3
  37. package/src/tools/read.js +13 -2
  38. package/src/tools/shell.js +145 -134
  39. package/src/tools/tasks.js +214 -141
  40. package/src/tools/team.js +13 -0
  41. package/src/tools/utils.js +12 -3
  42. package/src/tools/web-search.js +85 -34
  43. package/src/tui.js +2089 -714
  44. package/src/update.js +38 -3
  45. package/src/web.js +47 -1
@@ -1,6 +1,8 @@
1
1
  // FetchURL tool — fetch a web page's main text content (Node built-in fetch, no deps).
2
2
 
3
3
  import os from 'node:os';
4
+ import http from 'node:http';
5
+ import https from 'node:https';
4
6
 
5
7
  const MAX_BYTES = 200 * 1024; // generous cap for large pages
6
8
 
@@ -42,6 +44,100 @@ function rejectUrl(raw) {
42
44
  return null; // ok
43
45
  }
44
46
 
47
+ /**
48
+ * A browser-ish header set.
49
+ *
50
+ * `Accept` is part of it: a request with a UA but an empty Accept still looks like a script to some
51
+ * servers, and they answer 406 or 429.
52
+ */
53
+ const HEADERS = {
54
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
55
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.5',
56
+ 'Accept-Language': 'en-US,en;q=0.9,zh-CN;q=0.8',
57
+ };
58
+
59
+ /** How long to wait before retrying a rate-limited request. */
60
+ const RETRY_AFTER_MS = 1500;
61
+
62
+ /**
63
+ * Fetch with the browser headers, retrying a 429 once.
64
+ *
65
+ * One retry after a short pause turns a transient rate limit into a success; a second immediate attempt
66
+ * would only count as another request against the limit.
67
+ */
68
+ /**
69
+ * Fetch with the browser headers, retrying a 429 once.
70
+ *
71
+ * `insecure` SKIPS CERTIFICATE VERIFICATION, for a host whose chain is broken — an internal server with a
72
+ * self-signed certificate, an expired intermediate — where the alternative is not fetching it at all.
73
+ *
74
+ * `fetch` has no per-request switch for this: the option lives on the dispatcher, and Node does not expose
75
+ * one without the `undici` package this project does not depend on. So that path goes through
76
+ * `node:https` instead. It is the SAME request — same headers, same redirects, same body cap — just built
77
+ * by hand so `rejectUnauthorized: false` can be passed.
78
+ */
79
+ async function fetchWithRetry(url, signal, insecure = false) {
80
+ if (insecure) return httpsRequest(url, signal, true);
81
+ const first = await fetch(url, { signal, redirect: 'follow', headers: HEADERS });
82
+ if (first.status !== 429) return first;
83
+ await new Promise((r) => setTimeout(r, RETRY_AFTER_MS));
84
+ // The first response body was never read; release it so the connection can be reused.
85
+ try { await first.body?.cancel(); } catch { /* already gone */ }
86
+ return fetch(url, { signal, redirect: 'follow', headers: HEADERS });
87
+ }
88
+
89
+ /**
90
+ * One GET over `node:https`, returning the shape the caller already handles.
91
+ *
92
+ * Only used for the `insecure` path, where `fetch` cannot express what is needed. The response is
93
+ * buffered up to MAX_BYTES and handed back as an object with `.ok`, `.status`, `.headers.get()` and
94
+ * `.arrayBuffer()`, so the rest of `execute` does not know which transport ran.
95
+ */
96
+ function httpsRequest(url, signal, insecure) {
97
+ return new Promise((resolve, reject) => {
98
+ let u;
99
+ try { u = new URL(url); } catch (e) { reject(e); return; }
100
+ const mod = u.protocol === 'http:' ? http : https;
101
+ const req = mod.request({
102
+ protocol: u.protocol,
103
+ hostname: u.hostname,
104
+ port: u.port || undefined,
105
+ path: u.pathname + u.search,
106
+ method: 'GET',
107
+ headers: HEADERS,
108
+ // The whole point of this path. Every other request in the tool keeps verification on.
109
+ rejectUnauthorized: !insecure,
110
+ }, (res) => {
111
+ // A redirect is followed rather than reported: `fetch` defaults to `follow`, and a fetch that
112
+ // stopped at a 301 would silently return an empty body for most of the web.
113
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
114
+ res.resume();
115
+ const next = new URL(res.headers.location, u).href;
116
+ resolve(httpsRequest(next, signal, insecure));
117
+ return;
118
+ }
119
+ const chunks = [];
120
+ let got = 0;
121
+ res.on('data', (c) => { if (got < MAX_BYTES) { chunks.push(c); got += c.length; } });
122
+ res.on('end', () => {
123
+ const body = Buffer.concat(chunks);
124
+ resolve({
125
+ ok: res.statusCode >= 200 && res.statusCode < 300,
126
+ status: res.statusCode,
127
+ statusText: res.statusMessage || '',
128
+ headers: { get: (n) => res.headers[String(n).toLowerCase()] || null },
129
+ arrayBuffer: async () => body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength),
130
+ body: null,
131
+ });
132
+ });
133
+ res.on('error', reject);
134
+ });
135
+ req.on('error', reject);
136
+ if (signal) signal.addEventListener('abort', () => req.destroy(new Error('interrupted')), { once: true });
137
+ req.end();
138
+ });
139
+ }
140
+
45
141
  export const spec = {
46
142
  name: 'FetchURL',
47
143
  description: 'Fetch a URL and return its text (main page text, or the full body; a leading note says which). Any http/https URL, including localhost.',
@@ -49,6 +145,7 @@ export const spec = {
49
145
  type: 'object',
50
146
  properties: {
51
147
  url: { type: 'string', description: 'URL to fetch.' },
148
+ insecure: { type: 'boolean', default: false, description: 'Skip TLS certificate verification. For a host with a self-signed or expired certificate whose content is still wanted — the connection is no longer authenticated, so only use it for a source you already trust.' },
52
149
  },
53
150
  required: ['url'],
54
151
  },
@@ -57,10 +154,21 @@ export const spec = {
57
154
  if (rejected) return rejected;
58
155
  const signal = (ctx && ctx.signal) || undefined;
59
156
  try {
60
- const res = await fetch(args.url.trim(), { signal, redirect: 'follow' });
61
- if (!res.ok) {
62
- return `Error: ${res.status} ${res.statusText} from ${args.url}`;
63
- }
157
+ // A BROWSER USER-AGENT, and it is not cosmetic.
158
+ //
159
+ // Without one Node sends `user-agent: node`, and a great many sites answer that with 403 or 429
160
+ // before serving anything. Measured on a wukongsch.com article: 429 with the default headers,
161
+ // served normally with a browser UA.
162
+ const res = await fetchWithRetry(args.url.trim(), signal, args.insecure === true);
163
+ if (!res.ok) {
164
+ // SAY WHAT THE STATUS MEANS. A bare 429 left the caller re-fetching the same page to see the
165
+ // same number.
166
+ const hint = res.status === 429 ? ' — the site is rate-limiting requests; wait and retry, or find another source.'
167
+ : res.status === 403 ? ' — the site refused the request; it may require a login or block automated fetches.'
168
+ : res.status === 404 ? ' — no such page.'
169
+ : '';
170
+ return `Error: ${res.status} ${res.statusText} from ${args.url}${hint}`;
171
+ }
64
172
  const contentType = (res.headers.get('content-type') || '').toLowerCase();
65
173
  const isHtml = contentType.includes('text/html') || contentType.includes('application/xhtml');
66
174
  let isTruncated = false;
@@ -101,7 +209,12 @@ export const spec = {
101
209
  if (isTruncated) out += `\n\n[... truncated; page larger than ${Math.round(MAX_BYTES / 1024)} KB]`;
102
210
  return out;
103
211
  } catch (e) {
104
- const msg = e && e.name === 'AbortError' ? 'interrupted' : (e.message || String(e));
212
+ // 'fetch failed' IS NOT A REASON. Node puts the real cause — ENOTFOUND, ECONNREFUSED,
213
+ // CERT_HAS_EXPIRED, a TLS handshake failure — on `e.cause`, and only `e.message` was read, so
214
+ // every distinct failure looked identical and told the caller nothing they could act on.
215
+ const cause = e && e.cause ? (e.cause.code || e.cause.message) : '';
216
+ const base = e && e.name === 'AbortError' ? 'interrupted' : (e.message || String(e));
217
+ const msg = cause && !String(base).includes(cause) ? `${base} (${cause})` : base;
105
218
  return `Error fetching ${args.url}: ${msg}`;
106
219
  }
107
220
  },
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,14 +64,25 @@ 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
71
72
  // staleness hashes recorded below because they were computed from the
72
73
  // TRUNCATED text rather than the file on disk.
73
74
  const text = buf.toString('utf8');
74
- const lines = normalizeText(text).split('\n');
75
+ // A FILE ENDING IN A NEWLINE HAS ONE FEWER LINE THAN `split` REPORTS.
76
+ //
77
+ // `"a\nb\n".split("\n")` is `["a", "b", ""]` — the trailing newline leaves an empty element, and
78
+ // counting it made a ten-line file report `lines 1-11 of 11`. The numbers on each line were right;
79
+ // the TOTAL was one too high, so a caller planning an edit at the end of the file aimed at a row that
80
+ // does not exist.
81
+ //
82
+ // Only the element the trailing newline produced is dropped: a file that genuinely ends with a blank
83
+ // line (`"a\n\n"`) keeps it, because that is real content.
84
+ const lines = normalizeText(text).split("\n");
85
+ if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
75
86
  const total = lines.length;
76
87
  // Default: every line. An explicit n_lines still narrows the window.
77
88
  const nLines = args.n_lines ? args.n_lines : total;
@@ -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
+ }