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.
- package/README.md +146 -18
- package/dist/agent.js +293 -408
- package/dist/assistant-stream.js +11 -7
- package/dist/cli.js +403 -140
- package/dist/clipboard.js +59 -23
- package/dist/code-mode.js +3 -3
- package/dist/compaction.js +182 -81
- package/dist/config.js +186 -35
- package/dist/confirm.js +55 -6
- package/dist/context-window.js +67 -54
- package/dist/doom-loop.js +19 -12
- package/dist/http.js +119 -0
- package/dist/instructions.js +51 -33
- package/dist/logger.js +66 -0
- package/dist/markdown.js +3 -44
- package/dist/mcp.js +547 -100
- package/dist/memory.js +48 -6
- package/dist/output.js +36 -27
- package/dist/paste-handler.js +3 -3
- package/dist/plugins.js +33 -6
- package/dist/pricing.js +119 -0
- package/dist/provider.js +17 -15
- package/dist/serve.js +658 -369
- package/dist/sessions.js +151 -13
- package/dist/skills.js +466 -76
- package/dist/synthetic.js +7 -0
- package/dist/title-gen.js +2 -1
- package/dist/tool-display.js +173 -0
- package/dist/tool-output.js +54 -45
- package/dist/tools/apply_patch.js +191 -0
- package/dist/tools/backend.js +61 -0
- package/dist/tools/bash.js +147 -70
- package/dist/tools/code_search.js +6 -5
- package/dist/tools/edit.js +23 -7
- package/dist/tools/explore.js +80 -12
- package/dist/tools/glob.js +3 -3
- package/dist/tools/grep.js +146 -14
- package/dist/tools/index.js +7 -7
- package/dist/tools/question.js +4 -22
- package/dist/tools/read.js +71 -11
- package/dist/tools/task.js +33 -20
- package/dist/tools/todo.js +83 -73
- package/dist/tools/web_fetch.js +150 -46
- package/dist/tools/web_search.js +706 -28
- package/dist/tools/write.js +13 -7
- package/dist/tui/App.js +40 -6
- package/dist/tui/ConfirmBar.js +24 -3
- package/dist/tui/InputBar.js +390 -45
- package/dist/tui/MessageList.js +533 -20
- package/dist/tui/ModelPicker.js +108 -0
- package/dist/tui/QuestionBar.js +104 -0
- package/dist/tui/StatusBar.js +19 -11
- package/dist/tui/agent-runner.js +103 -0
- package/dist/tui/caret-pos.js +134 -0
- package/dist/tui/caret.js +69 -0
- package/dist/tui/diff-view.js +61 -0
- package/dist/tui/drag-state.js +44 -0
- package/dist/tui/index.js +153 -24
- package/dist/tui/input-history.js +44 -0
- package/dist/tui/layout.js +17 -0
- package/dist/tui/mouse.js +46 -0
- package/dist/tui/selection.js +134 -0
- package/dist/tui/slash-commands.js +90 -0
- package/dist/tui/slash-handler.js +370 -0
- package/dist/tui/text-width.js +91 -0
- package/dist/tui/theme.js +12 -0
- package/dist/tui/undo-stack.js +14 -0
- package/dist/tui/use-sgr-mouse.js +27 -0
- package/dist/tui-chat.js +111 -331
- package/dist/updater.js +57 -0
- package/docs/API.md +160 -14
- package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
- package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
- package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
- package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
- package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
- package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
- package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
- package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
- package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
- package/package.json +7 -8
package/dist/tools/bash.js
CHANGED
|
@@ -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
|
|
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
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
70
|
-
|
|
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;
|
package/dist/tools/edit.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { tool, jsonSchema } from "ai";
|
|
2
|
-
import {
|
|
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
|
-
|
|
19
|
-
|
|
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 =
|
|
41
|
-
const approved = await confirm(`Edit ${filePath}
|
|
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
|
-
|
|
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)`;
|
package/dist/tools/explore.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { tool, jsonSchema, streamText, stepCountIs } from "ai";
|
|
2
2
|
import { resolveModel } from "../provider.js";
|
|
3
|
-
import {
|
|
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
|
|
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.
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
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
|
+
});
|
package/dist/tools/glob.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { tool, jsonSchema } from "ai";
|
|
2
|
-
import {
|
|
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 =
|
|
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)
|