ineedcodes 1.5.0 → 1.6.0

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.0",
3
+ "version": "1.6.0",
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/config.js CHANGED
@@ -26,6 +26,7 @@ export function normalize(c) {
26
26
  mode: c.mode === 'plan' ? 'plan' : 'build',
27
27
  memory: c.memory !== false,
28
28
  mcp: c.mcp !== false,
29
+ tui: c.tui === true ? true : c.tui === false ? false : null,
29
30
  humanize: c.humanize !== false,
30
31
  stream: c.stream === true,
31
32
  searchUrl: c.searchUrl ? String(c.searchUrl) : '',
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
@@ -77,15 +77,28 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
77
77
  const pendingLines = [];
78
78
  const steerQueue = []; // notes typed while a task runs, injected mid-task
79
79
 
80
- const TUI = process.stdout.isTTY && !process.env.NO_COLOR;
80
+ // Full-screen TUI: great in ANSI terminals (Linux/macOS/Windows Terminal), but
81
+ // legacy Windows consoles garble alt-screen sequences. Auto: on everywhere except
82
+ // win32; force with config "tui": true, disable with "tui": false.
83
+ const TUI = process.stdout.isTTY && !process.env.NO_COLOR
84
+ && (state.tui === true || (state.tui === null && process.platform !== 'win32'));
81
85
  let sessionId = null;
82
86
  let lastBoost = null;
83
87
  let usage = { input: 0, output: 0 };
88
+ var tuiReady = false;
84
89
 
85
90
  // ONE readline, ONE line dispatcher for the whole session
86
91
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
87
92
  let handleRef = null;
88
93
  const ask = makeInput(rl, l => handleRef?.(l));
94
+ // EOF (Ctrl+D or closed pipe): exit cleanly, unless a task is still running
95
+ rl.on('close', () => {
96
+ if (!busy) doExit();
97
+ else {
98
+ const wait = setInterval(() => { if (!busy) { clearInterval(wait); doExit(); } }, 200);
99
+ setTimeout(() => { clearInterval(wait); doExit(); }, 30_000);
100
+ }
101
+ });
89
102
 
90
103
  const say = TUI ? lines => tuiPrint(lines) : (lines => console.log(lines));
91
104
 
@@ -116,6 +129,18 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
116
129
  }
117
130
 
118
131
  let lastStreamedForHooks = '';
132
+ let streamFlushTimer = null;
133
+ let streamFlushedCount = 0;
134
+ let streamBaseLines = null;
135
+ const chatLines = [];
136
+
137
+ function flushStreamed() {
138
+ if (!TUI || !lastStreamedForHooks) return;
139
+ if (streamBaseLines === null) streamBaseLines = chatLines.length;
140
+ chatLines.length = streamBaseLines; // re-render the growing answer in place
141
+ for (const l of wrapLines(lastStreamedForHooks, Math.max(10, (process.stdout.columns || 80) - 4))) chatLines.push(l);
142
+ redrawChat();
143
+ }
119
144
 
120
145
  function hooksForRun(stopSpinner) {
121
146
  let spinner = null;
@@ -132,7 +157,11 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
132
157
  onResult: out => { say(gray(' ' + trunc(out, 110))); },
133
158
  onText: t => { stop(); },
134
159
  onDelta: chunk => {
135
- if (TUI) { process.stdout.write(chunk); lastStreamedForHooks += chunk; }
160
+ // stream into the chat buffer; the full text lands on flushStreamed()
161
+ lastStreamedForHooks += chunk;
162
+ if (TUI && !streamFlushTimer) {
163
+ streamFlushTimer = setTimeout(() => { streamFlushTimer = null; flushStreamed(); }, 120);
164
+ }
136
165
  },
137
166
  onTodos: list => {
138
167
  stop();
@@ -143,7 +172,7 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
143
172
  onAgentEnd: (id, r) => { stop(); say((r.status === 'completed' ? green(' ◆ ' + id + ' done') : yellow(' ◆ ' + id + ' ' + r.status)) + gray(' ' + trunc(String(r.summary ?? '').replaceAll('\n', ' '), 90))); },
144
173
  onMCP: names => { if (names.length) say(dim(' MCP tools available: ' + names.join(', '))); },
145
174
  onMCPResult: (name, out) => { say(gray(' mcp result: ' + trunc(out, 100))); },
146
- 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)); },
147
176
  onUsage: u => { usage = u; if (TUI) drawStatus(); },
148
177
  drainSteer: () => steerQueue.splice(0),
149
178
  onSteer: list => { for (const s of list) say(yellow(' ↳ steer: ') + s); },
