min-agent 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +146 -18
  2. package/dist/agent.js +293 -408
  3. package/dist/assistant-stream.js +11 -7
  4. package/dist/cli.js +403 -140
  5. package/dist/clipboard.js +59 -23
  6. package/dist/code-mode.js +3 -3
  7. package/dist/compaction.js +182 -81
  8. package/dist/config.js +186 -35
  9. package/dist/confirm.js +55 -6
  10. package/dist/context-window.js +67 -54
  11. package/dist/doom-loop.js +19 -12
  12. package/dist/http.js +119 -0
  13. package/dist/instructions.js +51 -33
  14. package/dist/logger.js +66 -0
  15. package/dist/markdown.js +3 -44
  16. package/dist/mcp.js +547 -100
  17. package/dist/memory.js +48 -6
  18. package/dist/output.js +36 -27
  19. package/dist/paste-handler.js +3 -3
  20. package/dist/plugins.js +33 -6
  21. package/dist/pricing.js +119 -0
  22. package/dist/provider.js +17 -15
  23. package/dist/serve.js +658 -369
  24. package/dist/sessions.js +151 -13
  25. package/dist/skills.js +466 -76
  26. package/dist/synthetic.js +7 -0
  27. package/dist/title-gen.js +2 -1
  28. package/dist/tool-display.js +173 -0
  29. package/dist/tool-output.js +54 -45
  30. package/dist/tools/apply_patch.js +191 -0
  31. package/dist/tools/backend.js +61 -0
  32. package/dist/tools/bash.js +147 -70
  33. package/dist/tools/code_search.js +6 -5
  34. package/dist/tools/edit.js +23 -7
  35. package/dist/tools/explore.js +80 -12
  36. package/dist/tools/glob.js +3 -3
  37. package/dist/tools/grep.js +146 -14
  38. package/dist/tools/index.js +7 -7
  39. package/dist/tools/question.js +4 -22
  40. package/dist/tools/read.js +71 -11
  41. package/dist/tools/task.js +33 -20
  42. package/dist/tools/todo.js +83 -73
  43. package/dist/tools/web_fetch.js +150 -46
  44. package/dist/tools/web_search.js +706 -28
  45. package/dist/tools/write.js +13 -7
  46. package/dist/tui/App.js +40 -6
  47. package/dist/tui/ConfirmBar.js +24 -3
  48. package/dist/tui/InputBar.js +390 -45
  49. package/dist/tui/MessageList.js +533 -20
  50. package/dist/tui/ModelPicker.js +108 -0
  51. package/dist/tui/QuestionBar.js +104 -0
  52. package/dist/tui/StatusBar.js +19 -11
  53. package/dist/tui/agent-runner.js +103 -0
  54. package/dist/tui/caret-pos.js +134 -0
  55. package/dist/tui/caret.js +69 -0
  56. package/dist/tui/diff-view.js +61 -0
  57. package/dist/tui/drag-state.js +44 -0
  58. package/dist/tui/index.js +153 -24
  59. package/dist/tui/input-history.js +44 -0
  60. package/dist/tui/layout.js +17 -0
  61. package/dist/tui/mouse.js +46 -0
  62. package/dist/tui/selection.js +134 -0
  63. package/dist/tui/slash-commands.js +90 -0
  64. package/dist/tui/slash-handler.js +370 -0
  65. package/dist/tui/text-width.js +91 -0
  66. package/dist/tui/theme.js +12 -0
  67. package/dist/tui/undo-stack.js +14 -0
  68. package/dist/tui/use-sgr-mouse.js +27 -0
  69. package/dist/tui-chat.js +111 -331
  70. package/dist/updater.js +57 -0
  71. package/docs/API.md +160 -14
  72. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
  73. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
  74. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
  75. package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
  76. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
  77. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
  78. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
  79. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
  80. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
  81. package/package.json +7 -8
@@ -1,99 +1,176 @@
1
1
  import { tool, jsonSchema } from "ai";
2
2
  import { spawn } from "child_process";
3
+ import path from "path";
3
4
  import { confirm, isDangerousCommand, isAutoApprove } from "../confirm.js";
