@hncode/hncode 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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
- fs.mkdirSync(dir(storeDir), { recursive: true });
25
- const tmp = sessionFile(session.id, storeDir) + '.tmp';
26
- fs.writeFileSync(tmp, JSON.stringify(session, null, 2), 'utf8');
27
- fs.renameSync(tmp, sessionFile(session.id, storeDir));
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
- try { return JSON.parse(fs.readFileSync(sessionFile(id, storeDir), 'utf8')); }
33
- catch { return null; }
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 d = dir(storeDir);
42
- let files;
43
- try { files = fs.readdirSync(d).filter((f) => f.endsWith('.json')); } catch { return null; }
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
- for (const f of files) {
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
- const s = JSON.parse(fs.readFileSync(path.join(d, f), 'utf8'));
49
- if (cwd !== undefined && s.workspace !== path.resolve(cwd)) continue;
50
- if (!best || s.updatedAt > best.updatedAt) best = s;
51
- const hasMsgs = Array.isArray(s.messages) && s.messages.length > 0;
52
- if (hasMsgs && (!bestEmpty || s.updatedAt > bestEmpty.updatedAt)) bestEmpty = s;
53
- } catch {}
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 f of files) {
65
- try { out.push(JSON.parse(fs.readFileSync(path.join(d, f), 'utf8'))); } catch {}
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
  }
@@ -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
- const resumeMap = (args.resume_agent_ids && typeof args.resume_agent_ids === 'object')
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
 
@@ -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
- const stripThink = (s) => String(s).replace(/<think\b[^>]*>[\s\S]*?<\/think>/gi, '').trim();
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
- if (child.killed) return;
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
- // Hash the <start..end> 1-based line slice of an already-normalized line array.
10
- function hashLines(lines, start, end) {
11
- return hashStr((lines.slice(start - 1, end) || []).join('\n'));
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
- description: 'Replace an exact substring (old_string) with new_string. Fails if old_string is missing or ambiguous (unless replace_all).',
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
- start_line: { type: 'integer', minimum: 1, description: '1-based first line to replace (requires end_line and new_content).' },
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
- // Either exact-substring mode (old_string+new_string) OR line-range mode.
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
@@ -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. Splitting on the same newline forms
28
- // Read normalizes keeps the count consistent with Read's own total.
29
- const lines = fs.readFileSync(p, 'utf8').split(/\r\n|\r|\n/);
30
- // A file ending in a newline splits with a trailing '' element; that is the
31
- // terminator of the previous line, not a line of its own.
32
- if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
33
-
34
- return `Lines: ${lines.length}`;
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 { globRegex, expandBraces } from './matchers.js';
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
- const alts = expandBraces(args.pattern);
39
- const re = new RegExp('^(?:' + alts.map((a) => globRegex(a).source.slice(1, -1)).join('|') + ')$');
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: false });
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 { cp.spawnSync('rg', ['--version'], { stdio: 'ignore' }); return true; }
46
- catch { return false; }
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 `rg unavailable: ${e.message}; falling back`;
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; }
@@ -50,8 +50,19 @@ export function combinedTools() {
50
50
  export const tools = builtinTools;
51
51
  export { builtinTools };
52
52
 
53
- const byName = new Map(builtinTools.map((t) => [t.name, t]));
54
- export function getTool(name) { return byName.get(name); }
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).