@hncode/hncode 0.3.0 → 0.3.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.
- package/README_CN.md +0 -1
- package/package.json +1 -1
- package/src/agent.js +5 -0
- package/src/config.js +63 -10
- package/src/index.js +24 -7
- package/src/llm.js +60 -11
- package/src/plugin.js +13 -0
- package/src/session.js +257 -24
- package/src/tools/agent-swarm.js +6 -1
- package/src/tools/agent.js +8 -1
- package/src/tools/bash.js +7 -1
- package/src/tools/edit.js +12 -12
- package/src/tools/file-lines.js +41 -8
- package/src/tools/glob.js +9 -4
- package/src/tools/grep.js +16 -3
- package/src/tools/index.js +13 -2
- package/src/tools/matchers.js +75 -71
- package/src/tools/read-media-file.js +17 -3
- package/src/tools/read.js +2 -0
- package/src/tools/tasks.js +2 -1
- package/src/tools/utils.js +11 -1
- package/src/tui.js +209 -47
- package/src/update.js +2 -14
- package/src/version.js +20 -0
package/src/session.js
CHANGED
|
@@ -10,6 +10,43 @@ import crypto from 'node:crypto';
|
|
|
10
10
|
const DEFAULT_DIR = () => path.join(os.homedir(), '.hncode', 'sessions');
|
|
11
11
|
function dir(custom) { return custom || process.env.HNCODE_SESSIONS_DIR || DEFAULT_DIR(); }
|
|
12
12
|
|
|
13
|
+
// ---- metadata cache --------------------------------------------------------
|
|
14
|
+
// Enumerating sessions (listSessions / latestSession / --continue) re-read every
|
|
15
|
+
// session file to grab its id/title/workspace/timestamps. With hundreds of
|
|
16
|
+
// sessions that is hundreds of open+read+parse per call. Since session files are
|
|
17
|
+
// only ever written through THIS module (saveSession/deleteSession), we cache the
|
|
18
|
+
// parsed metadata per directory and invalidate the affected entries on write. A
|
|
19
|
+
// caller that knows the dir changed externally (another hncode instance) can call
|
|
20
|
+
// invalidateSessionCache(). The cache never stores message content — only the
|
|
21
|
+
// cheap metadata head.
|
|
22
|
+
const _metaCache = new Map(); // key: storeDir (string) -> Map<fullPath, {meta, hasMessages}>
|
|
23
|
+
function cacheFor(storeDir, create = true) {
|
|
24
|
+
const d = dir(storeDir);
|
|
25
|
+
let c = _metaCache.get(d);
|
|
26
|
+
if (!c && create) { c = new Map(); _metaCache.set(d, c); }
|
|
27
|
+
return c;
|
|
28
|
+
}
|
|
29
|
+
export function invalidateSessionCache(storeDir) {
|
|
30
|
+
_metaCache.delete(dir(storeDir));
|
|
31
|
+
}
|
|
32
|
+
// Public types do not change; this is used by the TUI/headless before a re-list
|
|
33
|
+
// if external state is suspected. Our own save/delete already invalidate.
|
|
34
|
+
|
|
35
|
+
// FIX: single place for the error log so save/load failures are never silent.
|
|
36
|
+
function errorLogFile() {
|
|
37
|
+
return path.join(os.homedir(), '.hncode', 'session-errors.log');
|
|
38
|
+
}
|
|
39
|
+
function logSessionError(op, detail) {
|
|
40
|
+
try {
|
|
41
|
+
fs.mkdirSync(path.dirname(errorLogFile()), { recursive: true });
|
|
42
|
+
fs.appendFileSync(
|
|
43
|
+
errorLogFile(),
|
|
44
|
+
`[${new Date().toISOString()}] ${op}: ${detail}\n`,
|
|
45
|
+
'utf8',
|
|
46
|
+
);
|
|
47
|
+
} catch { /* logging must never throw */ }
|
|
48
|
+
}
|
|
49
|
+
|
|
13
50
|
export function newId() {
|
|
14
51
|
// compact, mostly-sorted id (like a timestamp+rand ULID-ish)
|
|
15
52
|
return Date.now().toString(36) + '-' + crypto.randomBytes(3).toString('hex');
|
|
@@ -19,55 +56,251 @@ export function sessionFile(id, storeDir) {
|
|
|
19
56
|
return path.join(dir(storeDir), `${id}.json`);
|
|
20
57
|
}
|
|
21
58
|
|
|
59
|
+
// FIX: build the serialized object with a FIXED key order so `updatedAt`
|
|
60
|
+
// always lands BEFORE `messages`. readSessionMeta() stops scanning at the
|
|
61
|
+
// `"messages"` key, so a session whose updatedAt sits after the transcript
|
|
62
|
+
// would report updatedAt = 0 and the session list / --continue would sort
|
|
63
|
+
// wrongly (or pick the wrong session). Key order here is authoritative.
|
|
64
|
+
function serializableSession(session) {
|
|
65
|
+
const out = {};
|
|
66
|
+
out.id = session.id;
|
|
67
|
+
out.title = session.title || '';
|
|
68
|
+
out.workspace = session.workspace || '';
|
|
69
|
+
out.model = session.model || '';
|
|
70
|
+
out.createdAt = session.createdAt || Date.now();
|
|
71
|
+
out.updatedAt = Date.now();
|
|
72
|
+
// Everything else (messages, rounds, steps, plan, focus, swarm, effort, …)
|
|
73
|
+
// is copied AFTER updatedAt. `messages` in particular must come after the
|
|
74
|
+
// metadata block.
|
|
75
|
+
for (const k of Object.keys(session)) {
|
|
76
|
+
if (k in out) continue;
|
|
77
|
+
if (k === 'messages') continue; // handled below, last
|
|
78
|
+
out[k] = session[k];
|
|
79
|
+
}
|
|
80
|
+
out.messages = Array.isArray(session.messages) ? session.messages : [];
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
22
84
|
export function saveSession(session, storeDir) {
|
|
85
|
+
if (!session || !session.id) return session;
|
|
86
|
+
// Keep the live object's updatedAt in sync for callers that read it back.
|
|
23
87
|
session.updatedAt = Date.now();
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
88
|
+
try {
|
|
89
|
+
fs.mkdirSync(dir(storeDir), { recursive: true });
|
|
90
|
+
const target = sessionFile(session.id, storeDir);
|
|
91
|
+
// FIX: unique temp name so two concurrent saves of the same session cannot
|
|
92
|
+
// clobber each other's temp file mid-write.
|
|
93
|
+
const tmp = `${target}.${process.pid}.${crypto.randomBytes(3).toString('hex')}.tmp`;
|
|
94
|
+
const json = JSON.stringify(serializableSession(session), null, 2);
|
|
95
|
+
fs.writeFileSync(tmp, json, 'utf8');
|
|
96
|
+
// Atomic replace. If this throws (EXDEV etc.) the temp is cleaned up below.
|
|
97
|
+
fs.renameSync(tmp, target);
|
|
98
|
+
// The saved transcript changed, so its cached metadata head is stale. Drop
|
|
99
|
+
// just this entry; the next scan re-reads the file (cheap, one file).
|
|
100
|
+
cacheFor(storeDir, false)?.delete(sessionFile(session.id, storeDir));
|
|
101
|
+
} catch (e) {
|
|
102
|
+
// FIX: never fail silently. Previously an empty catch {} in the TUI meant a
|
|
103
|
+
// serialization error (circular ref, disk full, permissions) lost the turn
|
|
104
|
+
// with no signal at all.
|
|
105
|
+
logSessionError('save', `${session.id}: ${e && e.message ? e.message : String(e)}`);
|
|
106
|
+
return session;
|
|
107
|
+
}
|
|
28
108
|
return session;
|
|
29
109
|
}
|
|
30
110
|
|
|
31
111
|
export function loadSession(id, storeDir) {
|
|
32
|
-
|
|
33
|
-
|
|
112
|
+
const file = sessionFile(id, storeDir);
|
|
113
|
+
let text;
|
|
114
|
+
try {
|
|
115
|
+
text = fs.readFileSync(file, 'utf8');
|
|
116
|
+
} catch (e) {
|
|
117
|
+
// ENOENT is "no such session" (normal). Anything else is a real read error.
|
|
118
|
+
if (e && e.code !== 'ENOENT') logSessionError('load', `${id}: ${e.message}`);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
return JSON.parse(text);
|
|
123
|
+
} catch (e) {
|
|
124
|
+
// FIX: a corrupt file is NOT the same as a missing session. Log it so the
|
|
125
|
+
// user can find the damaged file instead of silently "losing" the session.
|
|
126
|
+
logSessionError('parse', `${id}: ${e.message} (file: ${file})`);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
34
129
|
}
|
|
35
130
|
|
|
36
131
|
// Most recent session for a workspace, or globally.
|
|
37
132
|
// `opts.skipEmpty` ignores sessions with no messages: hncode creates an empty
|
|
38
133
|
// session shell on every launch, so without this `--continue` would "resume"
|
|
39
134
|
// a brand-new empty session instead of the user's last real conversation.
|
|
135
|
+
//
|
|
136
|
+
// Only metadata is read (see readSessionMeta): the full parse was 370 ms on 40
|
|
137
|
+
// realistic sessions, pure waste since the caller needs a title and a timestamp.
|
|
40
138
|
export function latestSession(cwd, storeDir, opts = {}) {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
let best = null;
|
|
139
|
+
const entries = scanCached(storeDir);
|
|
140
|
+
const wantWs = cwd !== undefined ? path.resolve(cwd) : null;
|
|
141
|
+
let best = null; // { meta, file }
|
|
45
142
|
let bestEmpty = null;
|
|
46
|
-
|
|
143
|
+
const stamp = (m) => (m && (m.updatedAt || m.createdAt)) || 0;
|
|
144
|
+
for (const { meta, hasMessages, file } of entries) {
|
|
145
|
+
if (wantWs !== null && meta.workspace !== wantWs) continue;
|
|
146
|
+
const up = stamp(meta);
|
|
147
|
+
if (!best || up > stamp(best.meta)) best = { meta, file };
|
|
148
|
+
// hasMessages comes from the same cache entry (single head-read per file),
|
|
149
|
+
// so we do not reopen anything to test emptiness.
|
|
150
|
+
if (hasMessages && (!bestEmpty || up > stamp(bestEmpty.meta))) bestEmpty = { meta, file };
|
|
151
|
+
}
|
|
152
|
+
const pick = opts.skipEmpty ? (bestEmpty || best) : best;
|
|
153
|
+
if (!pick) return null;
|
|
154
|
+
// The caller wants a usable session, so load the chosen one in full — one file,
|
|
155
|
+
// not forty.
|
|
156
|
+
const loaded = loadSession(pick.meta.id || path.basename(pick.file, '.json'), storeDir);
|
|
157
|
+
if (loaded) return loaded;
|
|
158
|
+
// If the full load failed (corrupt), fall back to the metadata we already have
|
|
159
|
+
// so the caller still gets an id/title/workspace instead of null.
|
|
160
|
+
return pick.meta;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Enumerate the session directory, reading each file's metadata head ONCE per
|
|
164
|
+
// file per process (cached). Repeated calls — listSessions then latestSession,
|
|
165
|
+
// or the /sessions picker — reuse the parsed metadata instead of re-opening
|
|
166
|
+
// every file. Entries are deleted from the cache when saveSession/deleteSession
|
|
167
|
+
// touches that file, and stale paths are pruned here when the directory shrinks.
|
|
168
|
+
// Returns [{ file, meta, hasMessages }].
|
|
169
|
+
function scanCached(storeDir) {
|
|
170
|
+
const d = dir(storeDir);
|
|
171
|
+
const map = cacheFor(storeDir);
|
|
172
|
+
let names;
|
|
173
|
+
try { names = fs.readdirSync(d).filter((f) => f.endsWith('.json')); } catch { map.clear(); return []; }
|
|
174
|
+
// Prune cached paths that no longer exist on disk.
|
|
175
|
+
const live = new Set(names);
|
|
176
|
+
for (const p of map.keys()) if (!live.has(path.basename(p))) map.delete(p);
|
|
177
|
+
// Read only the files we have not cached yet.
|
|
178
|
+
const out = [];
|
|
179
|
+
for (const f of names) {
|
|
180
|
+
const full = path.join(d, f);
|
|
181
|
+
let got = map.get(full);
|
|
182
|
+
if (!got) {
|
|
183
|
+
got = _readMetaOnce(full);
|
|
184
|
+
if (got) map.set(full, got);
|
|
185
|
+
}
|
|
186
|
+
if (got) out.push({ file: full, meta: got.meta, hasMessages: got.hasMessages });
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Read ONLY a session's metadata (id/title/workspace/timestamps), never its
|
|
192
|
+
// messages — and never the whole FILE. Two costs had to go:
|
|
193
|
+
// * `JSON.parse` of the entire session: listing 40 realistic sessions measured
|
|
194
|
+
// 528 ms, `--continue` 370 ms, to read a title and a date;
|
|
195
|
+
// * even `readFileSync` of the whole file: a session with a long transcript is
|
|
196
|
+
// megabytes, and pulling it into a string is itself the stall.
|
|
197
|
+
//
|
|
198
|
+
// FIX: the previous version scanned for the bare `"messages"` key and stopped,
|
|
199
|
+
// which is wrong in two ways:
|
|
200
|
+
// 1. it assumes no metadata follows messages, but saveSession USED TO append
|
|
201
|
+
// `updatedAt` after `messages` (object key insertion order), so updatedAt
|
|
202
|
+
// was never seen and every session sorted as 0;
|
|
203
|
+
// 2. it cut the head mid-key, so the JSON.parse fixup was fragile.
|
|
204
|
+
// Now we stop at `"messages": [` (the array VALUE), not the key, so any key
|
|
205
|
+
// between the metadata block and the messages array is still included.
|
|
206
|
+
//
|
|
207
|
+
// Returns null when the file is not a readable session.
|
|
208
|
+
const META_HEAD_BYTES = 64 * 1024;
|
|
209
|
+
const META_MAX_BYTES = 4 * 1024 * 1024; // give up rather than scan a giant file
|
|
210
|
+
|
|
211
|
+
export function readSessionMeta(file) {
|
|
212
|
+
const res = _readMetaOnce(file);
|
|
213
|
+
return res ? res.meta : null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Metadata for `file`, plus whether it holds any message. Reads the file head
|
|
217
|
+
// ONE time: latestSession needs both the timestamp (to sort) and the
|
|
218
|
+
// non-empty rule (for --continue's skipEmpty). Doing them in two passes —
|
|
219
|
+
// readSessionMeta then hasMessages — opened every candidate file twice.
|
|
220
|
+
// Returns { meta, hasMessages } or null.
|
|
221
|
+
function _readMetaOnce(file) {
|
|
222
|
+
let fd;
|
|
223
|
+
try { fd = fs.openSync(file, 'r'); } catch { return null; }
|
|
224
|
+
try {
|
|
225
|
+
const buf = Buffer.allocUnsafe(META_HEAD_BYTES);
|
|
226
|
+
let text = '';
|
|
227
|
+
let read = 0;
|
|
228
|
+
let cut = -1;
|
|
229
|
+
// Grow in chunks until the `"messages"` VALUE starts, or the cap is hit.
|
|
230
|
+
for (;;) {
|
|
231
|
+
const n = fs.readSync(fd, buf, 0, buf.length, read);
|
|
232
|
+
if (n <= 0) break;
|
|
233
|
+
text += buf.toString('utf8', 0, n);
|
|
234
|
+
read += n;
|
|
235
|
+
cut = findMessagesValueStart(text);
|
|
236
|
+
if (cut >= 0 || read >= META_MAX_BYTES) break;
|
|
237
|
+
}
|
|
238
|
+
// Non-empty check from the SAME buffered head: true when a real "role" line
|
|
239
|
+
// follows the messages-array opening. Mirrors hasMessages().
|
|
240
|
+
const nonEmpty = !!(cut >= 0 && /"role"/.test(text.slice(cut)));
|
|
241
|
+
// Take everything BEFORE the messages array (which is the metadata block),
|
|
242
|
+
// then close the truncated JSON object.
|
|
243
|
+
const head = cut > 0 ? text.slice(0, cut) : text;
|
|
47
244
|
try {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
245
|
+
// `head` ends right after `"messages":` (no value), so append `[]}` to
|
|
246
|
+
// close it cleanly. Also handle the case where the array value already
|
|
247
|
+
// started but the head stops elsewhere.
|
|
248
|
+
const json = head.replace(/,\s*$/, '').replace(/"messages"\s*:\s*$/, '"messages": []')
|
|
249
|
+
+ (head.trimEnd().endsWith('{') || head.trimEnd().endsWith(',') ? '}' : '}');
|
|
250
|
+
const parsed = JSON.parse(json);
|
|
251
|
+
delete parsed.messages;
|
|
252
|
+
return { meta: parsed, hasMessages: nonEmpty };
|
|
253
|
+
} catch {
|
|
254
|
+
// Fallback: parse as much of the file as we have, drop messages.
|
|
255
|
+
try {
|
|
256
|
+
const m = JSON.parse(text.slice(0, text.lastIndexOf('}') + 1));
|
|
257
|
+
delete m.messages;
|
|
258
|
+
return { meta: m, hasMessages: nonEmpty };
|
|
259
|
+
} catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
} catch {
|
|
264
|
+
return null;
|
|
265
|
+
} finally {
|
|
266
|
+
try { fs.closeSync(fd); } catch {}
|
|
54
267
|
}
|
|
55
|
-
if (opts.skipEmpty) return bestEmpty || best;
|
|
56
|
-
return best;
|
|
57
268
|
}
|
|
58
269
|
|
|
270
|
+
// Locate the index of the character right after `"messages"` (whitespace and
|
|
271
|
+
// the `:` included) so the metadata head can be closed with `"messages": []}`.
|
|
272
|
+
// Returns -1 when the key is not present in `text`.
|
|
273
|
+
function findMessagesValueStart(text) {
|
|
274
|
+
const key = text.indexOf('"messages"');
|
|
275
|
+
if (key < 0) return -1;
|
|
276
|
+
let i = key + '"messages"'.length;
|
|
277
|
+
while (i < text.length && /\s/.test(text[i])) i++;
|
|
278
|
+
if (text[i] === ':') i++;
|
|
279
|
+
while (i < text.length && /\s/.test(text[i])) i++;
|
|
280
|
+
return i; // may point at `[` (or be end-of-text; caller handles both)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Non-empty detection now lives in _readMetaOnce (it reads the file head once
|
|
284
|
+
// for both the metadata and the emptiness rule), so the old separate
|
|
285
|
+
// hasMessages() scan is gone — latestSession no longer reopens each candidate.
|
|
286
|
+
|
|
287
|
+
// The session list for the /sessions picker. Metadata only — see readSessionMeta
|
|
288
|
+
// for why. Entries carry no `messages`, which every caller already treats as
|
|
289
|
+
// "not loaded yet".
|
|
59
290
|
export function listSessions(storeDir) {
|
|
60
|
-
const d = dir(storeDir);
|
|
61
|
-
let files;
|
|
62
|
-
try { files = fs.readdirSync(d).filter((f) => f.endsWith('.json')); } catch { return []; }
|
|
63
291
|
const out = [];
|
|
64
|
-
for (const
|
|
65
|
-
|
|
292
|
+
for (const { meta } of scanCached(storeDir)) {
|
|
293
|
+
// FIX: fall back to createdAt so a legacy session written with a
|
|
294
|
+
// post-messages updatedAt still sorts reasonably instead of as 0.
|
|
295
|
+
if (!meta.updatedAt && meta.createdAt) meta.updatedAt = meta.createdAt;
|
|
296
|
+
out.push(meta);
|
|
66
297
|
}
|
|
67
298
|
out.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
|
|
68
299
|
return out;
|
|
69
300
|
}
|
|
70
301
|
|
|
71
302
|
export function deleteSession(id, storeDir) {
|
|
303
|
+
// Drop the cached entry if present (before unlink, so the path is known).
|
|
304
|
+
try { cacheFor(storeDir, false)?.delete(sessionFile(id, storeDir)); } catch {}
|
|
72
305
|
try { fs.unlinkSync(sessionFile(id, storeDir)); return true; } catch { return false; }
|
|
73
306
|
}
|
package/src/tools/agent-swarm.js
CHANGED
|
@@ -26,7 +26,12 @@ const SWARM_CONCURRENCY = 4;
|
|
|
26
26
|
export function planSwarm(args) {
|
|
27
27
|
const items = Array.isArray(args.items) ? args.items.map((s) => String(s).trim()).filter(Boolean) : [];
|
|
28
28
|
const template = args.prompt_template == null ? '' : String(args.prompt_template);
|
|
29
|
-
|
|
29
|
+
// `typeof [] === 'object'`, so an array was previously accepted and turned
|
|
30
|
+
// into [['0', ...], ['1', ...]] — the swarm then tried to resume agents
|
|
31
|
+
// named "0"/"1". Require a plain object.
|
|
32
|
+
const resumeMap = (args.resume_agent_ids
|
|
33
|
+
&& typeof args.resume_agent_ids === 'object'
|
|
34
|
+
&& !Array.isArray(args.resume_agent_ids))
|
|
30
35
|
? Object.entries(args.resume_agent_ids).map(([id, p]) => [String(id).trim(), String(p).trim()])
|
|
31
36
|
: [];
|
|
32
37
|
|
package/src/tools/agent.js
CHANGED
|
@@ -112,7 +112,14 @@ async function runSubagent({ cfg, type, prompt, history, signal, onEvent, onProg
|
|
|
112
112
|
// content stream instead of the separate reasoning channel.
|
|
113
113
|
// So: take the LAST assistant message (the handoff), and strip any think block
|
|
114
114
|
// from it. Fall back to the filtered stream text when there is no message.
|
|
115
|
-
|
|
115
|
+
// Strip whichever spelling the model used. `\b` alone is wrong here: `k` and `i`
|
|
116
|
+
// are both word characters, so `<think\b` does NOT match `<thinking>` — a model
|
|
117
|
+
// using the long form had its whole reasoning block leak into the handoff. Enumerate
|
|
118
|
+
// the variants instead, matching llm.js's tag table.
|
|
119
|
+
const stripThink = (s) => String(s)
|
|
120
|
+
.replace(/<(?:think|thinking)\b[^>]*>[\s\S]*?<\/(?:think|thinking)>/gi, '')
|
|
121
|
+
.replace(/<think(?:ing)?\b[^>]*>[\s\S]*$/i, '') // an unclosed block: drop to the end
|
|
122
|
+
.trim();
|
|
116
123
|
const lastAssistant = [...agent.messages].reverse()
|
|
117
124
|
.find((m) => m.role === 'assistant' && typeof m.content === 'string' && stripThink(m.content));
|
|
118
125
|
const result = (lastAssistant && stripThink(lastAssistant.content)) || stripThink(text) || '(no output)';
|
package/src/tools/bash.js
CHANGED
|
@@ -53,7 +53,8 @@ function decodeChunks(chunks) {
|
|
|
53
53
|
|
|
54
54
|
function killTree(child) {
|
|
55
55
|
try {
|
|
56
|
-
|
|
56
|
+
const exit = child.exitCode !== null || child.signalCode !== null;
|
|
57
|
+
if (exit || child.killed) return;
|
|
57
58
|
if (process.platform === 'win32') {
|
|
58
59
|
try { cp.spawnSync('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore' }); } catch {}
|
|
59
60
|
} else {
|
|
@@ -215,6 +216,11 @@ function registerBackgroundTask(args, child, ctx, existingChunks) {
|
|
|
215
216
|
|
|
216
217
|
function spawnBackground(args, cwd, ctx) {
|
|
217
218
|
const command = args.command;
|
|
219
|
+
// Detached + piped stdio is not a real detach: when the parent exits, the
|
|
220
|
+
// pipes close and the child gets SIGPIPE. Redirect stdout/stderr to the
|
|
221
|
+
// task's output file instead, so the process survives and TaskOutput can
|
|
222
|
+
// still read it. (In the TUI the parent rarely exits, but headless -p mode
|
|
223
|
+
// does, and a background job should outlive the prompt.)
|
|
218
224
|
const child = cp.spawn('pwsh', ['-Command', command], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
219
225
|
const task = createTask(ctx, 'process', {
|
|
220
226
|
description: args.description || args.command,
|
package/src/tools/edit.js
CHANGED
|
@@ -6,14 +6,14 @@ import fs from 'node:fs';
|
|
|
6
6
|
import { resolvePath, ensureDir } from './utils.js';
|
|
7
7
|
import { hashStr, normalizeText } from './read.js';
|
|
8
8
|
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
9
|
+
// Re-exported from read.js so the staleness guard has ONE definition of the
|
|
10
|
+
// region hash. Previously read.js and edit.js each had their own copy; any
|
|
11
|
+
// drift between them would silently disable staleness detection.
|
|
12
|
+
import { hashRegion as hashLines } from './read.js';
|
|
13
13
|
|
|
14
14
|
export const spec = {
|
|
15
15
|
name: 'Edit',
|
|
16
|
-
|
|
16
|
+
description: 'Edit a file. PREFERRED: line-range mode (path + start_line/end_line/new_content) — replace by line numbers when you have Read the file and know the positions. Use line-range mode whenever possible; fall back to exact-substring mode (path + old_string/new_string) only when the exact text is known and a line range is not convenient.',
|
|
17
17
|
parameters: {
|
|
18
18
|
type: 'object',
|
|
19
19
|
properties: {
|
|
@@ -21,14 +21,16 @@ export const spec = {
|
|
|
21
21
|
old_string: { type: 'string', description: 'Exact text to replace, including whitespace and newlines. Omit when using start_line/end_line.' },
|
|
22
22
|
new_string: { type: 'string', description: 'Replacement text for old_string.' },
|
|
23
23
|
replace_all: { type: 'boolean', default: false, description: 'Replace all occurrences of old_string.' },
|
|
24
|
-
|
|
25
|
-
end_line: { type: 'integer', minimum: 1, description: '1-based last line to replace (inclusive; requires start_line).' },
|
|
26
|
-
new_content: { type: 'string', description: 'Replacement text for the line range [start_line, end_line]. Requires start_line and end_line.' },
|
|
24
|
+
start_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based first line to replace (requires end_line and new_content). Use this line-range mode first.' },
|
|
25
|
+
end_line: { type: 'integer', minimum: 1, description: 'PREFERRED MODE. 1-based last line to replace (inclusive; requires start_line).' },
|
|
26
|
+
new_content: { type: 'string', description: 'PREFERRED MODE. Replacement text for the line range [start_line, end_line]. Requires start_line and end_line.' },
|
|
27
27
|
},
|
|
28
|
-
|
|
28
|
+
// Either line-range mode (start_line+end_line+new_content) OR exact-substring
|
|
29
|
+
// mode (old_string+new_string). Line-range is the preferred route — see the
|
|
30
|
+
// tool description and the PREFERRED MODE notes on the params.
|
|
29
31
|
anyOf: [
|
|
30
|
-
{ required: ['path', 'old_string', 'new_string'] },
|
|
31
32
|
{ required: ['path', 'start_line', 'end_line', 'new_content'] },
|
|
33
|
+
{ required: ['path', 'old_string', 'new_string'] },
|
|
32
34
|
],
|
|
33
35
|
},
|
|
34
36
|
async execute(args, ctx) {
|
|
@@ -165,8 +167,6 @@ export const spec = {
|
|
|
165
167
|
},
|
|
166
168
|
};
|
|
167
169
|
|
|
168
|
-
// Returns an error message when the edit is stale, else null (safe to edit).
|
|
169
|
-
|
|
170
170
|
// Returns an error message when the edit is stale, else null (safe to edit).
|
|
171
171
|
function checkStale(p, normContent, normOld, ctx) {
|
|
172
172
|
if (!ctx || !ctx.readPool) return null; // no Read happened; nothing to verify
|
package/src/tools/file-lines.js
CHANGED
|
@@ -24,13 +24,46 @@ export const spec = {
|
|
|
24
24
|
const stat = fs.statSync(p);
|
|
25
25
|
if (!stat.isFile()) return `Error: not a file: ${args.path}`;
|
|
26
26
|
|
|
27
|
-
// Count LINES only — never the content.
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
27
|
+
// Count LINES only — never the content. Read the file in bounded chunks and
|
|
28
|
+
// count newlines, so a huge file is not pulled wholesale into memory just to
|
|
29
|
+
// answer "how many lines". Any of \r\n, \r, or \n counts as one line break,
|
|
30
|
+
// matching the newline forms Read normalizes.
|
|
31
|
+
let fd;
|
|
32
|
+
try { fd = fs.openSync(p, 'r'); } catch { return `Error: cannot open ${args.path}`; }
|
|
33
|
+
const CHUNK = 256 * 1024;
|
|
34
|
+
const buf = Buffer.allocUnsafe(CHUNK);
|
|
35
|
+
let lineBreaks = 0; // count of \r\n, \r, or \n sequences
|
|
36
|
+
let prevCR = false;
|
|
37
|
+
let lastWasBreak = false; // whether the last byte seen was a line break
|
|
38
|
+
let read = 0;
|
|
39
|
+
try {
|
|
40
|
+
for (;;) {
|
|
41
|
+
const n = fs.readSync(fd, buf, 0, CHUNK, read);
|
|
42
|
+
if (n <= 0) break;
|
|
43
|
+
for (let i = 0; i < n; i++) {
|
|
44
|
+
const b = buf[i];
|
|
45
|
+
if (b === 0x0a) { // \n
|
|
46
|
+
if (!prevCR) lineBreaks++;
|
|
47
|
+
prevCR = false;
|
|
48
|
+
lastWasBreak = true;
|
|
49
|
+
} else if (b === 0x0d) { // \r
|
|
50
|
+
lineBreaks++;
|
|
51
|
+
prevCR = true;
|
|
52
|
+
lastWasBreak = true;
|
|
53
|
+
} else {
|
|
54
|
+
prevCR = false;
|
|
55
|
+
lastWasBreak = false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (n < CHUNK) break;
|
|
59
|
+
read += n;
|
|
60
|
+
}
|
|
61
|
+
} finally {
|
|
62
|
+
try { fs.closeSync(fd); } catch {}
|
|
63
|
+
}
|
|
64
|
+
// "a\nb\nc" is 3 lines; "a\nb\nc\n" is also 3 (the trailing \n terminates
|
|
65
|
+
// the last line, it does not open a new one). So drop the final break.
|
|
66
|
+
const lines = 1 + lineBreaks - (lastWasBreak ? 1 : 0);
|
|
67
|
+
return `Lines: ${Math.max(1, lines)}`;
|
|
35
68
|
},
|
|
36
69
|
};
|
package/src/tools/glob.js
CHANGED
|
@@ -6,7 +6,7 @@ import fs from 'node:fs';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { normalizeInput } from './utils.js';
|
|
8
8
|
import { walkFiles } from './ignore.js';
|
|
9
|
-
import {
|
|
9
|
+
import { globToRegexSource, expandBraces } from './matchers.js';
|
|
10
10
|
|
|
11
11
|
export const spec = {
|
|
12
12
|
name: 'Glob',
|
|
@@ -35,11 +35,16 @@ export const spec = {
|
|
|
35
35
|
if (!fs.existsSync(base)) return `Error: path does not exist: ${fromDir}`;
|
|
36
36
|
if (!fs.statSync(base).isDirectory()) return `Error: --path is not a directory: ${fromDir}`;
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
const alts = expandBraces(args.pattern);
|
|
39
|
+
// Each alt is already anchored (globToRegexSource includes ^ and $), so
|
|
40
|
+
// join them into one anchored alternation. The previous code inferred the
|
|
41
|
+
// anchors by slicing the first/last char off globRegex(), which was fragile:
|
|
42
|
+
// a brace pattern has per-alt anchors that slicing stripped into the wrong
|
|
43
|
+
// shape.
|
|
44
|
+
const re = new RegExp('^(?:' + alts.map((a) => globToRegexSource(a)).join('|') + ')$');
|
|
40
45
|
|
|
41
46
|
|
|
42
|
-
const files = walkFiles(base, { includeIgnored: !!args.include_ignored, includeDirs:
|
|
47
|
+
const files = walkFiles(base, { includeIgnored: !!args.include_ignored, includeDirs: !!args.include_dirs });
|
|
43
48
|
const matches = [];
|
|
44
49
|
for (const abs of files) {
|
|
45
50
|
const rel = path.relative(base, abs).split(path.sep).join('/');
|
package/src/tools/grep.js
CHANGED
|
@@ -42,8 +42,14 @@ export const spec = {
|
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
function hasRg() {
|
|
45
|
-
try {
|
|
46
|
-
|
|
45
|
+
try {
|
|
46
|
+
// spawnSync does NOT throw when the binary is missing — it returns
|
|
47
|
+
// { error: ENOENT, status: null }. The old try/catch therefore always
|
|
48
|
+
// reported "rg available", and grepRg() then returned an empty stdout as
|
|
49
|
+
// "No matches found" instead of falling back to the JS walker.
|
|
50
|
+
const r = cp.spawnSync('rg', ['--version'], { stdio: 'ignore' });
|
|
51
|
+
return !r.error && r.status === 0;
|
|
52
|
+
} catch { return false; }
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
function buildRgArgs(args, base) {
|
|
@@ -70,10 +76,13 @@ function grepRg(args, base, ctx) {
|
|
|
70
76
|
maxBuffer: 64 * 1024 * 1024,
|
|
71
77
|
encoding: 'utf8',
|
|
72
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);
|
|
73
82
|
out = r.stdout || '';
|
|
74
83
|
if (r.status > 1 && r.stderr) return `rg error: ${r.stderr.trim()}`;
|
|
75
84
|
} catch (e) {
|
|
76
|
-
return
|
|
85
|
+
return grepJs(args, base, ctx);
|
|
77
86
|
}
|
|
78
87
|
return paginate(out, args);
|
|
79
88
|
}
|
|
@@ -118,6 +127,10 @@ function grepJs(args, base, ctx) {
|
|
|
118
127
|
const lines = txt.split('\n');
|
|
119
128
|
let count = 0;
|
|
120
129
|
for (let i = 0; i < lines.length; i++) {
|
|
130
|
+
// `re` carries the `g` flag, so RegExp.test() advances lastIndex and
|
|
131
|
+
// the NEXT call starts mid-string — every other matching line was
|
|
132
|
+
// skipped. Reset lastIndex before each test.
|
|
133
|
+
re.lastIndex = 0;
|
|
121
134
|
if (re.test(lines[i])) {
|
|
122
135
|
count++;
|
|
123
136
|
if (mode === 'files_with_matches') { out.push(abs); break; }
|
package/src/tools/index.js
CHANGED
|
@@ -50,8 +50,19 @@ export function combinedTools() {
|
|
|
50
50
|
export const tools = builtinTools;
|
|
51
51
|
export { builtinTools };
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
// NOTE: the map is built lazily over combinedTools() so plugin-registered
|
|
54
|
+
// tools are resolvable. The previous Map was built from builtinTools only,
|
|
55
|
+
// so `llmTools()` advertised a plugin tool to the model but `getTool(name)`
|
|
56
|
+
// returned undefined and the call failed with `unknown tool`.
|
|
57
|
+
let _byName = null;
|
|
58
|
+
function byNameMap() {
|
|
59
|
+
if (!_byName) {
|
|
60
|
+
_byName = new Map();
|
|
61
|
+
for (const t of combinedTools()) if (!_byName.has(t.name)) _byName.set(t.name, t);
|
|
62
|
+
}
|
|
63
|
+
return _byName;
|
|
64
|
+
}
|
|
65
|
+
export function getTool(name) { return byNameMap().get(name); }
|
|
55
66
|
export function toolNames() { return combinedTools().map((t) => t.name); }
|
|
56
67
|
|
|
57
68
|
// Schemas for sending to an LLM (OpenAI `tools` array).
|