5
+ import { writeFullToolOutput } from "../tool-output.js";
6
+ const MAX_STREAM_BYTES = 200 * 1024;
7
+ const MAX_OUTPUT_BYTES = 100_000;
4
8
  /** Track active child processes so they can be killed on abort (e.g. ESC). */
5
- const activeProcesses = new Set();
9
+ const activeRuns = new Set();
10
+ let sigintRegistered = false;
11
+ /**
12
+ * Registered exactly once: Ctrl+C kills all active commands instead of leaking a handler per call.
13
+ * When nothing is running, re-dispatch the signal so the process can exit normally
14
+ * (single-shot mode must not swallow Ctrl+C).
15
+ */
16
+ function ensureSigintHandler() {
17
+ if (sigintRegistered)
18
+ return;
19
+ sigintRegistered = true;
20
+ const onSigint = () => {
21
+ if (activeRuns.size === 0) {
22
+ process.removeListener("SIGINT", onSigint);
23
+ sigintRegistered = false;
24
+ process.kill(process.pid, "SIGINT");
25
+ return;
26
+ }
27
+ for (const run of [...activeRuns]) {
28
+ interruptRun(run);
29
+ }
30
+ };
31
+ process.on("SIGINT", onSigint);
32
+ }
33
+ function interruptRun(run) {
34
+ run.killed = true;
35
+ run.settled = true;
36
+ if (run.timer)
37
+ clearTimeout(run.timer);
38
+ killProcess(run.proc.pid);
39
+ run.resolve(formatOutput(run) + "\n\n[Command interrupted by user.]");
40
+ }
6
41
  /** Kill all active child processes spawned by the bash tool. */
