ineedcodes 1.5.1 → 1.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ineedcodes",
3
- "version": "1.5.1",
3
+ "version": "1.6.1",
4
4
  "description": "Your terminal, now autonomous. Just say what you want, ineed does the rest.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/agent.js CHANGED
@@ -29,8 +29,11 @@ const SYSTEM = `You are ineed, an autonomous terminal agent on the user's machin
29
29
  Rules:
30
30
  - Use the tools to do real work. Never invent output. Every success claim needs evidence from a tool result.
31
31
  - Prefer targeted edits (edit_file) over full rewrites (write_file). Work only inside the current folder.
32
+ - Scope discipline: never explore outside the working folder (no listing the home directory, no scanning drives, no cloning repos) unless the user explicitly names those paths in the current objective. If the task needs it, ask first.
33
+ - Prefer answering from what you already know: questions like "udah?", "done?", or status checks get a direct answer from the conversation. Only call tools when new facts are genuinely needed.
32
34
  - Never push to remotes or delete data without being asked.
33
35
  - Destructive commands are always blocked. Ask the user to run those themselves.
36
+ - Your replies go straight to a terminal: never use markdown formatting (no **bold**, no ## headers, no tables, no emojis as decoration). Plain sentences and simple "- " bullets only.
34
37
  - Explain to match the user's depth preference (short: results only; normal: what changed and why; deep: also the reasoning and trade-offs).
35
38
  - Suggest "boost" (isolated git-worktree run) when a task involves major refactoring, repeated failed fixes, or architecture changes, by telling the user to run /boost. Do not start it yourself.
36
39
  - Some actions need user approval. A tool result starting with "Denied" means the user said no: do not retry the same call, explain what you wanted instead.
@@ -121,7 +124,7 @@ const SPAWN_TOOL = {
121
124
  };
122
125
 
123
126
  export async function runObjective(cfg, objective, cwd, history, hooks = {}, extra = {}) {
124
- const ctrl = new AbortController();
127
+ let ctrl = new AbortController();
125
128
  hooks.onRunStart?.(ctrl);
126
129
  const plan = cfg.mode === 'plan';
127
130
  const depth = extra.depth ?? 0;
@@ -188,19 +191,27 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
188
191
  const usage = { input: 0, output: 0 };
189
192
  let answer = '';
190
193
  let lastShown = '';
194
+ // notes typed mid-task, injected at safe points; a steer can also interrupt an in-flight call
195
+ const drainSteerInto = () => {
196
+ const steer = hooks.drainSteer?.() ?? [];
197
+ for (const s of steer) {
198
+ messages.push({ role: 'user', content: `[steer from the user, newer than the objective] ${s}` });
199
+ }
200
+ if (steer.length) hooks.onSteer?.(steer);
201
+ return steer;
202
+ };
203
+ let steerRestarts = 0;
191
204
  try {
192
205
  for (let step = 0; step < MAX_STEPS; step++) {
193
- if (ctrl.signal.aborted) break;
194
- // steering: notes typed while the task runs join the conversation here
195
- if (hooks.drainSteer) {
196
- const steer = hooks.drainSteer();
197
- if (steer.length) {
198
- for (const s of steer) {
199
- messages.push({ role: 'user', content: `[steer from the user, newer than the objective] ${s}` });
200
- }
201
- hooks.onSteer?.(steer);
202
- }
206
+ // a steer abort must not kill the task: restart the call with the note included
207
+ if (ctrl.signal.aborted && ctrl.signal.reason === 'steer') {
208
+ drainSteerInto();
209
+ hooks.onNote?.('applying your steer, restarting the call');
210
+ ctrl = new AbortController();
211
+ hooks.onRunStart?.(ctrl);
203
212
  }
213
+ if (ctrl.signal.aborted) break;
214
+ drainSteerInto();
204
215
  let msg;
205
216
  try {
206
217
  hooks.onThinkingStart?.();
@@ -208,6 +219,16 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
208
219
  hooks.onThinkingEnd?.();
209
220
  } catch (err) {
210
221
  hooks.onThinkingEnd?.();
222
+ const steerInterrupt = ctrl.signal.aborted && (ctrl.signal.reason === 'steer' || err.reason === 'steer');
223
+ if (steerInterrupt && steerRestarts < 20) {
224
+ steerRestarts++;
225
+ drainSteerInto();
226
+ hooks.onNote?.('applying your steer, restarting the call');
227
+ ctrl = new AbortController();
228
+ hooks.onRunStart?.(ctrl);
229
+ step--; // redo this step with the steer included
230
+ continue;
231
+ }
211
232
  if (ctrl.signal.aborted) break;
212
233
  throw err;
213
234
  }
@@ -253,11 +274,16 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
253
274
  }
254
275
 
255
276
  for (const call of calls) {
277
+ // a stop must cut through: never execute queued tools after an abort
278
+ if (ctrl.signal.aborted) break;
256
279
  let input = {};
257
280
  try { input = JSON.parse(call.function?.arguments || '{}'); } catch {}
258
281
  hooks.onTool?.(call.function?.name, input);
259
282
  let result;
260
- if (spawnResults.has(call.id)) {
283
+ // enforce the role's tool allowlist at execution time, not just listing time
284
+ if (extra.toolFilter && !extra.toolFilter.includes(call.function?.name)) {
285
+ result = { output: `Refused: your role is not allowed to use ${call.function?.name}. Report what you need instead.` };
286
+ } else if (spawnResults.has(call.id)) {
261
287
  result = { output: workerResultText(spawnResults.get(call.id)) };
262
288
  } else if (call.function?.name === 'spawn_agent') {
263
289
  result = { output: 'Refused: workers cannot spawn more agents.' };
@@ -334,7 +360,7 @@ export async function runObjective(cfg, objective, cwd, history, hooks = {}, ext
334
360
  result = { output: `Todo list updated (${todos.filter(t => t.status === 'completed').length}/${todos.length} done).` };
335
361
  } else {
336
362
  const name = call.function?.name;
337
- const isEdit = ['write_file', 'edit_file', 'delete_file'].includes(name);
363
+ const isEdit = ['write_file', 'edit_file', 'delete_file', 'copy_file', 'move_file'].includes(name);
338
364
  let allowedNow = true;
339
365
  if (isEdit && cfg.permEdit !== 'allow' && !hooks.approved?.has('edit')) {
340
366
  const verdict = canAsk ? await hooks.onApprove('edit', name, input) : true; // cannot ask: CI-style allow
package/src/provider.js CHANGED
@@ -11,7 +11,13 @@ async function request(url, opts, signal) {
11
11
  try {
12
12
  return await fetch(url, { ...opts, signal: ctrl.signal });
13
13
  } catch (err) {
14
- if (signal?.aborted) { const e = new Error('stopped by user'); e.stopped = true; throw e; }
14
+ if (signal?.aborted) {
15
+ // signal.reason can be a string (abort('steer')) that undici rethrows as-is
16
+ const e = new Error('stopped by user');
17
+ e.stopped = true;
18
+ e.reason = signal.reason;
19
+ throw e;
20
+ }
15
21
  if (timedOut) throw new Error('request timed out after 120s');
16
22
  throw new Error(`cannot reach ${url}: ${err.message}`);
17
23
  } finally {
package/src/session.js CHANGED
@@ -85,6 +85,7 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
85
85
  let sessionId = null;
86
86
  let lastBoost = null;
87
87
  let usage = { input: 0, output: 0 };
88
+ var tuiReady = false;
88
89
 
89
90
  // ONE readline, ONE line dispatcher for the whole session
90
91
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -131,6 +132,7 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
131
132
  let streamFlushTimer = null;
132
133
  let streamFlushedCount = 0;
133
134
  let streamBaseLines = null;
135
+ const chatLines = [];
134
136
 
135
137
  function flushStreamed() {
136
138
  if (!TUI || !lastStreamedForHooks) return;
@@ -170,7 +172,7 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
170
172
  onAgentEnd: (id, r) => { stop(); say((r.status === 'completed' ? green(' ◆ ' + id + ' done') : yellow(' ◆ ' + id + ' ' + r.status)) + gray(' ' + trunc(String(r.summary ?? '').replaceAll('\n', ' '), 90))); },
171
173
  onMCP: names => { if (names.length) say(dim(' MCP tools available: ' + names.join(', '))); },
172
174
  onMCPResult: (name, out) => { say(gray(' mcp result: ' + trunc(out, 100))); },
173
- onNote: note => { stop(); say(dim(' ◇ ' + note)); },
175
+ onNote: note => { stop(); if (note.includes('applying your steer')) { lastStreamedForHooks = ''; streamFlushedCount = 0; streamBaseLines = null; if (typeof tuiReady !== 'undefined' && tuiReady) { chatLines.length = 0; redrawChat(); } } say(dim(' ◇ ' + note)); },
174
176
  onUsage: u => { usage = u; if (TUI) drawStatus(); },
175
177
  drainSteer: () => steerQueue.splice(0),
176
178
  onSteer: list => { for (const s of list) say(yellow(' ↳ steer: ') + s); },
@@ -262,7 +264,8 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
262
264
  say(' ' + cyan('/status') + ' everything about this session at a glance');
263
265
  say(' ' + cyan('/compact') + ' shrink the conversation into a checkpoint');
264
266
  say(' ' + cyan('/depth') + ' answer depth: /depth short|normal|deep');
265
- say(' ' + cyan('/resume') + ' bring back a saved conversation');
267
+ say(' ' + cyan('/new') + ' start a fresh session, keep the old saved');
268
+ say(' ' + cyan('/resume') + ' list sessions, /resume <code> like 1425-0609');
266
269
  say(' ' + cyan('/skills') + ' list installed skills, /skills <name> shows one');
267
270
  say(' ' + cyan('/mcp') + ' list MCP servers and their tools');
268
271
  say(' ' + cyan('/humanizer') + ' natural-writing pass for pages and posts (on/off)');
@@ -292,9 +295,12 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
292
295
  async function handle(input) {
293
296
  if (!input) return;
294
297
  if (busy) {
295
- if (input === '/stop') { activeRun?.abort(); say(dim(' (stopping...')); return; }
298
+ if (input === '/stop' || input.startsWith('/stop ') || input === 'stop') { activeRun?.abort(); say(dim(' (stopping...')); return; }
296
299
  if (input.startsWith('/')) { pendingLines.push(input); return; }
297
300
  steerQueue.push(input);
301
+ // interrupt the in-flight provider call so the steer applies immediately
302
+ if (activeRun) activeRun.abort('steer');
303
+ say(dim(' ↳ steer noted, applying it now...'));
298
304
  return;
299
305
  }
300
306
  if (['/exit', '/quit', 'exit', 'quit'].includes(input)) return doExit();
@@ -481,22 +487,43 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
481
487
  say(all.length ? all.map(s => ` ${cyan(s.name)} ${dim('(' + s.scope + ')')} ${s.description}`).join('\n') : yellow('No skills installed.'));
482
488
  return;
483
489
  }
484
- if (input === '/resume') {
490
+ if (input === '/new') {
491
+ if (sessionId) saveSession({ id: sessionId, cwd: process.cwd(), model: state.model, history });
492
+ history = [];
493
+ sessionId = null;
494
+ say(green('New session started.') + dim(' The old one is saved, /resume brings it back.'));
495
+ if (TUI) { chatLines.length = 0; redrawChat(); drawStatus(); }
496
+ return;
497
+ }
498
+ if (input === '/resume' || input.startsWith('/resume ')) {
499
+ const codeArg = input === '/resume' ? '' : input.slice(8).trim();
485
500
  busy = true;
486
501
  const list = listSessions();
487
502
  if (!list.length) { say(yellow('No saved sessions yet.')); busy = false; return afterTask(); }
488
- list.slice(0, 5).forEach((s, i) => {
503
+ if (codeArg) {
504
+ const s = loadSession(codeArg);
505
+ if (s?.history?.length) {
506
+ history = s.history;
507
+ sessionId = s.id;
508
+ renderHistory(s.history);
509
+ say(green(`Resumed ${s.id} (${Math.floor(s.history.length / 2)} turns). Continue where we left off.`));
510
+ } else say(red(`No session with code ${codeArg}. Check /resume for the list of codes.`));
511
+ busy = false;
512
+ return afterTask();
513
+ }
514
+ list.slice(0, 8).forEach((s, i) => {
489
515
  const first = String(s.history?.find(m => m.role === 'user')?.content ?? '').replaceAll('\n', ' ').slice(0, 70);
490
- say(` ${i + 1}. ${new Date(s.time).toLocaleString()} · ${Math.floor((s.history?.length ?? 0) / 2)} turns · ${first}`);
516
+ say(` ${s.id} · ${Math.floor((s.history?.length ?? 0) / 2)} turns · ${first}`);
491
517
  });
492
- const pick = await ask(' Resume which? [1]: ');
493
- const n = Number(pick) || 1;
494
- const s = loadSession(list[n - 1]?.id);
518
+ const pick = await ask(' Resume which? (code, e.g. 1425-0609, empty = newest): ');
519
+ const s = pick ? loadSession(pick.trim()) : list[0];
495
520
  if (s?.history?.length) {
496
521
  history = s.history;
497
522
  sessionId = s.id;
498
- say(green(`Resumed ${Math.floor(s.history.length / 2)} turns. Continue where we left off.`));
499
- } else say(red('Could not load that session.'));
523
+ renderHistory(s.history);
524
+ say(green(`Resumed ${s.id} (${Math.floor(s.history.length / 2)} turns). Continue where we left off.`));
525
+ if (TUI) redrawChat();
526
+ } else say(red(`No session with code ${pick}. Check the list above.`));
500
527
  busy = false;
501
528
  return afterTask();
502
529
  }
@@ -584,7 +611,6 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
584
611
  }
585
612
 
586
613
  // ── full TUI mode ──
587
- const chatLines = []; // completed chat lines (ANSI strings)
588
614
  let chatTop = 0, chatBot = 0; // scroll region rows
589
615
  let statusRow = 0;
590
616
 
@@ -621,14 +647,14 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
621
647
  }
622
648
 
623
649
  function redrawChat() {
624
- for (let i = chatTop; i <= chatBot; i++) {
625
- screen.at(i, 1);
626
- screen.clearLine();
627
- }
628
- screen.at(chatTop, 1);
650
+ // build the whole frame in one buffer, then paint once: no flicker
629
651
  const vis = chatBot - chatTop + 1;
630
652
  const show = chatLines.slice(-vis);
631
- process.stdout.write(show.join('\r\n'));
653
+ let buf = '';
654
+ for (let i = 0; i < vis; i++) {
655
+ buf += `\x1b[${chatTop + i};1H\x1b[2K` + (show[i] ?? '');
656
+ }
657
+ process.stdout.write(buf);
632
658
  scrollRegion();
633
659
  }
634
660
 
@@ -637,10 +663,29 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
637
663
  redrawChat();
638
664
  }
639
665
 
640
- const tuiUserLine = input => {
666
+ function renderHistory(h) {
667
+ if (!TUI) {
668
+ for (const m of h) {
669
+ const c = String(m.content ?? '').replaceAll('\n', ' ').slice(0, 90);
670
+ if (c) console.log(' ' + gray((m.role === 'user' ? 'you: ' : 'ineed: ') + c));
671
+ }
672
+ return;
673
+ }
674
+ if (!tuiReady) return;
675
+ setTimeout(() => {
676
+ for (const m of h) {
677
+ if (m.role === 'user') tuiUserLine(String(m.content ?? ''));
678
+ else if (m.content) tuiPrint(box([dim(' (earlier) ') + trunc(String(m.content).replaceAll('\n', ' '), 90)]));
679
+ }
680
+ drawStatus();
681
+ scrollRegion();
682
+ }, 0);
683
+ }
684
+
685
+ function tuiUserLine(input) {
641
686
  for (const l of wrapLines(userBubble(input), Math.max(10, (process.stdout.columns || 80) - 4))) chatLines.push(l);
642
687
  redrawChat();
643
- };
688
+ }
644
689
 
645
690
  const layout = () => {
646
691
  const rows = process.stdout.rows || 24;
@@ -658,6 +703,18 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
658
703
 
659
704
  rl.on('resize', layout);
660
705
 
706
+ // typed-ahead input while busy: echo it above the status bar so the user sees what they type
707
+ let typedAhead = '';
708
+ rl.on('keypress', (ch, key) => {
709
+ if (!busy || !key) return;
710
+ if (key.name === 'backspace') typedAhead = typedAhead.slice(0, -1);
711
+ else if (key.name === 'return') typedAhead = '';
712
+ else if (key.ctrl || !ch || ch < ' ') return;
713
+ else typedAhead += ch;
714
+ process.stdout.write(`\x1b[${statusRow - 1};1H\x1b[2K` + dim(' you: ') + typedAhead);
715
+ scrollRegion();
716
+ });
717
+
661
718
  // keep Ctrl+Z from suspending us in raw mode: swallow the key, tell the user
662
719
  const origWrite = rl.write.bind(rl);
663
720
  rl.write = (d, key) => {
package/src/sessions.js CHANGED
@@ -1,15 +1,22 @@
1
1
  // sessions.js: session persistence. Conversation checkpoints live outside the repo,
2
2
  // under the config dir, so users can leave and resume work (master prompt #34).
3
+ // IDs are HHMM-DDMM codes so /resume 1425-0609 just works.
3
4
 
4
5
  import * as fs from 'node:fs';
5
6
  import * as path from 'node:path';
6
7
  import { CONFIG_DIR as configDirPath } from './config.js';
8
+
7
9
  const DIR = path.join(configDirPath, 'sessions');
8
10
 
11
+ const pad = n => String(n).padStart(2, '0');
12
+
13
+ export function newSessionId(d = new Date()) {
14
+ return `${pad(d.getHours())}${pad(d.getMinutes())}-${pad(d.getDate())}${pad(d.getMonth() + 1)}`;
15
+ }
9
16
 
10
17
  export function saveSession(data) {
11
18
  fs.mkdirSync(DIR, { recursive: true });
12
- const id = data.id || 's_' + Date.now();
19
+ const id = data.id || newSessionId();
13
20
  fs.writeFileSync(path.join(DIR, id + '.json'), JSON.stringify({ ...data, id, time: Date.now() }, null, 2), { mode: 0o600 });
14
21
  return id;
15
22
  }
@@ -19,7 +26,7 @@ export function listSessions() {
19
26
  return fs.readdirSync(DIR)
20
27
  .filter(f => f.endsWith('.json'))
21
28
  .map(f => { try { return JSON.parse(fs.readFileSync(path.join(DIR, f), 'utf8')); } catch { return null; } })
22
- .filter(Boolean)
29
+ .filter(Boolean).filter(s => (s.history?.length ?? 0) > 0)
23
30
  .sort((a, b) => b.time - a.time)
24
31
  .slice(0, 20);
25
32
  } catch { return []; }
package/src/tools.js CHANGED
@@ -129,6 +129,48 @@ export const TOOLS = [
129
129
  allowedInPlan: true,
130
130
  web: true
131
131
  },
132
+ {
133
+ name: 'read_file_range',
134
+ description: 'Read part of a file by line numbers (1-based). For big files.',
135
+ parameters: { type: 'object', properties: { path: { type: 'string' }, offset: { type: 'number', description: 'first line, default 1' }, limit: { type: 'number', description: 'lines to read, default 200' } }, required: ['path'] },
136
+ allowedInPlan: true
137
+ },
138
+ {
139
+ name: 'file_exists',
140
+ description: 'Check whether a path exists.',
141
+ parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
142
+ allowedInPlan: true
143
+ },
144
+ {
145
+ name: 'file_metadata',
146
+ description: 'Size, modified time, and type of a file.',
147
+ parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
148
+ allowedInPlan: true
149
+ },
150
+ {
151
+ name: 'copy_file',
152
+ description: 'Copy a file to a new path.',
153
+ parameters: { type: 'object', properties: { path: { type: 'string' }, to: { type: 'string' } }, required: ['path', 'to'] },
154
+ allowedInPlan: false
155
+ },
156
+ {
157
+ name: 'move_file',
158
+ description: 'Move or rename a file.',
159
+ parameters: { type: 'object', properties: { path: { type: 'string' }, to: { type: 'string' } }, required: ['path', 'to'] },
160
+ allowedInPlan: false
161
+ },
162
+ {
163
+ name: 'search_files',
164
+ description: 'Find files whose NAME contains a string (recursive).',
165
+ parameters: { type: 'object', properties: { pattern: { type: 'string' }, path: { type: 'string' } }, required: ['pattern'] },
166
+ allowedInPlan: true
167
+ },
168
+ {
169
+ name: 'list_tracked_files',
170
+ description: 'List files tracked by git in this repository.',
171
+ parameters: { type: 'object', properties: {} },
172
+ allowedInPlan: true
173
+ },
132
174
  {
133
175
  name: 'shell',
134
176
  description: 'Run a shell command in the working directory. Returns exit code with stdout and stderr.',
@@ -208,6 +250,59 @@ export function runTool(name, input, cwd) {
208
250
  fs.unlinkSync(abs);
209
251
  return { output: `Deleted ${path.relative(cwd, abs)}.` };
210
252
  }
253
+ if (name === 'read_file_range') {
254
+ if (isSecret(abs)) return { output: 'Refused: that looks like a secret file, and secrets never enter the model context.' };
255
+ const all = fs.readFileSync(abs, 'utf8').split('\n');
256
+ const offset = Math.max(1, Number(input.offset ?? 1));
257
+ const limit = Math.max(1, Math.min(2_000, Number(input.limit ?? 200)));
258
+ const slice = all.slice(offset - 1, offset - 1 + limit);
259
+ const numbered = slice.map((l, i) => `${offset + i}: ${l}`).join('\n');
260
+ return { output: `${abs} lines ${offset}-${offset + slice.length - 1} of ${all.length}\n${numbered.slice(0, 60_000)}` };
261
+ }
262
+ if (name === 'file_exists') {
263
+ return { output: fs.existsSync(abs) ? `yes: ${path.relative(cwd, abs)}` : `no: ${path.relative(cwd, abs)}` };
264
+ }
265
+ if (name === 'file_metadata') {
266
+ const st = fs.statSync(abs);
267
+ return { output: `${path.relative(cwd, abs)}\nsize: ${st.size} bytes\n${st.isDirectory() ? 'directory' : 'file'}\nmodified: ${st.mtime.toISOString()}` };
268
+ }
269
+ if (name === 'copy_file') {
270
+ const dest = path.resolve(cwd, String(input.to ?? ''));
271
+ if (!underRoot(dest, cwd)) return { output: 'Refused: destination is outside the working directory.' };
272
+ fs.copyFileSync(abs, dest);
273
+ return { output: `Copied ${path.relative(cwd, abs)} -> ${path.relative(cwd, dest)}.` };
274
+ }
275
+ if (name === 'move_file') {
276
+ const dest = path.resolve(cwd, String(input.to ?? ''));
277
+ if (!underRoot(dest, cwd)) return { output: 'Refused: destination is outside the working directory.' };
278
+ fs.renameSync(abs, dest);
279
+ return { output: `Moved ${path.relative(cwd, abs)} -> ${path.relative(cwd, dest)}.` };
280
+ }
281
+ if (name === 'search_files') {
282
+ const pattern = String(input.pattern ?? '').toLowerCase();
283
+ if (!pattern) return { output: 'Error: empty pattern.' };
284
+ const out = [];
285
+ const skip = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.cache']);
286
+ (function walk(d, depth) {
287
+ if (out.length >= 100 || depth > 5) return;
288
+ let entries;
289
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
290
+ for (const e of entries) {
291
+ if (out.length >= 100) return;
292
+ if (skip.has(e.name)) continue;
293
+ const p = path.join(d, e.name);
294
+ if (e.name.toLowerCase().includes(pattern)) out.push(path.relative(cwd, p) + (e.isDirectory() ? '/' : ''));
295
+ if (e.isDirectory()) walk(p, depth + 1);
296
+ }
297
+ })(abs, 0);
298
+ return { output: out.length ? out.join('\n') : '(no matches)' };
299
+ }
300
+ if (name === 'list_tracked_files') {
301
+ const g = spawnSync('git', ['ls-files'], { cwd, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 });
302
+ if (g.status !== 0) return { output: 'Error: not a git repository (or git unavailable).' };
303
+ const files = g.stdout.split('\n').filter(Boolean);
304
+ return { output: files.length ? files.slice(0, 500).join('\n') + (files.length > 500 ? `\n(+${files.length - 500} more)` : '') : '(no tracked files)' };
305
+ }
211
306
  return { output: `Unknown tool: ${name}` };
212
307
  } catch (err) {
213
308
  return { output: `Error: ${err.message}` };
package/src/ui.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // ui.js: terminal helpers. No dependencies, respects NO_COLOR and non-TTY.
2
2
 
3
- export const VERSION = '1.5.1';
3
+ export const VERSION = '1.6.1';
4
4
 
5
5
  const USE_COLOR = process.stdout.isTTY && !process.env.NO_COLOR;
6
6
  const wrap = (code, t) => USE_COLOR ? `\x1b[${code}m${t}\x1b[0m` : String(t);
@@ -18,6 +18,17 @@ export const trunc = (s, n = 120) => {
18
18
  return o.length > n ? o.slice(0, n - 1) + '...' : o;
19
19
  };
20
20
 
21
+ // Terminal-safe markdown: **bold** becomes ANSI bold (or disappears without color),
22
+ // headers lose their hashes. Files keep their markdown; only the screen is cleaned.
23
+ export const mdTerm = t => {
24
+ let s = String(t);
25
+ if (USE_COLOR) s = s.replace(/\*\*([^*\n]+)\*\*/g, `\x1b[1m$1\x1b[0m`);
26
+ else s = s.replace(/\*\*([^*\n]+)\*\*/g, '$1');
27
+ s = s.replace(/(^|\n)#{1,6} /g, '$1');
28
+ s = s.replace(/\*([^*\n]+)\*/g, '$1');
29
+ return s;
30
+ };
31
+
21
32
  const plain = s => String(s).replace(/\x1b\[[0-9;]*m/g, '');
22
33
 
23
34
  // Rounded box around lines. Width follows the longest line, capped to the terminal.