mini-coder 0.0.7 → 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 +403 -173
  2. package/package.json +1 -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 join15 } 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.0.6";
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,15 +2993,15 @@ var createTool = {
2766
2993
  };
2767
2994
 
2768
2995
  // src/tools/glob.ts
2769
- import { join as join9, relative as relative3 } from "path";
2770
- import { z as z3 } from "zod";
2996
+ import { join as join10, relative as relative3 } from "path";
2997
+ import { z as z4 } from "zod";
2771
2998
 
2772
2999
  // src/tools/ignore.ts
2773
- import { join as join8 } from "path";
3000
+ import { join as join9 } from "path";
2774
3001
  import ignore from "ignore";
2775
3002
  async function loadGitignore(cwd) {
2776
3003
  try {
2777
- const gitignore = await Bun.file(join8(cwd, ".gitignore")).text();
3004
+ const gitignore = await Bun.file(join9(cwd, ".gitignore")).text();
2778
3005
  return ignore().add(gitignore);
2779
3006
  } catch {
2780
3007
  return null;
@@ -2782,9 +3009,9 @@ async function loadGitignore(cwd) {
2782
3009
  }
2783
3010
 
2784
3011
  // src/tools/glob.ts
2785
- var GlobSchema = z3.object({
2786
- pattern: z3.string().describe("Glob pattern to match files against, e.g. '**/*.ts'"),
2787
- ignore: z3.array(z3.string()).optional().describe("Glob patterns to exclude")
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")
2788
3015
  });
2789
3016
  var MAX_RESULTS = 500;
2790
3017
  var globTool = {
@@ -2807,7 +3034,7 @@ var globTool = {
2807
3034
  if (ignored)
2808
3035
  continue;
2809
3036
  try {
2810
- const fullPath = join9(cwd, file);
3037
+ const fullPath = join10(cwd, file);
2811
3038
  const stat = await Bun.file(fullPath).stat?.() ?? null;
2812
3039
  matches.push({ path: file, mtime: stat?.mtime?.getTime() ?? 0 });
2813
3040
  } catch {
@@ -2820,14 +3047,14 @@ var globTool = {
2820
3047
  if (truncated)
2821
3048
  matches.pop();
2822
3049
  matches.sort((a, b) => b.mtime - a.mtime);
2823
- const files = matches.map((m) => relative3(cwd, join9(cwd, m.path)));
3050
+ const files = matches.map((m) => relative3(cwd, join10(cwd, m.path)));
2824
3051
  return { files, count: files.length, truncated };
2825
3052
  }
2826
3053
  };
2827
3054
 
2828
3055
  // src/tools/grep.ts
2829
- import { join as join10 } from "path";
2830
- import { z as z4 } from "zod";
3056
+ import { join as join11 } from "path";
3057
+ import { z as z5 } from "zod";
2831
3058
 
2832
3059
  // src/tools/hashline.ts
2833
3060
  var FNV_OFFSET_BASIS = 2166136261;
@@ -2873,12 +3100,12 @@ function findLineByHash(lines, hash, hintLine) {
2873
3100
  }
2874
3101
 
2875
3102
  // src/tools/grep.ts
2876
- var GrepSchema = z4.object({
2877
- pattern: z4.string().describe("Regular expression to search for"),
2878
- include: z4.string().optional().describe("Glob pattern to filter files, e.g. '*.ts' or '*.{ts,tsx}'"),
2879
- contextLines: z4.number().int().min(0).max(10).optional().default(2).describe("Lines of context to include around each match"),
2880
- caseSensitive: z4.boolean().optional().default(true),
2881
- 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)
2882
3109
  });
2883
3110
  var DEFAULT_IGNORE = [
2884
3111
  "node_modules",
@@ -2917,7 +3144,7 @@ var grepTool = {
2917
3144
  if (ignoreGlob.some((g) => g.match(relPath) || g.match(firstSegment))) {
2918
3145
  continue;
2919
3146
  }
2920
- const fullPath = join10(cwd, relPath);
3147
+ const fullPath = join11(cwd, relPath);
2921
3148
  let text;
2922
3149
  try {
2923
3150
  text = await Bun.file(fullPath).text();
@@ -2968,8 +3195,8 @@ var grepTool = {
2968
3195
 
2969
3196
  // src/tools/hooks.ts
2970
3197
  import { constants, accessSync } from "fs";
2971
- import { homedir as homedir6 } from "os";
2972
- import { join as join11 } from "path";
3198
+ import { homedir as homedir7 } from "os";
3199
+ import { join as join12 } from "path";
2973
3200
  function isExecutable(filePath) {
2974
3201
  try {
2975
3202
  accessSync(filePath, constants.X_OK);
@@ -2981,8 +3208,8 @@ function isExecutable(filePath) {
2981
3208
  function findHook(toolName, cwd) {
2982
3209
  const scriptName = `post-${toolName}`;
2983
3210
  const candidates = [
2984
- join11(cwd, ".agents", "hooks", scriptName),
2985
- join11(homedir6(), ".agents", "hooks", scriptName)
3211
+ join12(cwd, ".agents", "hooks", scriptName),
3212
+ join12(homedir7(), ".agents", "hooks", scriptName)
2986
3213
  ];
2987
3214
  for (const p of candidates) {
2988
3215
  if (isExecutable(p))
@@ -3071,13 +3298,13 @@ function hookEnvForRead(input, cwd) {
3071
3298
  }
3072
3299
 
3073
3300
  // src/tools/insert.ts
3074
- import { join as join12, relative as relative4 } from "path";
3075
- import { z as z5 } from "zod";
3076
- var InsertSchema = z5.object({
3077
- path: z5.string().describe("File path to edit (absolute or relative to cwd)"),
3078
- anchor: z5.string().describe('Anchor line from a prior read/grep, e.g. "11:a3"'),
3079
- position: z5.enum(["before", "after"]).describe('Insert the content "before" or "after" the anchor line'),
3080
- 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")
3081
3308
  });
3082
3309
  var HASH_NOT_FOUND_ERROR = "Hash not found. Re-read the file to get current anchors.";
3083
3310
  var insertTool = {
@@ -3086,7 +3313,7 @@ var insertTool = {
3086
3313
  schema: InsertSchema,
3087
3314
  execute: async (input) => {
3088
3315
  const cwd = input.cwd ?? process.cwd();
3089
- const filePath = input.path.startsWith("/") ? input.path : join12(cwd, input.path);
3316
+ const filePath = input.path.startsWith("/") ? input.path : join13(cwd, input.path);
3090
3317
  const relPath = relative4(cwd, filePath);
3091
3318
  const file = Bun.file(filePath);
3092
3319
  if (!await file.exists()) {
@@ -3131,12 +3358,12 @@ function parseAnchor(value) {
3131
3358
  }
3132
3359
 
3133
3360
  // src/tools/read.ts
3134
- import { join as join13, relative as relative5 } from "path";
3135
- import { z as z6 } from "zod";
3136
- var ReadSchema = z6.object({
3137
- path: z6.string().describe("File path to read (absolute or relative to cwd)"),
3138
- line: z6.number().int().min(1).optional().describe("1-indexed starting line (default: 1)"),
3139
- 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)")
3140
3367
  });
3141
3368
  var MAX_COUNT = 500;
3142
3369
  var MAX_BYTES = 1e6;
@@ -3146,7 +3373,7 @@ var readTool = {
3146
3373
  schema: ReadSchema,
3147
3374
  execute: async (input) => {
3148
3375
  const cwd = input.cwd ?? process.cwd();
3149
- const filePath = input.path.startsWith("/") ? input.path : join13(cwd, input.path);
3376
+ const filePath = input.path.startsWith("/") ? input.path : join14(cwd, input.path);
3150
3377
  const file = Bun.file(filePath);
3151
3378
  const exists = await file.exists();
3152
3379
  if (!exists) {
@@ -3179,13 +3406,13 @@ var readTool = {
3179
3406
  };
3180
3407
 
3181
3408
  // src/tools/replace.ts
3182
- import { join as join14, relative as relative6 } from "path";
3183
- import { z as z7 } from "zod";
3184
- var ReplaceSchema = z7.object({
3185
- path: z7.string().describe("File path to edit (absolute or relative to cwd)"),
3186
- startAnchor: z7.string().describe('Start anchor from a prior read/grep, e.g. "11:a3"'),
3187
- endAnchor: z7.string().optional().describe('End anchor (inclusive), e.g. "33:0e". Omit to target only the startAnchor line.'),
3188
- 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.")
3189
3416
  });
3190
3417
  var HASH_NOT_FOUND_ERROR2 = "Hash not found. Re-read the file to get current anchors.";
3191
3418
  var replaceTool = {
@@ -3194,7 +3421,7 @@ var replaceTool = {
3194
3421
  schema: ReplaceSchema,
3195
3422
  execute: async (input) => {
3196
3423
  const cwd = input.cwd ?? process.cwd();
3197
- const filePath = input.path.startsWith("/") ? input.path : join14(cwd, input.path);
3424
+ const filePath = input.path.startsWith("/") ? input.path : join15(cwd, input.path);
3198
3425
  const relPath = relative6(cwd, filePath);
3199
3426
  const file = Bun.file(filePath);
3200
3427
  if (!await file.exists()) {
@@ -3252,11 +3479,11 @@ function parseAnchor2(value, name) {
3252
3479
  }
3253
3480
 
3254
3481
  // src/tools/shell.ts
3255
- import { z as z8 } from "zod";
3256
- var ShellSchema = z8.object({
3257
- command: z8.string().describe("Shell command to execute"),
3258
- timeout: z8.number().int().min(1000).max(300000).optional().default(30000).describe("Timeout in milliseconds (default: 30s, max: 5min)"),
3259
- 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")
3260
3487
  });
3261
3488
  var MAX_OUTPUT_BYTES = 1e4;
3262
3489
  var shellTool = {
@@ -3268,6 +3495,7 @@ var shellTool = {
3268
3495
  const timeout = input.timeout ?? 30000;
3269
3496
  const env = Object.assign({}, process.env, input.env ?? {});
3270
3497
  let timedOut = false;
3498
+ const wasRaw = process.stdin.isTTY ? process.stdin.isRaw : false;
3271
3499
  const proc = Bun.spawn(["bash", "-c", input.command], {
3272
3500
  cwd,
3273
3501
  env,
@@ -3321,6 +3549,11 @@ var shellTool = {
3321
3549
  } finally {
3322
3550
  clearTimeout(timer);
3323
3551
  restoreTerminal();
3552
+ if (wasRaw) {
3553
+ try {
3554
+ process.stdin.setRawMode(true);
3555
+ } catch {}
3556
+ }
3324
3557
  }
3325
3558
  return {
3326
3559
  stdout: stdout.trimEnd(),
@@ -3333,12 +3566,12 @@ var shellTool = {
3333
3566
  };
3334
3567
 
3335
3568
  // src/tools/subagent.ts
3336
- import { z as z9 } from "zod";
3337
- var SubagentInput = z9.object({
3338
- prompt: z9.string().describe("The task or question to give the subagent"),
3339
- 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.")
3340
3573
  });
3341
- function createSubagentTool(runSubagent, availableAgents) {
3574
+ function createSubagentTool(runSubagent, availableAgents, parentLabel) {
3342
3575
  const agentSection = availableAgents.size > 0 ? `
3343
3576
 
3344
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(", ")}.` : "";
@@ -3347,7 +3580,7 @@ When the user's message contains @<agent-name>, delegate to that agent by settin
3347
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}`,
3348
3581
  schema: SubagentInput,
3349
3582
  execute: async (input) => {
3350
- return runSubagent(input.prompt, input.agentName);
3583
+ return runSubagent(input.prompt, input.agentName, parentLabel);
3351
3584
  }
3352
3585
  };
3353
3586
  }
@@ -3396,7 +3629,7 @@ function buildToolSet(opts) {
3396
3629
  const { cwd, onHook } = opts;
3397
3630
  const depth = opts.depth ?? 0;
3398
3631
  const lookupHook = createHookCache(HOOKABLE_TOOLS, cwd);
3399
- return [
3632
+ const tools = [
3400
3633
  withHooks(withCwdDefault(globTool, cwd), lookupHook, cwd, (_, input) => hookEnvForGlob(input, cwd), onHook),
3401
3634
  withHooks(withCwdDefault(grepTool, cwd), lookupHook, cwd, (_, input) => hookEnvForGrep(input, cwd), onHook),
3402
3635
  withHooks(withCwdDefault(readTool, cwd), lookupHook, cwd, (_, input) => hookEnvForRead(input, cwd), onHook),
@@ -3408,17 +3641,25 @@ function buildToolSet(opts) {
3408
3641
  if (depth >= MAX_SUBAGENT_DEPTH) {
3409
3642
  throw new Error(`Subagent depth limit reached (max ${MAX_SUBAGENT_DEPTH}). ` + `Cannot spawn another subagent from depth ${depth}.`);
3410
3643
  }
3411
- return opts.runSubagent(prompt, depth + 1, agentName);
3412
- }, opts.availableAgents)
3644
+ return opts.runSubagent(prompt, depth + 1, agentName, undefined, opts.parentLabel);
3645
+ }, opts.availableAgents, opts.parentLabel)
3413
3646
  ];
3647
+ if (process.env.EXA_API_KEY) {
3648
+ tools.push(webSearchTool, webContentTool);
3649
+ }
3650
+ return tools;
3414
3651
  }
3415
3652
  function buildReadOnlyToolSet(opts) {
3416
3653
  const { cwd } = opts;
3417
- return [
3654
+ const tools = [
3418
3655
  withCwdDefault(globTool, cwd),
3419
3656
  withCwdDefault(grepTool, cwd),
3420
3657
  withCwdDefault(readTool, cwd)
3421
3658
  ];
3659
+ if (process.env.EXA_API_KEY) {
3660
+ tools.push(webSearchTool, webContentTool);
3661
+ }
3662
+ return tools;
3422
3663
  }
3423
3664
 
3424
3665
  // src/agent/agent.ts
@@ -3440,9 +3681,9 @@ async function getGitBranch(cwd) {
3440
3681
  }
3441
3682
  function loadContextFile(cwd) {
3442
3683
  const candidates = [
3443
- join15(cwd, "AGENTS.md"),
3444
- join15(cwd, "CLAUDE.md"),
3445
- join15(getConfigDir(), "AGENTS.md")
3684
+ join16(cwd, "AGENTS.md"),
3685
+ join16(cwd, "CLAUDE.md"),
3686
+ join16(getConfigDir(), "AGENTS.md")
3446
3687
  ];
3447
3688
  for (const p of candidates) {
3448
3689
  if (existsSync6(p)) {
@@ -3519,7 +3760,9 @@ async function runAgent(opts) {
3519
3760
  }
3520
3761
  let turnIndex = getMaxTurnIndex(session.id) + 1;
3521
3762
  const coreHistory = [...session.messages];
3522
- 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) => {
3523
3766
  const allAgents = loadAgents(cwd);
3524
3767
  const agentConfig = agentName ? allAgents.get(agentName) : undefined;
3525
3768
  if (agentName && !agentConfig) {
@@ -3528,19 +3771,21 @@ async function runAgent(opts) {
3528
3771
  const model = modelOverride ?? agentConfig?.model ?? currentModel;
3529
3772
  const systemPrompt = agentConfig?.systemPrompt ?? buildSystemPrompt(cwd);
3530
3773
  const subMessages = [{ role: "user", content: prompt }];
3774
+ const laneId = nextLaneId++;
3775
+ activeLanes.add(laneId);
3776
+ const laneLabel = formatSubagentLabel(laneId, parentLabel);
3531
3777
  const subTools = buildToolSet({
3532
3778
  cwd,
3533
3779
  depth,
3534
3780
  runSubagent,
3535
3781
  onHook: renderHook,
3536
- availableAgents: allAgents
3782
+ availableAgents: allAgents,
3783
+ parentLabel: laneLabel
3537
3784
  });
3538
3785
  const subLlm = resolveModel(model);
3539
3786
  let result = "";
3540
3787
  let inputTokens = 0;
3541
3788
  let outputTokens = 0;
3542
- const activity = [];
3543
- const pendingCalls = new Map;
3544
3789
  const events = runTurn({
3545
3790
  model: subLlm,
3546
3791
  messages: subMessages,
@@ -3548,32 +3793,18 @@ async function runAgent(opts) {
3548
3793
  systemPrompt
3549
3794
  });
3550
3795
  for await (const event of events) {
3796
+ spinner.stop();
3797
+ renderSubagentEvent(event, { laneId, parentLabel, activeLanes });
3798
+ spinner.start("thinking");
3551
3799
  if (event.type === "text-delta")
3552
3800
  result += event.delta;
3553
- if (event.type === "tool-call-start") {
3554
- pendingCalls.set(event.toolCallId, {
3555
- toolName: event.toolName,
3556
- args: event.args
3557
- });
3558
- }
3559
- if (event.type === "tool-result") {
3560
- const pending = pendingCalls.get(event.toolCallId);
3561
- if (pending) {
3562
- pendingCalls.delete(event.toolCallId);
3563
- activity.push({
3564
- toolName: pending.toolName,
3565
- args: pending.args,
3566
- result: event.result,
3567
- isError: event.isError
3568
- });
3569
- }
3570
- }
3571
3801
  if (event.type === "turn-complete") {
3572
3802
  inputTokens = event.inputTokens;
3573
3803
  outputTokens = event.outputTokens;
3574
3804
  }
3575
3805
  }
3576
- return { result, inputTokens, outputTokens, activity };
3806
+ activeLanes.delete(laneId);
3807
+ return { result, inputTokens, outputTokens };
3577
3808
  };
3578
3809
  const agents = loadAgents(cwd);
3579
3810
  const tools = buildToolSet({
@@ -3760,12 +3991,10 @@ ${out}
3760
3991
  async function processUserInput(text, pastedImages = []) {
3761
3992
  const abortController = new AbortController;
3762
3993
  let wasAborted = false;
3763
- const onSigInt = () => {
3994
+ abortController.signal.addEventListener("abort", () => {
3764
3995
  wasAborted = true;
3765
- abortController.abort();
3766
- process.removeListener("SIGINT", onSigInt);
3767
- };
3768
- process.on("SIGINT", onSigInt);
3996
+ });
3997
+ const stopWatcher = watchForInterrupt(abortController);
3769
3998
  const { text: resolvedText, images: refImages } = await resolveFileRefs(text, cwd);
3770
3999
  const allImages = [...pastedImages, ...refImages];
3771
4000
  const thisTurn = turnIndex++;
@@ -3787,7 +4016,7 @@ ${out}
3787
4016
  ]
3788
4017
  } : { role: "user", content: coreContent };
3789
4018
  if (wasAborted) {
3790
- process.removeListener("SIGINT", onSigInt);
4019
+ stopWatcher();
3791
4020
  const stubMsg = {
3792
4021
  role: "assistant",
3793
4022
  content: "[interrupted]"
@@ -3853,7 +4082,7 @@ ${out}
3853
4082
  rollbackTurn();
3854
4083
  throw err;
3855
4084
  } finally {
3856
- process.removeListener("SIGINT", onSigInt);
4085
+ stopWatcher();
3857
4086
  if (wasAborted)
3858
4087
  ralphMode = false;
3859
4088
  }
@@ -3917,7 +4146,7 @@ ${skill.content}
3917
4146
  result = result.slice(0, match.index) + replacement + result.slice((match.index ?? 0) + match[0].length);
3918
4147
  continue;
3919
4148
  }
3920
- const filePath = ref.startsWith("/") ? ref : join15(cwd, ref);
4149
+ const filePath = ref.startsWith("/") ? ref : join16(cwd, ref);
3921
4150
  if (isImageFilename(ref)) {
3922
4151
  const attachment = await loadImageFile(filePath);
3923
4152
  if (attachment) {
@@ -3945,6 +4174,7 @@ ${preview}
3945
4174
 
3946
4175
  // src/index.ts
3947
4176
  registerTerminalCleanup();
4177
+ initErrorLog();
3948
4178
  function parseArgs(argv) {
3949
4179
  const args = {
3950
4180
  model: null,
@@ -4063,11 +4293,11 @@ async function main() {
4063
4293
  agentOpts.initialPrompt = args.prompt;
4064
4294
  await runAgent(agentOpts);
4065
4295
  } catch (err) {
4066
- renderError(err);
4296
+ renderError(err, "agent");
4067
4297
  process.exit(1);
4068
4298
  }
4069
4299
  }
4070
4300
  main().catch((err) => {
4071
- console.error(err);
4301
+ renderError(err, "main");
4072
4302
  process.exit(1);
4073
4303
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mini-coder",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "description": "A small, fast CLI coding agent",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
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).