7
42
  export function killActiveProcesses() {
8
- for (const proc of activeProcesses) {
9
- killProcess(proc.pid);
43
+ for (const run of activeRuns) {
44
+ run.killed = true;
45
+ run.settled = true;
46
+ if (run.timer)
47
+ clearTimeout(run.timer);
48
+ killProcess(run.proc.pid);
49
+ run.resolve(formatOutput(run) + "\n\n[Command interrupted by user.]");
10
50
  }
11
- activeProcesses.clear();
51
+ activeRuns.clear();
12
52
  }
13
53
  export const bashTool = tool({
14
- description: "Run a shell command. Use this for system operations, running builds, tests, git commands, etc. The command runs in the current working directory. You SHOULD set a timeout based on how long you expect the command to take. If no timeout is set, the command runs until it finishes or the user manually interrupts (Ctrl+C).",
54
+ description: "Run a shell command. Use this for system operations, running builds, tests, git commands, etc. The command runs in the current working directory (or `cwd` if specified). You SHOULD set a timeout based on how long you expect the command to take. If no timeout is set, the command runs until it finishes or the user manually interrupts (Ctrl+C).",
15
55
  inputSchema: jsonSchema({
16
56
  type: "object",
17
57
  properties: {
18
58
  command: { type: "string", description: "The shell command to execute" },
19
59
  timeout: { type: "number", description: "Timeout in milliseconds. Set based on expected duration (e.g. 5000 for quick commands, 60000 for builds). Omit only for commands with unpredictable duration." },
60
+ cwd: { type: "string", description: "Working directory for the command (relative to the current directory or absolute)" },
20
61
  },
21
62
  required: ["command"],
22
63
  }),
23
- execute: async ({ command, timeout }) => {
64
+ execute: async ({ command, timeout, cwd }) => {
24
65
  if (!isAutoApprove() && isDangerousCommand(command)) {
25
66
  const approved = await confirm(`Execute dangerous command: ${command}`);
26
67
  if (!approved)
27
68
  return "Command rejected by user.";
28
69
  }
29
- return new Promise((resolve) => {
30
- const chunks = [];
31
- let killed = false;
32
- let timer;
33
- // On Windows, force UTF-8 codepage to avoid Chinese garbled text
34
- const isWin = process.platform === "win32";
35
- const actualCommand = isWin ? `chcp 65001 >nul && ${command}` : command;
36
- const proc = spawn(actualCommand, [], {
37
- shell: true,
38
- cwd: process.cwd(),
39
- stdio: ["ignore", "pipe", "pipe"],
40
- detached: process.platform !== "win32",
41
- env: { ...process.env, ...(isWin ? { PYTHONIOENCODING: "utf-8" } : {}) },
42
- });
43
- activeProcesses.add(proc);
44
- proc.stdout?.on("data", (chunk) => chunks.push(chunk));
45
- proc.stderr?.on("data", (chunk) => chunks.push(chunk));
46
- // Timeout kill (only if timeout is specified)
47
- if (timeout && timeout > 0) {
48
- timer = setTimeout(() => {
49
- killed = true;
50
- killProcess(proc.pid);
51
- resolve(getOutput(chunks) +
52
- `\n\n[Command timed out after ${timeout}ms and was killed. Retry with a larger timeout if needed.]`);
53
- }, timeout);
70
+ return executeBash(command, timeout, cwd);
71
+ },
72
+ });
73
+ /** Shared bash execution engine (no permission prompt). Used by bashTool and the read-only variant. */
74
+ export async function executeBash(command, timeout, cwd) {
75
+ ensureSigintHandler();
76
+ return new Promise((resolve) => {
77
+ const run = {
78
+ proc: undefined,
79
+ resolve,
80
+ stdoutChunks: [],
81
+ stderrChunks: [],
82
+ killed: false,
83
+ settled: false,
84
+ stdoutTruncated: false,
85
+ stderrTruncated: false,
86
+ };
87
+ activeRuns.add(run);
88
+ // On Windows, force UTF-8 codepage to avoid Chinese garbled text
89
+ const isWin = process.platform === "win32";
90
+ const actualCommand = isWin ? `chcp 65001 >nul && ${command}` : command;
91
+ const proc = spawn(actualCommand, [], {
92
+ shell: true,
93
+ cwd: cwd ? path.resolve(process.cwd(), cwd) : process.cwd(),
94
+ stdio: ["ignore", "pipe", "pipe"],
95
+ detached: process.platform !== "win32",
96
+ env: { ...process.env, ...(isWin ? { PYTHONIOENCODING: "utf-8" } : {}) },
97
+ });
98
+ run.proc = proc;
99
+ const collect = (chunks, truncated) => (chunk) => {
100
+ const total = chunks.reduce((sum, c) => sum + c.byteLength, 0);
101
+ if (total >= MAX_STREAM_BYTES) {
102
+ run[truncated] = true;
103
+ return;
54
104
  }
55
- // Allow user to interrupt with Ctrl+C (SIGINT)
56
- const sigintHandler = () => {
57
- killed = true;
58
- if (timer)
59
- clearTimeout(timer);
105
+ chunks.push(chunk);
106
+ };
107
+ proc.stdout?.on("data", collect(run.stdoutChunks, "stdoutTruncated"));
108
+ proc.stderr?.on("data", collect(run.stderrChunks, "stderrTruncated"));
109
+ // Timeout kill (only if timeout is specified)
110
+ if (timeout && timeout > 0) {
111
+ run.timer = setTimeout(() => {
112
+ run.killed = true;
113
+ run.settled = true;
60
114
  killProcess(proc.pid);
61
- resolve(getOutput(chunks) + "\n\n[Command interrupted by user.]");
62
- };
63
- process.on("SIGINT", sigintHandler);
64
- proc.on("close", (code) => {
65
- activeProcesses.delete(proc);
66
- process.removeListener("SIGINT", sigintHandler);
67
- if (timer)
68
- clearTimeout(timer);
69
- if (killed)
70
- return;
71
- const output = getOutput(chunks);
72
- if (code === 0) {
73
- resolve(output || "(no output)");
74
- }
75
- else {
76
- resolve(`Exit code ${code}\n${output || "(no output)"}`);
77
- }
78
- });
79
- proc.on("error", (err) => {
80
- activeProcesses.delete(proc);
81
- process.removeListener("SIGINT", sigintHandler);
82
- if (timer)
83
- clearTimeout(timer);
84
- if (killed)
85
- return;
86
- resolve(`Error: ${err.message}`);
87
- });
115
+ resolve(formatOutput(run) +
116
+ `\n\n[Command timed out after ${timeout}ms and was killed. Retry with a larger timeout if needed.]`);
117
+ }, timeout);
118
+ }
119
+ proc.on("close", (code) => {
120
+ activeRuns.delete(run);
121
+ if (run.timer)
122
+ clearTimeout(run.timer);
123
+ if (run.killed || run.settled)
124
+ return;
125
+ run.settled = true;
126
+ const output = formatOutput(run);
127
+ if (code === 0) {
128
+ resolve(output || "(no output)");
129
+ }
130
+ else {
131
+ resolve(`Exit code ${code}\n${output || "(no output)"}`);
132
+ }
88
133
  });
89
- },
90
- });
91
- function getOutput(chunks) {
92
- const output = Buffer.concat(chunks).toString("utf-8").trim();
93
- if (output.length > 100_000) {
94
- return output.slice(0, 50_000) + `\n\n...(truncated, ${output.length} bytes total)...\n\n` + output.slice(-10_000);
134
+ proc.on("error", (err) => {
135
+ activeRuns.delete(run);
136
+ if (run.timer)
137
+ clearTimeout(run.timer);
138
+ if (run.killed || run.settled)
139
+ return;
140
+ run.settled = true;
141
+ resolve(`Error: ${err.message}`);
142
+ });
143
+ });
144
+ }
145
+ /** Merge stdout + stderr, saved to disk when too large for the response budget. */
146
+ function formatOutput(run) {
147
+ const out = Buffer.concat(run.stdoutChunks).toString("utf-8").trim();
148
+ const err = Buffer.concat(run.stderrChunks).toString("utf-8").trim();
149
+ let text = out;
150
+ if (err)
151
+ text = text ? `${text}\n\n--- stderr ---\n${err}` : err;
152
+ if (run.stdoutTruncated || run.stderrTruncated) {
153
+ text += `\n\n[Output capped at ${MAX_STREAM_BYTES} bytes; the remainder was discarded.]`;
95
154
  }
96
- return output;
155
+ const total = Buffer.byteLength(text, "utf-8");
156
+ if (total <= MAX_OUTPUT_BYTES)
157
+ return text;
158
+ const filePath = writeFullToolOutput(text);
159
+ return `${truncateUtf8(text, MAX_OUTPUT_BYTES)}\n\nFull output saved to: ${filePath}`;
160
+ }
161
+ function truncateUtf8(text, maxBytes) {
162
+ const buf = Buffer.from(text, "utf-8");
163
+ const headLen = Math.floor(maxBytes * 0.5);
164
+ const tailLen = Math.floor(maxBytes * 0.1);
165
+ let h = headLen;
166
+ while (h > 0 && (buf[h] & 0xc0) === 0x80)
167
+ h--;
168
+ let t = buf.length - tailLen;
169
+ while (t < buf.length && (buf[t] & 0xc0) === 0x80)
170
+ t++;
171
+ return (buf.subarray(0, h).toString("utf-8") +
172
+ `\n\n...(truncated, ${buf.length} bytes total)...\n\n` +
173
+ buf.subarray(Math.max(t, h)).toString("utf-8"));
97
174
  }
98
175
  function killProcess(pid) {
99
176
  if (!pid)
@@ -1,8 +1,6 @@
1
1
  import { tool, jsonSchema } from "ai";
2
2
  import { truncateToolOutput } from "../tool-output.js";
3
- const EXA_MCP_URL = process.env.EXA_API_KEY
4
- ? `https://mcp.exa.ai/mcp?exaApiKey=${encodeURIComponent(process.env.EXA_API_KEY)}`
5
- : "https://mcp.exa.ai/mcp";
3
+ const EXA_MCP_URL = "https://mcp.exa.ai/mcp";
6
4
  export const codeSearchTool = tool({
7
5
  description: `Search and get relevant context for any programming task using Exa Code API.
8
6
  Provides high-quality, fresh context for libraries, SDKs, and APIs.
@@ -59,6 +57,7 @@ async function callExaCode(query, tokensNum) {
59
57
  headers: {
60
58
  "Content-Type": "application/json",
61
59
  Accept: "application/json, text/event-stream",
60
+ ...(process.env.EXA_API_KEY ? { Authorization: `Bearer ${process.env.EXA_API_KEY}` } : {}),
62
61
  },
63
62
  body,
64
63
  signal: AbortSignal.timeout(30000),
@@ -66,8 +65,10 @@ async function callExaCode(query, tokensNum) {
66
65
  if (!response.ok) {
67
66
  throw new Error(`Exa API returned ${response.status}`);
68
67
  }
69
- const text = await response.text();
70
- // Parse SSE response: look for "data: {...}" lines
68
+ return await parseExaResponse(await response.text());
69
+ }
70
+ /** Parse either an SSE stream or a direct JSON body into the tool result text. */
71
+ async function parseExaResponse(text) {
71
72
  for (const line of text.split("\n")) {
72
73
  if (!line.startsWith("data: "))
73
74
  continue;
@@ -1,5 +1,6 @@
1
1
  import { tool, jsonSchema } from "ai";
2
- import { readFileSync, writeFileSync, existsSync } from "fs";
2
+ import { readFile, writeFile, rename, unlink } from "fs/promises";
3
+ import { randomBytes } from "crypto";
3
4
  import path from "path";
4
5
  import { confirm, isAutoApprove } from "../confirm.js";
5
6
  export const editTool = tool({
@@ -14,11 +15,18 @@ export const editTool = tool({
14
15
  required: ["filePath", "oldText", "newText"],
15
16
  }),
16
17
  execute: async ({ filePath, oldText, newText }) => {
18
+ if (!oldText)
19
+ return "Error: oldText must not be empty";
20
+ if (oldText === newText)
21
+ return "No change: oldText equals newText";
17
22
  const resolved = path.resolve(process.cwd(), filePath);
18
- if (!existsSync(resolved)) {
19
- return `Error: File not found: ${filePath}`;
23
+ let content;
24
+ try {
25
+ content = await readFile(resolved, "utf-8");
26
+ }
27
+ catch (err) {
28
+ return `Error: cannot read ${filePath}: ${err.message}`;
20
29
  }
21
- const content = readFileSync(resolved, "utf-8");
22
30
  const occurrences = content.split(oldText).length - 1;
23
31
  if (occurrences === 0) {
24
32
  // Try to help: show nearby content
@@ -37,13 +45,21 @@ export const editTool = tool({
37
45
  }
38
46
  // Confirm edit
39
47
  if (!isAutoApprove()) {
40
- const preview = oldText.length > 80 ? oldText.slice(0, 80) + "..." : oldText;
41
- const approved = await confirm(`Edit ${filePath}: replace "${preview}"`);
48
+ const preview = (s) => (s.length > 80 ? s.slice(0, 80) + "..." : s);
49
+ const approved = await confirm(`Edit ${filePath}:\nreplace "${preview(oldText)}"\nwith "${preview(newText)}"`);
42
50
  if (!approved)
43
51
  return "Edit rejected by user.";
44
52
  }
45
53
  const updated = content.replace(oldText, newText);
46
- writeFileSync(resolved, updated, "utf-8");
54
+ const tmpPath = path.join(path.dirname(resolved), `.${path.basename(resolved)}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
55
+ try {
56
+ await writeFile(tmpPath, updated, "utf-8");
57
+ await rename(tmpPath, resolved);
58
+ }
59
+ catch (err) {
60
+ await unlink(tmpPath).catch(() => { });
61
+ return `Error: cannot write ${filePath}: ${err.message}`;
62
+ }
47
63
  const oldLines = oldText.split("\n").length;
48
64
  const newLines = newText.split("\n").length;
49
65
  return `Edited ${filePath}: replaced ${oldLines} line(s) with ${newLines} line(s)`;
@@ -1,6 +1,6 @@
1
1
  import { tool, jsonSchema, streamText, stepCountIs } from "ai";
2
2
  import { resolveModel } from "../provider.js";
3
- import { bashTool } from "./bash.js";
3
+ import { executeBash } from "./bash.js";
4
4
  import { readTool } from "./read.js";
5
5
  import { globTool } from "./glob.js";
6
6
  import { grepTool } from "./grep.js";
@@ -17,7 +17,7 @@ Guidelines:
17
17
  - Use glob for broad file pattern matching
18
18
  - Use grep for searching file contents with regex
19
19
  - Use read when you know the specific file path
20
- - Use bash ONLY for read-only operations (ls, find, cat, wc, head, tail)
20
+ - Use bash ONLY for read-only inspection (ls, find, cat, wc, head, tail, grep, git status/log/diff)
21
21
  - Adapt your search approach based on the thoroughness level specified
22
22
  - Return file paths as absolute paths in your final response
23
23
  - Do NOT create, modify, or delete any files
@@ -28,7 +28,7 @@ Complete the search request efficiently and report findings clearly.
28
28
  Working directory: ${process.cwd()}
29
29
  Platform: ${process.platform}`;
30
30
  const EXPLORE_MAX_STEPS = 20;
31
- export function createExploreTool(modelId) {
31
+ export function createExploreTool(modelId, abortSignal, onUsage) {
32
32
  return tool({
33
33
  description: `Deep codebase exploration agent. Use this to understand project structure, find files by patterns, search code for keywords, trace module relationships, or answer questions about the codebase.
34
34
 
@@ -51,27 +51,31 @@ Examples:
51
51
  }),
52
52
  execute: async ({ query, thoroughness }) => {
53
53
  const level = thoroughness ?? "medium";
54
- console.log(`\x1b[90m ┌─ Explore (${level}): ${query.slice(0, 60)}\x1b[0m`);
54
+ console.error(`\x1b[90m ┌─ Explore (${level}): ${query.slice(0, 60)}\x1b[0m`);
55
55
  try {
56
- const result = await runExploreAgent(query, level, modelId);
57
- console.log(`\x1b[90m └─ ✓ Done\x1b[0m`);
58
- return truncateToolOutput(result, { direction: "head" }).content;
56
+ const result = await runExploreAgent(query, level, modelId, abortSignal);
57
+ if (result.usage)
58
+ onUsage?.(result.usage);
59
+ console.error(`\x1b[90m └─ ✓ Done\x1b[0m`);
60
+ return truncateToolOutput(result.text, { direction: "head" }).content;
59
61
  }
60
62
  catch (err) {
61
- console.log(`\x1b[90m └─ ✗ Failed: ${err.message}\x1b[0m`);
63
+ if (abortSignal?.aborted)
64
+ return "Explore cancelled by user.";
65
+ console.error(`\x1b[90m └─ ✗ Failed: ${err.message}\x1b[0m`);
62
66
  return `Explore error: ${err.message}`;
63
67
  }
64
68
  },
65
69
  });
66
70
  }
67
- async function runExploreAgent(query, thoroughness, modelId) {
71
+ async function runExploreAgent(query, thoroughness, modelId, abortSignal) {
68
72
  const model = resolveModel(modelId);
69
73
  // Read-only tools only
70
74
  const tools = {
71
75
  glob: globTool,
72
76
  grep: grepTool,
73
77
  read: readTool,
74
- bash: bashTool, // bash is available but prompt restricts to read-only
78
+ bash: readOnlyBashTool,
75
79
  };
76
80
  const prompt = `Thoroughness level: ${thoroughness}
77
81
  ${thoroughness === "quick" ? "Do a quick search (1-3 tool calls max)." : ""}
@@ -80,6 +84,8 @@ ${thoroughness === "thorough" ? "Do a comprehensive analysis. Check multiple loc
80
84
 
81
85
  Task: ${query}`;
82
86
  const messages = [{ role: "user", content: prompt }];
87
+ const controller = new AbortController();
88
+ const signal = abortSignal ? AbortSignal.any([abortSignal, controller.signal]) : controller.signal;
83
89
  const result = streamText({
84
90
  model,
85
91
  system: EXPLORE_SYSTEM,
@@ -87,6 +93,7 @@ Task: ${query}`;
87
93
  tools,
88
94
  stopWhen: stepCountIs(EXPLORE_MAX_STEPS),
89
95
  maxRetries: 2,
96
+ abortSignal: signal,
90
97
  onError() { },
91
98
  });
92
99
  let assistantText = "";
@@ -96,9 +103,70 @@ Task: ${query}`;
96
103
  assistantText += event.text;
97
104
  break;
98
105
  case "tool-call":
99
- console.log(`\x1b[90m │ ⚡ ${event.toolName}\x1b[0m`);
106
+ console.error(`\x1b[90m │ ⚡ ${event.toolName}\x1b[0m`);
100
107
  break;
108
+ case "error":
109
+ controller.abort();
110
+ return { text: stripThinkingFromAssistantText(assistantText) || `[Explore error: ${event.error}]` };
101
111
  }
102
112
  }
103
- return stripThinkingFromAssistantText(assistantText) || "(explore agent produced no output)";
113
+ let usage;
114
+ try {
115
+ usage = await result.usage;
116
+ }
117
+ catch { }
118
+ return {
119
+ text: stripThinkingFromAssistantText(assistantText) || "(explore agent produced no output)",
120
+ usage,
121
+ };
122
+ }
123
+ /* ── Read-only bash: white-listed commands only, no redirection / chaining ── */
124
+ const READ_ONLY_BINS = new Set([
125
+ "ls", "find", "cat", "wc", "head", "tail", "grep", "rg", "tree", "pwd", "stat", "du",
126
+ "file", "which", "echo", "printf", "sort", "uniq", "cut", "tr", "awk", "sed", "basename",
127
+ "dirname", "realpath", "env", "printenv", "git",
128
+ ]);
129
+ const READ_ONLY_GIT_CMDS = new Set([
130
+ "status", "log", "diff", "show", "rev-parse", "branch", "remote", "tag", "ls-files", "help", "--version", "version",
131
+ ]);
132
+ /** True if the command only inspects state (no writes, redirection, pipes, or chaining). */
133
+ export function isReadOnlyCommand(command) {
134
+ const trimmed = command.trim();
135
+ if (!trimmed)
136
+ return false;
137
+ if (/[<>|]|&&|;|`|\$\(/.test(trimmed))
138
+ return false;
139
+ const tokens = trimmed.split(/\s+/);
140
+ const bin = tokens[0].toLowerCase();
141
+ if (!READ_ONLY_BINS.has(bin))
142
+ return false;
143
+ if (bin === "git") {
144
+ const sub = tokens[1]?.toLowerCase();
145
+ if (!sub || !READ_ONLY_GIT_CMDS.has(sub))
146
+ return false;
147
+ }
148
+ if (bin === "find" && /\s-(delete|exec|ok|execdir)\b/.test(trimmed))
149
+ return false;
150
+ if ((bin === "sed" || bin === "awk" || bin === "perl") && /\s-i(\.[a-zA-Z0-9_-]+)?\b/.test(trimmed))
151
+ return false;
152
+ return true;
104
153
  }
154
+ export const readOnlyBashTool = tool({
155
+ description: "Run a READ-ONLY shell command (ls, find, cat, wc, head, tail, grep, git status/log/diff, etc.). " +
156
+ "File modification, redirection, and command chaining are blocked.",
157
+ inputSchema: jsonSchema({
158
+ type: "object",
159
+ properties: {
160
+ command: { type: "string", description: "The read-only shell command to execute" },
161
+ timeout: { type: "number", description: "Timeout in milliseconds" },
162
+ cwd: { type: "string", description: "Working directory (relative or absolute)" },
163
+ },
164
+ required: ["command"],
165
+ }),
166
+ execute: async ({ command, timeout, cwd }) => {
167
+ if (!isReadOnlyCommand(command)) {
168
+ return `Error: "${command.slice(0, 80)}" is not a read-only command. Allowed: ${[...READ_ONLY_BINS].join(", ")} (read-only git subcommands only).`;
169
+ }
170
+ return executeBash(command, timeout, cwd);
171
+ },
172
+ });
@@ -1,5 +1,5 @@
1
1
  import { tool, jsonSchema } from "ai";
2
- import { globSync } from "glob";
2
+ import { glob } from "glob";
3
3
  import { truncateToolOutput } from "../tool-output.js";
4
4
  export const globTool = tool({
5
5
  description: "Find files matching a glob pattern. Returns a list of file paths. Use this to discover project structure and find files.",
@@ -13,9 +13,9 @@ export const globTool = tool({
13
13
  }),
14
14
  execute: async ({ pattern, cwd }) => {
15
15
  try {
16
- const matches = globSync(pattern, {
16
+ const matches = await glob(pattern, {
17
17
  cwd: cwd ?? process.cwd(),
18
- ignore: ["**/node_modules/**", "**/.git/**"],
18
+ ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**"],
19
19
  nodir: true,
20
20
  });
21
21
  if (matches.length === 0)