@@ -174,6 +203,7 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
174
203
  async function runTask(input) {
175
204
  busy = true;
176
205
  lastStreamedForHooks = '';
206
+ streamFlushedCount = 0;
177
207
  if (TUI) tuiUserLine(input);
178
208
  const hooks = hooksForRun();
179
209
  const stopSpinner = hooks.spinnerStop;
@@ -234,7 +264,8 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
234
264
  say(' ' + cyan('/status') + ' everything about this session at a glance');
235
265
  say(' ' + cyan('/compact') + ' shrink the conversation into a checkpoint');
236
266
  say(' ' + cyan('/depth') + ' answer depth: /depth short|normal|deep');
237
- 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');
238
269
  say(' ' + cyan('/skills') + ' list installed skills, /skills <name> shows one');
239
270
  say(' ' + cyan('/mcp') + ' list MCP servers and their tools');
240
271
  say(' ' + cyan('/humanizer') + ' natural-writing pass for pages and posts (on/off)');
@@ -264,9 +295,12 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
264
295
  async function handle(input) {
265
296
  if (!input) return;
266
297
  if (busy) {
267
- if (input === '/stop') { activeRun?.abort(); say(dim(' (stopping...')); return; }
298
+ if (input === '/stop' || input.startsWith('/stop ') || input === 'stop') { activeRun?.abort(); say(dim(' (stopping...')); return; }
268
299
  if (input.startsWith('/')) { pendingLines.push(input); return; }
269
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...'));
270
304
  return;
271
305
  }
272
306
  if (['/exit', '/quit', 'exit', 'quit'].includes(input)) return doExit();
@@ -453,22 +487,41 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
453
487
  say(all.length ? all.map(s => ` ${cyan(s.name)} ${dim('(' + s.scope + ')')} ${s.description}`).join('\n') : yellow('No skills installed.'));
454
488
  return;
455
489
  }
456
- 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();
457
500
  busy = true;
458
501
  const list = listSessions();
459
502
  if (!list.length) { say(yellow('No saved sessions yet.')); busy = false; return afterTask(); }
460
- 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
+ say(green(`Resumed ${s.id} (${Math.floor(s.history.length / 2)} turns). Continue where we left off.`));
509
+ } else say(red(`No session with code ${codeArg}. Check /resume for the list of codes.`));
510
+ busy = false;
511
+ return afterTask();
512
+ }
513
+ list.slice(0, 8).forEach((s, i) => {
461
514
  const first = String(s.history?.find(m => m.role === 'user')?.content ?? '').replaceAll('\n', ' ').slice(0, 70);
462
- say(` ${i + 1}. ${new Date(s.time).toLocaleString()} · ${Math.floor((s.history?.length ?? 0) / 2)} turns · ${first}`);
515
+ say(` ${s.id} · ${Math.floor((s.history?.length ?? 0) / 2)} turns · ${first}`);
463
516
  });
464
- const pick = await ask(' Resume which? [1]: ');
465
- const n = Number(pick) || 1;
466
- const s = loadSession(list[n - 1]?.id);
517
+ const pick = await ask(' Resume which? (code, e.g. 1425-0609, empty = newest): ');
518
+ const s = pick ? loadSession(pick.trim()) : list[0];
467
519
  if (s?.history?.length) {
468
520
  history = s.history;
469
521
  sessionId = s.id;
470
- say(green(`Resumed ${Math.floor(s.history.length / 2)} turns. Continue where we left off.`));
471
- } else say(red('Could not load that session.'));
522
+ say(green(`Resumed ${s.id} (${Math.floor(s.history.length / 2)} turns). Continue where we left off.`));
523
+ if (TUI) redrawChat();
524
+ } else say(red(`No session with code ${pick}. Check the list above.`));
472
525
  busy = false;
473
526
  return afterTask();
474
527
  }
@@ -556,7 +609,6 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
556
609
  }
557
610
 
558
611
  // ── full TUI mode ──
559
- const chatLines = []; // completed chat lines (ANSI strings)
560
612
  let chatTop = 0, chatBot = 0; // scroll region rows
561
613
  let statusRow = 0;
562
614
 
@@ -609,10 +661,10 @@ export async function startSession(cfg, { fresh = false, resume = null } = {}) {
609
661
  redrawChat();
610
662
  }
611
663
 
612
- function tuiUserLine(input) {
664
+ const tuiUserLine = input => {
613
665
  for (const l of wrapLines(userBubble(input), Math.max(10, (process.stdout.columns || 80) - 4))) chatLines.push(l);
614
666
  redrawChat();
615
- }
667
+ };
616
668
 
617
669
  const layout = () => {
618
670
  const rows = process.stdout.rows || 24;
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.0';
3
+ export const VERSION = '1.6.0';
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.