mini-coder 0.0.6 → 0.0.8

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.
Files changed (3) hide show
  1. package/dist/mc.js +430 -186
  2. package/package.json +2 -1
  3. package/better-errors.md +0 -96
package/dist/mc.js CHANGED
@@ -6,7 +6,7 @@ import * as c8 from "yoctocolors";
6
6
 
7
7
  // src/agent/agent.ts
8
8
  import { existsSync as existsSync6, readFileSync as readFileSync5 } from "fs";
9
- import { join as join14 } from "path";
9
+ import { join as join16 } from "path";
10
10
  import * as c7 from "yoctocolors";
11
11
 
12
12
  // src/cli/agents.ts
@@ -521,16 +521,146 @@ function generateSessionId() {
521
521
 
522
522
  // src/cli/custom-commands.ts
523
523
  import { existsSync as existsSync3, readFileSync as readFileSync2, readdirSync as readdirSync2 } from "fs";
524
- import { homedir as homedir4 } from "os";
525
- import { basename as basename2, join as join3 } from "path";
524
+ import { homedir as homedir5 } from "os";
525
+ import { basename as basename2, join as join4 } from "path";
526
526
 
527
527
  // src/cli/config-conflicts.ts
528
528
  import * as c3 from "yoctocolors";
529
529
 
530
530
  // src/cli/output.ts
531
- import { homedir as homedir3 } from "os";
531
+ import { homedir as homedir4 } from "os";
532
532
  import * as c2 from "yoctocolors";
533
533
 
534
+ // src/cli/error-log.ts
535
+ import { mkdirSync as mkdirSync2 } from "fs";
536
+ import { homedir as homedir3 } from "os";
537
+ import { join as join3 } from "path";
538
+ var writer = null;
539
+ function initErrorLog() {
540
+ if (writer)
541
+ return;
542
+ const dirPath = join3(homedir3(), ".config", "mini-coder");
543
+ const logPath = join3(dirPath, "errors.log");
544
+ mkdirSync2(dirPath, { recursive: true });
545
+ writer = Bun.file(logPath).writer();
546
+ process.on("uncaughtException", (err) => {
547
+ logError(err, "uncaught");
548
+ process.exit(1);
549
+ });
550
+ }
551
+ function isObject(v) {
552
+ return typeof v === "object" && v !== null;
553
+ }
554
+ function logError(err, context) {
555
+ if (!writer)
556
+ return;
557
+ let entry = `[${new Date().toISOString()}]`;
558
+ if (context)
559
+ entry += ` context=${context}`;
560
+ entry += `
561
+ `;
562
+ if (isObject(err)) {
563
+ if (typeof err.name === "string")
564
+ entry += ` name: ${err.name}
565
+ `;
566
+ if (typeof err.message === "string")
567
+ entry += ` message: ${err.message}
568
+ `;
569
+ if ("statusCode" in err)
570
+ entry += ` statusCode: ${err.statusCode}
571
+ `;
572
+ if ("url" in err)
573
+ entry += ` url: ${err.url}
574
+ `;
575
+ if ("isRetryable" in err)
576
+ entry += ` isRetryable: ${err.isRetryable}
577
+ `;
578
+ if (typeof err.stack === "string") {
579
+ const indentedStack = err.stack.split(`
580
+ `).map((line, i) => i === 0 ? line : ` ${line}`).join(`
581
+ `);
582
+ entry += ` stack: ${indentedStack}
583
+ `;
584
+ }
585
+ } else {
586
+ entry += ` value: ${String(err)}
587
+ `;
588
+ }
589
+ entry += `---
590
+ `;
591
+ writer.write(entry);
592
+ writer.flush();
593
+ }
594
+
595
+ // src/cli/error-parse.ts
596
+ import {
597
+ APICallError,
598
+ LoadAPIKeyError,
599
+ NoContentGeneratedError,
600
+ NoSuchModelError,
601
+ RetryError
602
+ } from "ai";
603
+ function parseAppError(err) {
604
+ if (typeof err === "string") {
605
+ return { headline: err };
606
+ }
607
+ if (err instanceof RetryError) {
608
+ const inner = parseAppError(err.lastError);
609
+ return {
610
+ headline: `Retries exhausted: ${inner.headline}`,
611
+ ...inner.hint ? { hint: inner.hint } : {}
612
+ };
613
+ }
614
+ if (err instanceof APICallError) {
615
+ if (err.statusCode === 429) {
616
+ return {
617
+ headline: "Rate limit hit",
618
+ hint: "Wait a moment and retry, or switch model with /model"
619
+ };
620
+ }
621
+ if (err.statusCode === 401 || err.statusCode === 403) {
622
+ return {
623
+ headline: "Auth failed",
624
+ hint: "Check the relevant provider API key env var"
625
+ };
626
+ }
627
+ return {
628
+ headline: `API error ${err.statusCode ?? "unknown"}`,
629
+ ...err.url ? { hint: err.url } : {}
630
+ };
631
+ }
632
+ if (err instanceof NoContentGeneratedError) {
633
+ return {
634
+ headline: "Model returned empty response",
635
+ hint: "Try rephrasing or switching model with /model"
636
+ };
637
+ }
638
+ if (err instanceof LoadAPIKeyError) {
639
+ return {
640
+ headline: "API key not found",
641
+ hint: "Set the relevant provider env var"
642
+ };
643
+ }
644
+ if (err instanceof NoSuchModelError) {
645
+ return {
646
+ headline: "Model not found",
647
+ hint: "Use /model to pick a valid model"
648
+ };
649
+ }
650
+ const isObj = typeof err === "object" && err !== null;
651
+ const code = isObj && "code" in err ? String(err.code) : undefined;
652
+ const message = isObj && "message" in err ? String(err.message) : String(err);
653
+ if (code === "ECONNREFUSED" || message.includes("ECONNREFUSED")) {
654
+ return {
655
+ headline: "Connection failed",
656
+ hint: "Check network or local server"
657
+ };
658
+ }
659
+ const firstLine = message.split(`
660
+ `)[0]?.trim() || "Unknown error";
661
+ return { headline: firstLine };
662
+ }
663
+
534
664
  // src/cli/markdown.ts
535
665
  import * as c from "yoctocolors";
536
666
  function renderInline(text) {
@@ -644,8 +774,8 @@ function renderChunk(text, inFence) {
644
774
  }
645
775
 
646
776
  // src/cli/output.ts
647
- var HOME = homedir3();
648
- var PACKAGE_VERSION = "0.1.0";
777
+ var HOME = homedir4();
778
+ var PACKAGE_VERSION = "0.0.7";
649
779
  function tildePath(p) {
650
780
  return p.startsWith(HOME) ? `~${p.slice(HOME.length)}` : p;
651
781
  }
@@ -668,8 +798,6 @@ function registerTerminalCleanup() {
668
798
  process.exit(143);
669
799
  });
670
800
  process.on("SIGINT", () => {
671
- if (process.listenerCount("SIGINT") > 1)
672
- return;
673
801
  cleanup();
674
802
  process.exit(130);
675
803
  });
@@ -856,10 +984,6 @@ function renderToolResultInline(toolName, result, isError, indent) {
856
984
  return;
857
985
  }
858
986
  if (toolName === "subagent") {
859
- const r = result;
860
- if (r.inputTokens || r.outputTokens) {
861
- writeln(`${indent}${G.ok} ${c2.dim(`\u2191${r.inputTokens ?? 0} \u2193${r.outputTokens ?? 0}`)}`);
862
- }
863
987
  return;
864
988
  }
865
989
  if (toolName.startsWith("mcp_")) {
@@ -876,16 +1000,48 @@ function renderToolResultInline(toolName, result, isError, indent) {
876
1000
  const text = JSON.stringify(result);
877
1001
  writeln(`${indent}${G.info} ${c2.dim(text.length > 80 ? `${text.slice(0, 77)}\u2026` : text)}`);
878
1002
  }
879
- function renderSubagentActivity(activity, indent, maxDepth) {
880
- for (const entry of activity) {
881
- writeln(`${indent}${toolCallLine(entry.toolName, entry.args)}`);
882
- if (entry.toolName === "subagent" && maxDepth > 0) {
883
- const nested = entry.result;
884
- if (nested?.activity?.length) {
885
- renderSubagentActivity(nested.activity, `${indent} `, maxDepth - 1);
1003
+ function formatSubagentLabel(laneId, parentLabel) {
1004
+ const numStr = parentLabel ? `${parentLabel.replace(/[\[\]]/g, "")}.${laneId}` : `${laneId}`;
1005
+ return c2.dim(c2.cyan(`[${numStr}]`));
1006
+ }
1007
+ var laneBuffers = new Map;
1008
+ function renderSubagentEvent(event, opts) {
1009
+ const { laneId, parentLabel, activeLanes } = opts;
1010
+ const labelStr = formatSubagentLabel(laneId, parentLabel);
1011
+ const prefix = activeLanes.size > 1 ? `${labelStr} ` : "";
1012
+ if (event.type === "text-delta") {
1013
+ const buf = (laneBuffers.get(laneId) ?? "") + event.delta;
1014
+ const lines = buf.split(`
1015
+ `);
1016
+ if (lines.length > 1) {
1017
+ for (let i = 0;i < lines.length - 1; i++) {
1018
+ writeln(`${prefix}${lines[i]}`);
886
1019
  }
1020
+ laneBuffers.set(laneId, lines[lines.length - 1] ?? "");
1021
+ } else {
1022
+ laneBuffers.set(laneId, buf);
1023
+ }
1024
+ } else if (event.type === "tool-call-start") {
1025
+ writeln(`${prefix}${toolCallLine(event.toolName, event.args)}`);
1026
+ } else if (event.type === "tool-result") {
1027
+ renderToolResultInline(event.toolName, event.result, event.isError, `${prefix} `);
1028
+ } else if (event.type === "turn-complete") {
1029
+ const buf = laneBuffers.get(laneId);
1030
+ if (buf) {
1031
+ writeln(`${prefix}${buf}`);
1032
+ laneBuffers.delete(laneId);
1033
+ }
1034
+ if (event.inputTokens > 0 || event.outputTokens > 0) {
1035
+ writeln(`${prefix}${c2.dim(`\u2191${event.inputTokens} \u2193${event.outputTokens}`)}`);
1036
+ }
1037
+ } else if (event.type === "turn-error") {
1038
+ laneBuffers.delete(laneId);
1039
+ logError(event.error, "turn");
1040
+ const parsed = parseAppError(event.error);
1041
+ writeln(`${prefix}${G.err} ${c2.red(parsed.headline)}`);
1042
+ if (parsed.hint) {
1043
+ writeln(`${prefix} ${c2.dim(parsed.hint)}`);
887
1044
  }
888
- renderToolResultInline(entry.toolName, entry.result, entry.isError, `${indent} `);
889
1045
  }
890
1046
  }
891
1047
  function renderToolResult(toolName, result, isError) {
@@ -985,22 +1141,6 @@ function renderToolResult(toolName, result, isError) {
985
1141
  return;
986
1142
  }
987
1143
  if (toolName === "subagent") {
988
- const r = result;
989
- if (r.activity?.length) {
990
- renderSubagentActivity(r.activity, " ", 1);
991
- }
992
- if (r.result) {
993
- const lines = r.result.split(`
994
- `);
995
- const preview = lines.slice(0, 8);
996
- for (const line of preview)
997
- writeln(` ${c2.dim("\u2502")} ${line}`);
998
- if (lines.length > 8)
999
- writeln(` ${c2.dim(`\u2502 \u2026 +${lines.length - 8} lines`)}`);
1000
- }
1001
- if (r.inputTokens || r.outputTokens) {
1002
- writeln(` ${c2.dim(`\u2191${r.inputTokens ?? 0} \u2193${r.outputTokens ?? 0}`)}`);
1003
- }
1004
1144
  return;
1005
1145
  }
1006
1146
  if (toolName.startsWith("mcp_")) {
@@ -1159,9 +1299,12 @@ async function renderTurn(events, spinner) {
1159
1299
  if (isAbort) {
1160
1300
  writeln(`${G.warn} ${c2.dim("interrupted")}`);
1161
1301
  } else {
1162
- const msg = event.error.message.split(`
1163
- `)[0] ?? event.error.message;
1164
- writeln(`${G.err} ${c2.red(msg)}`);
1302
+ logError(event.error, "turn");
1303
+ const parsed = parseAppError(event.error);
1304
+ writeln(`${G.err} ${c2.red(parsed.headline)}`);
1305
+ if (parsed.hint) {
1306
+ writeln(` ${c2.dim(parsed.hint)}`);
1307
+ }
1165
1308
  }
1166
1309
  break;
1167
1310
  }
@@ -1211,9 +1354,13 @@ function renderBanner(model, cwd) {
1211
1354
  writeln(` ${c2.dim("/help for commands \xB7 ctrl+d to exit")}`);
1212
1355
  writeln();
1213
1356
  }
1214
- function renderError(err) {
1215
- const msg = err instanceof Error ? err.message : String(err);
1216
- writeln(`${G.err} ${c2.red(msg)}`);
1357
+ function renderError(err, context = "render") {
1358
+ logError(err, context);
1359
+ const parsed = parseAppError(err);
1360
+ writeln(`${G.err} ${c2.red(parsed.headline)}`);
1361
+ if (parsed.hint) {
1362
+ writeln(` ${c2.dim(parsed.hint)}`);
1363
+ }
1217
1364
  }
1218
1365
  function renderInfo(msg) {
1219
1366
  writeln(`${G.info} ${c2.dim(msg)}`);
@@ -1301,7 +1448,7 @@ function loadFromDir2(dir, source) {
1301
1448
  if (!entry.endsWith(".md"))
1302
1449
  continue;
1303
1450
  const name = basename2(entry, ".md");
1304
- const filePath = join3(dir, entry);
1451
+ const filePath = join4(dir, entry);
1305
1452
  let raw;
1306
1453
  try {
1307
1454
  raw = readFileSync2(filePath, "utf-8");
@@ -1320,10 +1467,10 @@ function loadFromDir2(dir, source) {
1320
1467
  return commands;
1321
1468
  }
1322
1469
  function loadCustomCommands(cwd) {
1323
- const globalAgentsDir = join3(homedir4(), ".agents", "commands");
1324
- const globalClaudeDir = join3(homedir4(), ".claude", "commands");
1325
- const localAgentsDir = join3(cwd, ".agents", "commands");
1326
- const localClaudeDir = join3(cwd, ".claude", "commands");
1470
+ const globalAgentsDir = join4(homedir5(), ".agents", "commands");
1471
+ const globalClaudeDir = join4(homedir5(), ".claude", "commands");
1472
+ const localAgentsDir = join4(cwd, ".agents", "commands");
1473
+ const localClaudeDir = join4(cwd, ".claude", "commands");
1327
1474
  const globalAgents = loadFromDir2(globalAgentsDir, "global");
1328
1475
  const globalClaude = loadFromDir2(globalClaudeDir, "global");
1329
1476
  const localAgents = loadFromDir2(localAgentsDir, "local");
@@ -1378,8 +1525,8 @@ async function expandTemplate(template, args, cwd) {
1378
1525
 
1379
1526
  // src/cli/skills.ts
1380
1527
  import { existsSync as existsSync4, readFileSync as readFileSync3, readdirSync as readdirSync3, statSync } from "fs";
1381
- import { homedir as homedir5 } from "os";
1382
- import { join as join4 } from "path";
1528
+ import { homedir as homedir6 } from "os";
1529
+ import { join as join5 } from "path";
1383
1530
  function loadFromDir3(dir, source) {
1384
1531
  const skills = new Map;
1385
1532
  if (!existsSync4(dir))
@@ -1391,9 +1538,9 @@ function loadFromDir3(dir, source) {
1391
1538
  return skills;
1392
1539
  }
1393
1540
  for (const entry of entries) {
1394
- const skillFile = join4(dir, entry, "SKILL.md");
1541
+ const skillFile = join5(dir, entry, "SKILL.md");
1395
1542
  try {
1396
- if (!statSync(join4(dir, entry)).isDirectory())
1543
+ if (!statSync(join5(dir, entry)).isDirectory())
1397
1544
  continue;
1398
1545
  if (!existsSync4(skillFile))
1399
1546
  continue;
@@ -1411,10 +1558,10 @@ function loadFromDir3(dir, source) {
1411
1558
  return skills;
1412
1559
  }
1413
1560
  function loadSkills(cwd) {
1414
- const globalAgentsDir = join4(homedir5(), ".agents", "skills");
1415
- const globalClaudeDir = join4(homedir5(), ".claude", "skills");
1416
- const localAgentsDir = join4(cwd, ".agents", "skills");
1417
- const localClaudeDir = join4(cwd, ".claude", "skills");
1561
+ const globalAgentsDir = join5(homedir6(), ".agents", "skills");
1562
+ const globalClaudeDir = join5(homedir6(), ".claude", "skills");
1563
+ const localAgentsDir = join5(cwd, ".agents", "skills");
1564
+ const localClaudeDir = join5(cwd, ".claude", "skills");
1418
1565
  const globalAgents = loadFromDir3(globalAgentsDir, "global");
1419
1566
  const globalClaude = loadFromDir3(globalClaudeDir, "global");
1420
1567
  const localAgents = loadFromDir3(localAgentsDir, "local");
@@ -1609,10 +1756,6 @@ async function handleReview(ctx, args) {
1609
1756
  writeln();
1610
1757
  try {
1611
1758
  const output = await ctx.runSubagent(REVIEW_PROMPT(ctx.cwd, focus));
1612
- if (output.activity.length) {
1613
- renderSubagentActivity(output.activity, " ", 1);
1614
- writeln();
1615
- }
1616
1759
  write(renderMarkdown(output.result));
1617
1760
  writeln();
1618
1761
  return {
@@ -1641,10 +1784,6 @@ async function handleCustomCommand(cmd, args, ctx) {
1641
1784
  writeln();
1642
1785
  try {
1643
1786
  const output = await ctx.runSubagent(prompt, cmd.model);
1644
- if (output.activity.length) {
1645
- renderSubagentActivity(output.activity, " ", 1);
1646
- writeln();
1647
- }
1648
1787
  write(renderMarkdown(output.result));
1649
1788
  writeln();
1650
1789
  return {
@@ -1795,7 +1934,7 @@ async function loadImageFile(filePath) {
1795
1934
  }
1796
1935
 
1797
1936
  // src/cli/input.ts
1798
- import { join as join5, relative } from "path";
1937
+ import { join as join6, relative } from "path";
1799
1938
  import * as c5 from "yoctocolors";
1800
1939
  var ESC = "\x1B";
1801
1940
  var CSI = `${ESC}[`;
@@ -1847,7 +1986,7 @@ async function getAtCompletions(prefix, cwd) {
1847
1986
  for await (const file of glob.scan({ cwd, onlyFiles: true })) {
1848
1987
  if (file.includes("node_modules") || file.includes(".git"))
1849
1988
  continue;
1850
- results.push(`@${relative(cwd, join5(cwd, file))}`);
1989
+ results.push(`@${relative(cwd, join6(cwd, file))}`);
1851
1990
  if (results.length >= MAX)
1852
1991
  break;
1853
1992
  }
@@ -1869,7 +2008,7 @@ async function tryExtractImageFromPaste(pasted, cwd) {
1869
2008
  }
1870
2009
  }
1871
2010
  if (!trimmed.includes(" ") && isImageFilename(trimmed)) {
1872
- const filePath = trimmed.startsWith("/") ? trimmed : join5(cwd, trimmed);
2011
+ const filePath = trimmed.startsWith("/") ? trimmed : join6(cwd, trimmed);
1873
2012
  const attachment = await loadImageFile(filePath);
1874
2013
  if (attachment) {
1875
2014
  const name = filePath.split("/").pop() ?? trimmed;
@@ -1906,6 +2045,28 @@ async function readKey(reader) {
1906
2045
  return "";
1907
2046
  return new TextDecoder().decode(value);
1908
2047
  }
2048
+ function watchForInterrupt(abortController) {
2049
+ if (!process.stdin.isTTY)
2050
+ return () => {};
2051
+ const onData = (chunk) => {
2052
+ for (const byte of chunk) {
2053
+ if (byte === 3) {
2054
+ cleanup();
2055
+ abortController.abort();
2056
+ return;
2057
+ }
2058
+ }
2059
+ };
2060
+ const cleanup = () => {
2061
+ process.stdin.removeListener("data", onData);
2062
+ process.stdin.setRawMode(false);
2063
+ process.stdin.pause();
2064
+ };
2065
+ process.stdin.setRawMode(true);
2066
+ process.stdin.resume();
2067
+ process.stdin.on("data", onData);
2068
+ return cleanup;
2069
+ }
1909
2070
  var PASTE_SENTINEL = "\x00PASTE\x00";
1910
2071
  var PASTE_SENTINEL_LEN = PASTE_SENTINEL.length;
1911
2072
  function pasteLabel(text) {
@@ -2278,6 +2439,7 @@ async function* runTurn(options) {
2278
2439
  ...signal ? { abortSignal: signal } : {}
2279
2440
  };
2280
2441
  const result = streamText(streamOpts);
2442
+ result.response.catch(() => {});
2281
2443
  for await (const chunk of result.fullStream) {
2282
2444
  if (signal?.aborted)
2283
2445
  break;
@@ -2402,7 +2564,7 @@ async function connectMcpServer(config) {
2402
2564
 
2403
2565
  // src/tools/snapshot.ts
2404
2566
  import { readFileSync as readFileSync4, unlinkSync as unlinkSync2 } from "fs";
2405
- import { join as join6 } from "path";
2567
+ import { join as join7 } from "path";
2406
2568
  async function gitBytes(args, cwd) {
2407
2569
  try {
2408
2570
  const proc = Bun.spawn(["git", ...args], {
@@ -2493,7 +2655,7 @@ async function takeSnapshot(cwd, sessionId, turnIndex) {
2493
2655
  return false;
2494
2656
  const files = [];
2495
2657
  for (const entry of entries) {
2496
- const absPath = join6(repoRoot, entry.path);
2658
+ const absPath = join7(repoRoot, entry.path);
2497
2659
  if (!entry.existsOnDisk) {
2498
2660
  const { bytes, code } = await gitBytes(["show", `HEAD:${entry.path}`], repoRoot);
2499
2661
  if (code === 0) {
@@ -2542,7 +2704,7 @@ async function restoreSnapshot(cwd, sessionId, turnIndex) {
2542
2704
  const root = repoRoot ?? cwd;
2543
2705
  let anyFailed = false;
2544
2706
  for (const file of files) {
2545
- const absPath = join6(root, file.path);
2707
+ const absPath = join7(root, file.path);
2546
2708
  if (!file.existed) {
2547
2709
  try {
2548
2710
  if (await Bun.file(absPath).exists()) {
@@ -2609,10 +2771,75 @@ function getMostRecentSession() {
2609
2771
  return sessions[0] ?? null;
2610
2772
  }
2611
2773
 
2612
- // src/tools/create.ts
2613
- import { existsSync as existsSync5, mkdirSync as mkdirSync2 } from "fs";
2614
- import { dirname, join as join7, relative as relative2 } from "path";
2774
+ // src/tools/exa.ts
2615
2775
  import { z as z2 } from "zod";
2776
+ var ExaSearchSchema = z2.object({
2777
+ query: z2.string().describe("The search query")
2778
+ });
2779
+ var webSearchTool = {
2780
+ name: "webSearch",
2781
+ description: "Search the web for a query using Exa.",
2782
+ schema: ExaSearchSchema,
2783
+ execute: async (input) => {
2784
+ const apiKey = process.env.EXA_API_KEY;
2785
+ if (!apiKey) {
2786
+ throw new Error("EXA_API_KEY is not set.");
2787
+ }
2788
+ const response = await fetch("https://api.exa.ai/search", {
2789
+ method: "POST",
2790
+ headers: {
2791
+ "Content-Type": "application/json",
2792
+ "x-api-key": apiKey
2793
+ },
2794
+ body: JSON.stringify({
2795
+ query: input.query,
2796
+ type: "auto",
2797
+ numResults: 10,
2798
+ contents: { text: { maxCharacters: 4000 } }
2799
+ })
2800
+ });
2801
+ if (!response.ok) {
2802
+ const errorBody = await response.text();
2803
+ throw new Error(`Exa API error: ${response.status} ${response.statusText} - ${errorBody}`);
2804
+ }
2805
+ return await response.json();
2806
+ }
2807
+ };
2808
+ var ExaContentSchema = z2.object({
2809
+ urls: z2.array(z2.string()).max(3).describe("Array of URLs to retrieve content for (max 3)")
2810
+ });
2811
+ var webContentTool = {
2812
+ name: "webContent",
2813
+ description: "Get the full content of specific URLs using Exa.",
2814
+ schema: ExaContentSchema,
2815
+ execute: async (input) => {
2816
+ const apiKey = process.env.EXA_API_KEY;
2817
+ if (!apiKey) {
2818
+ throw new Error("EXA_API_KEY is not set.");
2819
+ }
2820
+ const response = await fetch("https://api.exa.ai/contents", {
2821
+ method: "POST",
2822
+ headers: {
2823
+ "Content-Type": "application/json",
2824
+ "x-api-key": apiKey
2825
+ },
2826
+ body: JSON.stringify({
2827
+ urls: input.urls,
2828
+ text: { maxCharacters: 1e4 }
2829
+ })
2830
+ });
2831
+ if (!response.ok) {
2832
+ const errorBody = await response.text();
2833
+ throw new Error(`Exa API error: ${response.status} ${response.statusText} - ${errorBody}`);
2834
+ }
2835
+ return await response.json();
2836
+ }
2837
+ };
2838
+
2839
+ // src/tools/create.ts
2840
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3 } from "fs";
2841
+ import { dirname, join as join8, relative as relative2 } from "path";
2842
+ import { z as z3 } from "zod";
2616
2843
 
2617
2844
  // src/tools/diff.ts
2618
2845
  function generateDiff(filePath, before, after) {
@@ -2741,9 +2968,9 @@ ${lines.join(`
2741
2968
  }
2742
2969
 
2743
2970
  // src/tools/create.ts
2744
- var CreateSchema = z2.object({
2745
- path: z2.string().describe("File path to write (absolute or relative to cwd)"),
2746
- content: z2.string().describe("Full content to write to the file")
2971
+ var CreateSchema = z3.object({
2972
+ path: z3.string().describe("File path to write (absolute or relative to cwd)"),
2973
+ content: z3.string().describe("Full content to write to the file")
2747
2974
  });
2748
2975
  var createTool = {
2749
2976
  name: "create",
@@ -2751,11 +2978,11 @@ var createTool = {
2751
2978
  schema: CreateSchema,
2752
2979
  execute: async (input) => {
2753
2980
  const cwd = input.cwd ?? process.cwd();
2754
- const filePath = input.path.startsWith("/") ? input.path : join7(cwd, input.path);
2981
+ const filePath = input.path.startsWith("/") ? input.path : join8(cwd, input.path);
2755
2982
  const relPath = relative2(cwd, filePath);
2756
2983
  const dir = dirname(filePath);
2757
2984
  if (!existsSync5(dir))
2758
- mkdirSync2(dir, { recursive: true });
2985
+ mkdirSync3(dir, { recursive: true });
2759
2986
  const file = Bun.file(filePath);
2760
2987
  const created = !await file.exists();
2761
2988
  const before = created ? "" : await file.text();
@@ -2766,11 +2993,25 @@ var createTool = {
2766
2993
  };
2767
2994
 
2768
2995
  // src/tools/glob.ts
2769
- import { join as join8, relative as relative3 } from "path";
2770
- import { z as z3 } from "zod";
2771
- var GlobSchema = z3.object({
2772
- pattern: z3.string().describe("Glob pattern to match files against, e.g. '**/*.ts'"),
2773
- ignore: z3.array(z3.string()).optional().describe("Glob patterns to exclude")
2996
+ import { join as join10, relative as relative3 } from "path";
2997
+ import { z as z4 } from "zod";
2998
+
2999
+ // src/tools/ignore.ts
3000
+ import { join as join9 } from "path";
3001
+ import ignore from "ignore";
3002
+ async function loadGitignore(cwd) {
3003
+ try {
3004
+ const gitignore = await Bun.file(join9(cwd, ".gitignore")).text();
3005
+ return ignore().add(gitignore);
3006
+ } catch {
3007
+ return null;
3008
+ }
3009
+ }
3010
+
3011
+ // src/tools/glob.ts
3012
+ var GlobSchema = z4.object({
3013
+ pattern: z4.string().describe("Glob pattern to match files against, e.g. '**/*.ts'"),
3014
+ ignore: z4.array(z4.string()).optional().describe("Glob patterns to exclude")
2774
3015
  });
2775
3016
  var MAX_RESULTS = 500;
2776
3017
  var globTool = {
@@ -2779,26 +3020,21 @@ var globTool = {
2779
3020
  schema: GlobSchema,
2780
3021
  execute: async (input) => {
2781
3022
  const cwd = input.cwd ?? process.cwd();
2782
- const defaultIgnore = [
2783
- "node_modules/**",
2784
- ".git/**",
2785
- "dist/**",
2786
- "*.db",
2787
- "*.db-shm",
2788
- "*.db-wal"
2789
- ];
3023
+ const defaultIgnore = [".git/**", "node_modules/**"];
2790
3024
  const ignorePatterns = [...defaultIgnore, ...input.ignore ?? []];
3025
+ const ignoreGlobs = ignorePatterns.map((pat) => new Bun.Glob(pat));
3026
+ const ig = await loadGitignore(cwd);
2791
3027
  const glob = new Bun.Glob(input.pattern);
2792
3028
  const matches = [];
2793
- for await (const file of glob.scan({ cwd, onlyFiles: true })) {
2794
- const ignored = ignorePatterns.some((pat) => {
2795
- const ig = new Bun.Glob(pat);
2796
- return ig.match(file);
2797
- });
3029
+ for await (const file of glob.scan({ cwd, onlyFiles: true, dot: true })) {
3030
+ if (ig?.ignores(file))
3031
+ continue;
3032
+ const firstSegment = file.split("/")[0] ?? "";
3033
+ const ignored = ignoreGlobs.some((g) => g.match(file) || g.match(firstSegment));
2798
3034
  if (ignored)
2799
3035
  continue;
2800
3036
  try {
2801
- const fullPath = join8(cwd, file);
3037
+ const fullPath = join10(cwd, file);
2802
3038
  const stat = await Bun.file(fullPath).stat?.() ?? null;
2803
3039
  matches.push({ path: file, mtime: stat?.mtime?.getTime() ?? 0 });
2804
3040
  } catch {
@@ -2811,14 +3047,14 @@ var globTool = {
2811
3047
  if (truncated)
2812
3048
  matches.pop();
2813
3049
  matches.sort((a, b) => b.mtime - a.mtime);
2814
- const files = matches.map((m) => relative3(cwd, join8(cwd, m.path)));
3050
+ const files = matches.map((m) => relative3(cwd, join10(cwd, m.path)));
2815
3051
  return { files, count: files.length, truncated };
2816
3052
  }
2817
3053
  };
2818
3054
 
2819
3055
  // src/tools/grep.ts
2820
- import { join as join9 } from "path";
2821
- import { z as z4 } from "zod";
3056
+ import { join as join11 } from "path";
3057
+ import { z as z5 } from "zod";
2822
3058
 
2823
3059
  // src/tools/hashline.ts
2824
3060
  var FNV_OFFSET_BASIS = 2166136261;
@@ -2864,12 +3100,12 @@ function findLineByHash(lines, hash, hintLine) {
2864
3100
  }
2865
3101
 
2866
3102
  // src/tools/grep.ts
2867
- var GrepSchema = z4.object({
2868
- pattern: z4.string().describe("Regular expression to search for"),
2869
- include: z4.string().optional().describe("Glob pattern to filter files, e.g. '*.ts' or '*.{ts,tsx}'"),
2870
- contextLines: z4.number().int().min(0).max(10).optional().default(2).describe("Lines of context to include around each match"),
2871
- caseSensitive: z4.boolean().optional().default(true),
2872
- maxResults: z4.number().int().min(1).max(200).optional().default(50)
3103
+ var GrepSchema = z5.object({
3104
+ pattern: z5.string().describe("Regular expression to search for"),
3105
+ include: z5.string().optional().describe("Glob pattern to filter files, e.g. '*.ts' or '*.{ts,tsx}'"),
3106
+ contextLines: z5.number().int().min(0).max(10).optional().default(2).describe("Lines of context to include around each match"),
3107
+ caseSensitive: z5.boolean().optional().default(true),
3108
+ maxResults: z5.number().int().min(1).max(200).optional().default(50)
2873
3109
  });
2874
3110
  var DEFAULT_IGNORE = [
2875
3111
  "node_modules",
@@ -2895,15 +3131,20 @@ var grepTool = {
2895
3131
  const ignoreGlob = DEFAULT_IGNORE.map((p) => new Bun.Glob(p));
2896
3132
  const allMatches = [];
2897
3133
  let truncated = false;
3134
+ const ig = await loadGitignore(cwd);
2898
3135
  outer:
2899
3136
  for await (const relPath of fileGlob.scan({
2900
3137
  cwd,
2901
- onlyFiles: true
3138
+ onlyFiles: true,
3139
+ dot: true
2902
3140
  })) {
2903
- if (ignoreGlob.some((g) => g.match(relPath) || g.match(relPath.split("/")[0] ?? ""))) {
3141
+ if (ig?.ignores(relPath))
3142
+ continue;
3143
+ const firstSegment = relPath.split("/")[0] ?? "";
3144
+ if (ignoreGlob.some((g) => g.match(relPath) || g.match(firstSegment))) {
2904
3145
  continue;
2905
3146
  }
2906
- const fullPath = join9(cwd, relPath);
3147
+ const fullPath = join11(cwd, relPath);
2907
3148
  let text;
2908
3149
  try {
2909
3150
  text = await Bun.file(fullPath).text();
@@ -2954,8 +3195,8 @@ var grepTool = {
2954
3195
 
2955
3196
  // src/tools/hooks.ts
2956
3197
  import { constants, accessSync } from "fs";
2957
- import { homedir as homedir6 } from "os";
2958
- import { join as join10 } from "path";
3198
+ import { homedir as homedir7 } from "os";
3199
+ import { join as join12 } from "path";
2959
3200
  function isExecutable(filePath) {
2960
3201
  try {
2961
3202
  accessSync(filePath, constants.X_OK);
@@ -2967,8 +3208,8 @@ function isExecutable(filePath) {
2967
3208
  function findHook(toolName, cwd) {
2968
3209
  const scriptName = `post-${toolName}`;
2969
3210
  const candidates = [
2970
- join10(cwd, ".agents", "hooks", scriptName),
2971
- join10(homedir6(), ".agents", "hooks", scriptName)
3211
+ join12(cwd, ".agents", "hooks", scriptName),
3212
+ join12(homedir7(), ".agents", "hooks", scriptName)
2972
3213
  ];
2973
3214
  for (const p of candidates) {
2974
3215
  if (isExecutable(p))
@@ -3057,13 +3298,13 @@ function hookEnvForRead(input, cwd) {
3057
3298
  }
3058
3299
 
3059
3300
  // src/tools/insert.ts
3060
- import { join as join11, relative as relative4 } from "path";
3061
- import { z as z5 } from "zod";
3062
- var InsertSchema = z5.object({
3063
- path: z5.string().describe("File path to edit (absolute or relative to cwd)"),
3064
- anchor: z5.string().describe('Anchor line from a prior read/grep, e.g. "11:a3"'),
3065
- position: z5.enum(["before", "after"]).describe('Insert the content "before" or "after" the anchor line'),
3066
- content: z5.string().describe("Text to insert")
3301
+ import { join as join13, relative as relative4 } from "path";
3302
+ import { z as z6 } from "zod";
3303
+ var InsertSchema = z6.object({
3304
+ path: z6.string().describe("File path to edit (absolute or relative to cwd)"),
3305
+ anchor: z6.string().describe('Anchor line from a prior read/grep, e.g. "11:a3"'),
3306
+ position: z6.enum(["before", "after"]).describe('Insert the content "before" or "after" the anchor line'),
3307
+ content: z6.string().describe("Text to insert")
3067
3308
  });
3068
3309
  var HASH_NOT_FOUND_ERROR = "Hash not found. Re-read the file to get current anchors.";
3069
3310
  var insertTool = {
@@ -3072,7 +3313,7 @@ var insertTool = {
3072
3313
  schema: InsertSchema,
3073
3314
  execute: async (input) => {
3074
3315
  const cwd = input.cwd ?? process.cwd();
3075
- const filePath = input.path.startsWith("/") ? input.path : join11(cwd, input.path);
3316
+ const filePath = input.path.startsWith("/") ? input.path : join13(cwd, input.path);
3076
3317
  const relPath = relative4(cwd, filePath);
3077
3318
  const file = Bun.file(filePath);
3078
3319
  if (!await file.exists()) {
@@ -3117,12 +3358,12 @@ function parseAnchor(value) {
3117
3358
  }
3118
3359
 
3119
3360
  // src/tools/read.ts
3120
- import { join as join12, relative as relative5 } from "path";
3121
- import { z as z6 } from "zod";
3122
- var ReadSchema = z6.object({
3123
- path: z6.string().describe("File path to read (absolute or relative to cwd)"),
3124
- line: z6.number().int().min(1).optional().describe("1-indexed starting line (default: 1)"),
3125
- count: z6.number().int().min(1).max(500).optional().describe("Lines to read (default: 500, max: 500)")
3361
+ import { join as join14, relative as relative5 } from "path";
3362
+ import { z as z7 } from "zod";
3363
+ var ReadSchema = z7.object({
3364
+ path: z7.string().describe("File path to read (absolute or relative to cwd)"),
3365
+ line: z7.number().int().min(1).optional().describe("1-indexed starting line (default: 1)"),
3366
+ count: z7.number().int().min(1).max(500).optional().describe("Lines to read (default: 500, max: 500)")
3126
3367
  });
3127
3368
  var MAX_COUNT = 500;
3128
3369
  var MAX_BYTES = 1e6;
@@ -3132,7 +3373,7 @@ var readTool = {
3132
3373
  schema: ReadSchema,
3133
3374
  execute: async (input) => {
3134
3375
  const cwd = input.cwd ?? process.cwd();
3135
- const filePath = input.path.startsWith("/") ? input.path : join12(cwd, input.path);
3376
+ const filePath = input.path.startsWith("/") ? input.path : join14(cwd, input.path);
3136
3377
  const file = Bun.file(filePath);
3137
3378
  const exists = await file.exists();
3138
3379
  if (!exists) {
@@ -3165,13 +3406,13 @@ var readTool = {
3165
3406
  };
3166
3407
 
3167
3408
  // src/tools/replace.ts
3168
- import { join as join13, relative as relative6 } from "path";
3169
- import { z as z7 } from "zod";
3170
- var ReplaceSchema = z7.object({
3171
- path: z7.string().describe("File path to edit (absolute or relative to cwd)"),
3172
- startAnchor: z7.string().describe('Start anchor from a prior read/grep, e.g. "11:a3"'),
3173
- endAnchor: z7.string().optional().describe('End anchor (inclusive), e.g. "33:0e". Omit to target only the startAnchor line.'),
3174
- newContent: z7.string().optional().describe("Replacement text. Omit or pass empty string to delete the range.")
3409
+ import { join as join15, relative as relative6 } from "path";
3410
+ import { z as z8 } from "zod";
3411
+ var ReplaceSchema = z8.object({
3412
+ path: z8.string().describe("File path to edit (absolute or relative to cwd)"),
3413
+ startAnchor: z8.string().describe('Start anchor from a prior read/grep, e.g. "11:a3"'),
3414
+ endAnchor: z8.string().optional().describe('End anchor (inclusive), e.g. "33:0e". Omit to target only the startAnchor line.'),
3415
+ newContent: z8.string().optional().describe("Replacement text. Omit or pass empty string to delete the range.")
3175
3416
  });
3176
3417
  var HASH_NOT_FOUND_ERROR2 = "Hash not found. Re-read the file to get current anchors.";
3177
3418
  var replaceTool = {
@@ -3180,7 +3421,7 @@ var replaceTool = {
3180
3421
  schema: ReplaceSchema,
3181
3422
  execute: async (input) => {
3182
3423
  const cwd = input.cwd ?? process.cwd();
3183
- const filePath = input.path.startsWith("/") ? input.path : join13(cwd, input.path);
3424
+ const filePath = input.path.startsWith("/") ? input.path : join15(cwd, input.path);
3184
3425
  const relPath = relative6(cwd, filePath);
3185
3426
  const file = Bun.file(filePath);
3186
3427
  if (!await file.exists()) {
@@ -3238,11 +3479,11 @@ function parseAnchor2(value, name) {
3238
3479
  }
3239
3480
 
3240
3481
  // src/tools/shell.ts
3241
- import { z as z8 } from "zod";
3242
- var ShellSchema = z8.object({
3243
- command: z8.string().describe("Shell command to execute"),
3244
- timeout: z8.number().int().min(1000).max(300000).optional().default(30000).describe("Timeout in milliseconds (default: 30s, max: 5min)"),
3245
- env: z8.record(z8.string(), z8.string()).optional().describe("Additional environment variables to set")
3482
+ import { z as z9 } from "zod";
3483
+ var ShellSchema = z9.object({
3484
+ command: z9.string().describe("Shell command to execute"),
3485
+ timeout: z9.number().int().min(1000).max(300000).optional().default(30000).describe("Timeout in milliseconds (default: 30s, max: 5min)"),
3486
+ env: z9.record(z9.string(), z9.string()).optional().describe("Additional environment variables to set")
3246
3487
  });
3247
3488
  var MAX_OUTPUT_BYTES = 1e4;
3248
3489
  var shellTool = {
@@ -3254,6 +3495,7 @@ var shellTool = {
3254
3495
  const timeout = input.timeout ?? 30000;
3255
3496
  const env = Object.assign({}, process.env, input.env ?? {});
3256
3497
  let timedOut = false;
3498
+ const wasRaw = process.stdin.isTTY ? process.stdin.isRaw : false;
3257
3499
  const proc = Bun.spawn(["bash", "-c", input.command], {
3258
3500
  cwd,
3259
3501
  env,
@@ -3307,6 +3549,11 @@ var shellTool = {
3307
3549
  } finally {
3308
3550
  clearTimeout(timer);
3309
3551
  restoreTerminal();
3552
+ if (wasRaw) {
3553
+ try {
3554
+ process.stdin.setRawMode(true);
3555
+ } catch {}
3556
+ }
3310
3557
  }
3311
3558
  return {
3312
3559
  stdout: stdout.trimEnd(),
@@ -3319,12 +3566,12 @@ var shellTool = {
3319
3566
  };
3320
3567
 
3321
3568
  // src/tools/subagent.ts
3322
- import { z as z9 } from "zod";
3323
- var SubagentInput = z9.object({
3324
- prompt: z9.string().describe("The task or question to give the subagent"),
3325
- agentName: z9.string().optional().describe("Name of a custom agent to use (from .agents/agents/). Omit to use a generic subagent.")
3569
+ import { z as z10 } from "zod";
3570
+ var SubagentInput = z10.object({
3571
+ prompt: z10.string().describe("The task or question to give the subagent"),
3572
+ agentName: z10.string().optional().describe("Name of a custom agent to use (from .agents/agents/). Omit to use a generic subagent.")
3326
3573
  });
3327
- function createSubagentTool(runSubagent, availableAgents) {
3574
+ function createSubagentTool(runSubagent, availableAgents, parentLabel) {
3328
3575
  const agentSection = availableAgents.size > 0 ? `
3329
3576
 
3330
3577
  When the user's message contains @<agent-name>, delegate to that agent by setting agentName to the exact agent name. Available custom agents: ${[...availableAgents.entries()].map(([name, cfg]) => `"${name}" (${cfg.description})`).join(", ")}.` : "";
@@ -3333,7 +3580,7 @@ When the user's message contains @<agent-name>, delegate to that agent by settin
3333
3580
  description: `Spawn a sub-agent to handle a focused subtask. Use this for parallel exploration, specialised analysis, or tasks that benefit from a fresh context window. The subagent has access to all the same tools.${agentSection}`,
3334
3581
  schema: SubagentInput,
3335
3582
  execute: async (input) => {
3336
- return runSubagent(input.prompt, input.agentName);
3583
+ return runSubagent(input.prompt, input.agentName, parentLabel);
3337
3584
  }
3338
3585
  };
3339
3586
  }
@@ -3382,7 +3629,7 @@ function buildToolSet(opts) {
3382
3629
  const { cwd, onHook } = opts;
3383
3630
  const depth = opts.depth ?? 0;
3384
3631
  const lookupHook = createHookCache(HOOKABLE_TOOLS, cwd);
3385
- return [
3632
+ const tools = [
3386
3633
  withHooks(withCwdDefault(globTool, cwd), lookupHook, cwd, (_, input) => hookEnvForGlob(input, cwd), onHook),
3387
3634
  withHooks(withCwdDefault(grepTool, cwd), lookupHook, cwd, (_, input) => hookEnvForGrep(input, cwd), onHook),
3388
3635
  withHooks(withCwdDefault(readTool, cwd), lookupHook, cwd, (_, input) => hookEnvForRead(input, cwd), onHook),
@@ -3394,17 +3641,25 @@ function buildToolSet(opts) {
3394
3641
  if (depth >= MAX_SUBAGENT_DEPTH) {
3395
3642
  throw new Error(`Subagent depth limit reached (max ${MAX_SUBAGENT_DEPTH}). ` + `Cannot spawn another subagent from depth ${depth}.`);
3396
3643
  }
3397
- return opts.runSubagent(prompt, depth + 1, agentName);
3398
- }, opts.availableAgents)
3644
+ return opts.runSubagent(prompt, depth + 1, agentName, undefined, opts.parentLabel);
3645
+ }, opts.availableAgents, opts.parentLabel)
3399
3646
  ];
3647
+ if (process.env.EXA_API_KEY) {
3648
+ tools.push(webSearchTool, webContentTool);
3649
+ }
3650
+ return tools;
3400
3651
  }
3401
3652
  function buildReadOnlyToolSet(opts) {
3402
3653
  const { cwd } = opts;
3403
- return [
3654
+ const tools = [
3404
3655
  withCwdDefault(globTool, cwd),
3405
3656
  withCwdDefault(grepTool, cwd),
3406
3657
  withCwdDefault(readTool, cwd)
3407
3658
  ];
3659
+ if (process.env.EXA_API_KEY) {
3660
+ tools.push(webSearchTool, webContentTool);
3661
+ }
3662
+ return tools;
3408
3663
  }
3409
3664
 
3410
3665
  // src/agent/agent.ts
@@ -3426,9 +3681,9 @@ async function getGitBranch(cwd) {
3426
3681
  }
3427
3682
  function loadContextFile(cwd) {
3428
3683
  const candidates = [
3429
- join14(cwd, "AGENTS.md"),
3430
- join14(cwd, "CLAUDE.md"),
3431
- join14(getConfigDir(), "AGENTS.md")
3684
+ join16(cwd, "AGENTS.md"),
3685
+ join16(cwd, "CLAUDE.md"),
3686
+ join16(getConfigDir(), "AGENTS.md")
3432
3687
  ];
3433
3688
  for (const p of candidates) {
3434
3689
  if (existsSync6(p)) {
@@ -3505,7 +3760,9 @@ async function runAgent(opts) {
3505
3760
  }
3506
3761
  let turnIndex = getMaxTurnIndex(session.id) + 1;
3507
3762
  const coreHistory = [...session.messages];
3508
- const runSubagent = async (prompt, depth = 0, agentName, modelOverride) => {
3763
+ let nextLaneId = 1;
3764
+ const activeLanes = new Set;
3765
+ const runSubagent = async (prompt, depth = 0, agentName, modelOverride, parentLabel) => {
3509
3766
  const allAgents = loadAgents(cwd);
3510
3767
  const agentConfig = agentName ? allAgents.get(agentName) : undefined;
3511
3768
  if (agentName && !agentConfig) {
@@ -3514,19 +3771,21 @@ async function runAgent(opts) {
3514
3771
  const model = modelOverride ?? agentConfig?.model ?? currentModel;
3515
3772
  const systemPrompt = agentConfig?.systemPrompt ?? buildSystemPrompt(cwd);
3516
3773
  const subMessages = [{ role: "user", content: prompt }];
3774
+ const laneId = nextLaneId++;
3775
+ activeLanes.add(laneId);
3776
+ const laneLabel = formatSubagentLabel(laneId, parentLabel);
3517
3777
  const subTools = buildToolSet({
3518
3778
  cwd,
3519
3779
  depth,
3520
3780
  runSubagent,
3521
3781
  onHook: renderHook,
3522
- availableAgents: allAgents
3782
+ availableAgents: allAgents,
3783
+ parentLabel: laneLabel
3523
3784
  });
3524
3785
  const subLlm = resolveModel(model);
3525
3786
  let result = "";
3526
3787
  let inputTokens = 0;
3527
3788
  let outputTokens = 0;
3528
- const activity = [];
3529
- const pendingCalls = new Map;
3530
3789
  const events = runTurn({
3531
3790
  model: subLlm,
3532
3791
  messages: subMessages,
@@ -3534,32 +3793,18 @@ async function runAgent(opts) {
3534
3793
  systemPrompt
3535
3794
  });
3536
3795
  for await (const event of events) {
3796
+ spinner.stop();
3797
+ renderSubagentEvent(event, { laneId, parentLabel, activeLanes });
3798
+ spinner.start("thinking");
3537
3799
  if (event.type === "text-delta")
3538
3800
  result += event.delta;
3539
- if (event.type === "tool-call-start") {
3540
- pendingCalls.set(event.toolCallId, {
3541
- toolName: event.toolName,
3542
- args: event.args
3543
- });
3544
- }
3545
- if (event.type === "tool-result") {
3546
- const pending = pendingCalls.get(event.toolCallId);
3547
- if (pending) {
3548
- pendingCalls.delete(event.toolCallId);
3549
- activity.push({
3550
- toolName: pending.toolName,
3551
- args: pending.args,
3552
- result: event.result,
3553
- isError: event.isError
3554
- });
3555
- }
3556
- }
3557
3801
  if (event.type === "turn-complete") {
3558
3802
  inputTokens = event.inputTokens;
3559
3803
  outputTokens = event.outputTokens;
3560
3804
  }
3561
3805
  }
3562
- return { result, inputTokens, outputTokens, activity };
3806
+ activeLanes.delete(laneId);
3807
+ return { result, inputTokens, outputTokens };
3563
3808
  };
3564
3809
  const agents = loadAgents(cwd);
3565
3810
  const tools = buildToolSet({
@@ -3746,12 +3991,10 @@ ${out}
3746
3991
  async function processUserInput(text, pastedImages = []) {
3747
3992
  const abortController = new AbortController;
3748
3993
  let wasAborted = false;
3749
- const onSigInt = () => {
3994
+ abortController.signal.addEventListener("abort", () => {
3750
3995
  wasAborted = true;
3751
- abortController.abort();
3752
- process.removeListener("SIGINT", onSigInt);
3753
- };
3754
- process.on("SIGINT", onSigInt);
3996
+ });
3997
+ const stopWatcher = watchForInterrupt(abortController);
3755
3998
  const { text: resolvedText, images: refImages } = await resolveFileRefs(text, cwd);
3756
3999
  const allImages = [...pastedImages, ...refImages];
3757
4000
  const thisTurn = turnIndex++;
@@ -3773,7 +4016,7 @@ ${out}
3773
4016
  ]
3774
4017
  } : { role: "user", content: coreContent };
3775
4018
  if (wasAborted) {
3776
- process.removeListener("SIGINT", onSigInt);
4019
+ stopWatcher();
3777
4020
  const stubMsg = {
3778
4021
  role: "assistant",
3779
4022
  content: "[interrupted]"
@@ -3839,7 +4082,7 @@ ${out}
3839
4082
  rollbackTurn();
3840
4083
  throw err;
3841
4084
  } finally {
3842
- process.removeListener("SIGINT", onSigInt);
4085
+ stopWatcher();
3843
4086
  if (wasAborted)
3844
4087
  ralphMode = false;
3845
4088
  }
@@ -3903,7 +4146,7 @@ ${skill.content}
3903
4146
  result = result.slice(0, match.index) + replacement + result.slice((match.index ?? 0) + match[0].length);
3904
4147
  continue;
3905
4148
  }
3906
- const filePath = ref.startsWith("/") ? ref : join14(cwd, ref);
4149
+ const filePath = ref.startsWith("/") ? ref : join16(cwd, ref);
3907
4150
  if (isImageFilename(ref)) {
3908
4151
  const attachment = await loadImageFile(filePath);
3909
4152
  if (attachment) {
@@ -3931,6 +4174,7 @@ ${preview}
3931
4174
 
3932
4175
  // src/index.ts
3933
4176
  registerTerminalCleanup();
4177
+ initErrorLog();
3934
4178
  function parseArgs(argv) {
3935
4179
  const args = {
3936
4180
  model: null,
@@ -4049,11 +4293,11 @@ async function main() {
4049
4293
  agentOpts.initialPrompt = args.prompt;
4050
4294
  await runAgent(agentOpts);
4051
4295
  } catch (err) {
4052
- renderError(err);
4296
+ renderError(err, "agent");
4053
4297
  process.exit(1);
4054
4298
  }
4055
4299
  }
4056
4300
  main().catch((err) => {
4057
- console.error(err);
4301
+ renderError(err, "main");
4058
4302
  process.exit(1);
4059
4303
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mini-coder",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "A small, fast CLI coding agent",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -22,6 +22,7 @@
22
22
  "@ai-sdk/openai-compatible": "^2.0.30",
23
23
  "@modelcontextprotocol/sdk": "^1.26.0",
24
24
  "ai": "^6.0.94",
25
+ "ignore": "^7.0.5",
25
26
  "ollama-ai-provider": "^1.2.0",
26
27
  "yoctocolors": "^2.1.1",
27
28
  "zod": "^4.3.6"
package/better-errors.md DELETED
@@ -1,96 +0,0 @@
1
- # Better Errors — Implementation Plan
2
-
3
- ## Overview
4
-
5
- Add structured error logging to file and friendly error parsing for display. Three concerns: (1) log full error details to `~/.config/mini-coder/errors.log`, (2) map known AI SDK errors to terse user-facing messages, (3) wire both into existing error surfaces.
6
-
7
- ---
8
-
9
- ## Files to Create
10
-
11
- | File | Purpose |
12
- |---|---|
13
- | `src/cli/error-log.ts` | `initErrorLog()`, `logError()` |
14
- | `src/cli/error-parse.ts` | `parseAppError()` |
15
-
16
- ## Files to Modify
17
-
18
- | File | Change |
19
- |---|---|
20
- | `src/index.ts` | Call `initErrorLog()` at startup; use `logError` + `parseAppError` in top-level catch and `main().catch()` |
21
- | `src/cli/output.ts` | Update `renderError(err: unknown)` to call log + parse; update `turn-error` branch in `renderTurn()` |
22
- | `src/agent/agent.ts` | Pass `unknown` to `renderError()` — no logic change needed if signature widens correctly |
23
-
24
- ---
25
-
26
- ## Implementation Steps
27
-
28
- 1. **Create `src/cli/error-log.ts`**
29
- - Module-level `let writer: ReturnType<ReturnType<typeof Bun.file>['writer']> | null = null`
30
- - `initErrorLog()`: if `writer` is not null, return early (idempotency). Otherwise resolve path `~/.config/mini-coder/errors.log`, open with `Bun.file(path).writer()` (truncates on open), assign to `writer`. Register `process.on('uncaughtException', (err) => { logError(err, 'uncaught'); process.exit(1) })`.
31
- - `logError(err: unknown, context?: string)`: if `writer` is null, return. Build log entry string (see Log Format), call `writer.write(entry)`. Keep sync-ish by not awaiting — `write()` on a Bun file writer is buffered; call `writer.flush()` after each write so data lands before a crash.
32
- - Extract error fields via type-narrowing helpers (not exported): `isObject(err)`, read `.name`, `.message`, `.stack`, `.statusCode`, `.url`, `.isRetryable` defensively.
33
-
34
- 2. **Create `src/cli/error-parse.ts`**
35
- - Import AI SDK error classes: `APICallError`, `RetryError`, `NoContentGeneratedError`, `LoadAPIKeyError`, `NoSuchModelError` from `ai`.
36
- - Export `parseAppError(err: unknown): { headline: string; hint?: string }`.
37
- - Implement as a chain of `instanceof` checks (see Error Parse Table). For `RetryError`, recurse on `.lastError` and prepend `"Retries exhausted: "` to `headline`.
38
- - Fallback: extract first non-empty line of `(err as any)?.message ?? String(err)`, no hint.
39
- - Network check: before the fallback, check if `(err as any)?.code === 'ECONNREFUSED'` or message includes `'ECONNREFUSED'`.
40
-
41
- 3. **Update `src/cli/output.ts`**
42
- - Change `renderError` signature from `(msg: string)` to `(err: unknown)`. Inside: call `logError(err, 'render')`, call `parseAppError(err)`, print `✖ red(headline)`, if `hint` print a dim indented hint line (e.g. ` dim(hint)`).
43
- - In `renderTurn()` `turn-error` branch (non-abort path): replace raw `event.error.message` display with `logError(event.error, 'turn')` then `parseAppError(event.error)` → print `✖ red(headline)`, optional dim hint. Keep the abort quiet-note branch unchanged.
44
- - All callers of `renderError` that currently pass a string (e.g. in `agent.ts`) — check each call site; if passing a plain string, wrap in `new Error(string)` or let `parseAppError` fallback handle a string gracefully (add string branch at top of `parseAppError`: `if (typeof err === 'string') return { headline: err }`).
45
-
46
- 4. **Update `src/index.ts`**
47
- - After `registerTerminalCleanup()` (or equivalent startup call), add `initErrorLog()`.
48
- - Top-level `catch` around `runAgent()`: replace any raw print with `logError(err, 'agent')` + `parseAppError(err)` → print `✖ red(headline)` + dim hint, then `process.exit(1)`.
49
- - `main().catch()`: same pattern — `logError(err, 'main')` + parse + print + `process.exit(1)`. Remove bare `console.error(err)`.
50
-
51
- 5. **Tests (`src/cli/error-parse.test.ts`)**
52
- - Test `parseAppError` for each mapped error type.
53
- - Construct real instances where possible (e.g. `new APICallError({ ... })`); check `headline` and `hint` values.
54
- - Test `RetryError` unwrapping.
55
- - Test fallback for plain `Error` and plain string.
56
- - No mocks, no file I/O, no server calls.
57
-
58
- ---
59
-
60
- ## Error Parse Table
61
-
62
- | Condition | `headline` | `hint` |
63
- |---|---|---|
64
- | `APICallError` with `statusCode === 429` | `"Rate limit hit"` | `"Wait a moment and retry, or switch model with /model"` |
65
- | `APICallError` with `statusCode === 401 \|\| 403` | `"Auth failed"` | `"Check the relevant provider API key env var"` |
66
- | `APICallError` other | `"API error \${statusCode}"` | `url` if present |
67
- | `RetryError` | `"Retries exhausted: \${inner.headline}"` | inner `hint` |
68
- | `NoContentGeneratedError` | `"Model returned empty response"` | `"Try rephrasing or switching model with /model"` |
69
- | `LoadAPIKeyError` | `"API key not found"` | `"Set the relevant provider env var"` |
70
- | `NoSuchModelError` | `"Model not found"` | `"Use /model to pick a valid model"` |
71
- | `code === 'ECONNREFUSED'` or message contains `'ECONNREFUSED'` | `"Connection failed"` | `"Check network or local server"` |
72
- | `string` input | string value | — |
73
- | fallback | first line of `err.message` | — |
74
-
75
- > `AbortError` is never passed to `parseAppError` — abort is handled at the call sites before reaching these functions.
76
-
77
- ---
78
-
79
- ## Log Format
80
-
81
- ```
82
- [2026-02-25T22:38:53.123Z] context=turn
83
- name: APICallError
84
- message: 429 Too Many Requests
85
- statusCode: 429
86
- url: https://api.anthropic.com/v1/messages
87
- isRetryable: true
88
- stack: APICallError: 429 Too Many Requests
89
- at ...
90
- ---
91
- ```
92
-
93
- - Each entry ends with `---\n`.
94
- - Extra fields (`statusCode`, `url`, `isRetryable`) are only emitted if present on the error object.
95
- - Stack is indented two spaces per line.
96
- - File is truncated on each app start (writer opened without append flag).