@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.
- package/package.json +38 -38
- package/src/agent.js +172 -19
- package/src/cache.js +175 -130
- package/src/clipboard.js +69 -2
- package/src/config.js +10 -3
- package/src/index.js +29 -5
- package/src/keybindings.js +25 -3
- package/src/llm.js +54 -6
- package/src/mcp.js +419 -384
- package/src/plugin.js +95 -10
- package/src/remote.js +307 -289
- package/src/session-hub.js +37 -0
- package/src/settings-schema.js +2 -0
- package/src/shell-safety.js +610 -562
- package/src/swarm-progress.js +1 -1
- package/src/team/mailbox.js +216 -139
- package/src/team/permission.js +121 -0
- package/src/team/protocol.js +167 -0
- package/src/team/runner.js +127 -12
- package/src/team/spawn.js +63 -5
- package/src/team/store.js +31 -4
- package/src/team/tasks.js +15 -2
- package/src/team/team.js +51 -16
- package/src/team-panel.js +418 -20
- package/src/term-caps.js +457 -410
- package/src/term.js +142 -26
- package/src/tool-result.js +50 -2
- package/src/tools/agent.js +464 -436
- package/src/tools/bash.js +53 -14
- package/src/tools/diagnostics.js +13 -0
- package/src/tools/edit.js +727 -577
- package/src/tools/fetch-url.js +118 -5
- package/src/tools/glob.js +5 -3
- package/src/tools/grep.js +19 -5
- package/src/tools/ignore.js +17 -3
- package/src/tools/index.js +17 -3
- package/src/tools/read.js +13 -2
- package/src/tools/shell.js +145 -134
- package/src/tools/tasks.js +214 -141
- package/src/tools/team.js +13 -0
- package/src/tools/utils.js +12 -3
- package/src/tools/web-search.js +85 -34
- package/src/tui.js +2089 -714
- package/src/update.js +38 -3
- package/src/web.js +47 -1
package/src/tools/fetch-url.js
CHANGED
|
@@ -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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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('/');
|
package/src/tools/ignore.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 >
|
|
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) {
|
package/src/tools/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/src/tools/shell.js
CHANGED
|
@@ -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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
+
}
|