min-agent 0.3.0 → 0.4.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 +111 -28
- package/dist/agent.js +1119 -256
- package/dist/cli/commands/chat.js +10 -0
- package/dist/cli/commands/exec.js +32 -0
- package/dist/cli/commands/history.js +58 -0
- package/dist/cli/commands/index.js +224 -0
- package/dist/cli/commands/init.js +18 -0
- package/dist/cli/commands/mcp.js +173 -0
- package/dist/cli/commands/memory.js +69 -0
- package/dist/cli/commands/models.js +21 -0
- package/dist/cli/commands/permission.js +12 -0
- package/dist/cli/commands/rules.js +33 -0
- package/dist/cli/commands/sandbox.js +13 -0
- package/dist/cli/commands/serve.js +9 -0
- package/dist/cli/commands/setup.js +4 -0
- package/dist/cli/commands/shared.js +16 -0
- package/dist/cli/commands/skills.js +119 -0
- package/dist/cli/commands/update.js +7 -0
- package/dist/cli/commands/write-config.js +30 -0
- package/dist/cli/errors.js +36 -0
- package/dist/cli/exec-prompt.js +26 -0
- package/dist/cli/option-helpers.js +53 -0
- package/dist/cli/program.js +180 -0
- package/dist/cli.js +5 -888
- package/dist/code-mode.js +32 -14
- package/dist/compaction.js +347 -160
- package/dist/config.js +119 -10
- package/dist/confirm.js +56 -9
- package/dist/context-window.js +107 -39
- package/dist/doom-loop.js +264 -29
- package/dist/fetch-timeout.js +152 -0
- package/dist/http-approvals.js +60 -0
- package/dist/instructions.js +21 -0
- package/dist/logger.js +33 -4
- package/dist/markdown.js +37 -11
- package/dist/mcp.js +328 -30
- package/dist/memory.js +97 -56
- package/dist/output.js +7 -5
- package/dist/permission-cli.js +43 -0
- package/dist/plugins.js +46 -8
- package/dist/pricing.js +4 -4
- package/dist/provider.js +23 -6
- package/dist/question-format.js +60 -0
- package/dist/sandbox-cli.js +82 -0
- package/dist/sandbox.js +403 -0
- package/dist/save-throttle.js +45 -0
- package/dist/serve/common.js +404 -0
- package/dist/serve/routes-chat.js +347 -0
- package/dist/serve/routes-mcp.js +212 -0
- package/dist/serve/routes-memory.js +66 -0
- package/dist/serve/routes-meta.js +205 -0
- package/dist/serve/routes-sessions.js +61 -0
- package/dist/serve/routes-skills.js +70 -0
- package/dist/serve.js +33 -883
- package/dist/sessions.js +53 -9
- package/dist/skills.js +82 -18
- package/dist/title-gen.js +8 -2
- package/dist/token-display.js +36 -0
- package/dist/tool-display.js +5 -0
- package/dist/tool-output.js +1 -3
- package/dist/tools/apply_patch.js +85 -11
- package/dist/tools/atomic-file.js +35 -0
- package/dist/tools/backend.js +2 -2
- package/dist/tools/bash.js +57 -19
- package/dist/tools/code_search.js +7 -1
- package/dist/tools/edit.js +11 -10
- package/dist/tools/explore.js +74 -14
- package/dist/tools/glob.js +4 -0
- package/dist/tools/grep.js +17 -10
- package/dist/tools/index.js +6 -21
- package/dist/tools/question.js +28 -9
- package/dist/tools/read.js +6 -4
- package/dist/tools/search-searxng.js +223 -0
- package/dist/tools/search-serper.js +189 -0
- package/dist/tools/task.js +84 -30
- package/dist/tools/todo.js +120 -19
- package/dist/tools/web_fetch.js +11 -3
- package/dist/tools/web_search.js +66 -556
- package/dist/tools/write.js +23 -6
- package/dist/tui/App.js +63 -14
- package/dist/tui/ConfirmBar.js +45 -13
- package/dist/tui/InputBar.js +150 -35
- package/dist/tui/MessageList.js +266 -125
- package/dist/tui/ModelPicker.js +8 -3
- package/dist/tui/QuestionBar.js +51 -19
- package/dist/tui/SessionPicker.js +79 -0
- package/dist/tui/StatusBar.js +8 -14
- package/dist/tui/agent-runner.js +142 -22
- package/dist/tui/caret-pos.js +48 -5
- package/dist/tui/caret.js +1 -1
- package/dist/tui/click-count.js +13 -0
- package/dist/tui/drag-state.js +8 -3
- package/dist/tui/hydrate.js +129 -0
- package/dist/tui/index.js +42 -13
- package/dist/tui/input-history.js +92 -11
- package/dist/tui/layout.js +75 -4
- package/dist/tui/prompt-queue.js +24 -0
- package/dist/tui/selection.js +113 -21
- package/dist/tui/session-switch.js +28 -0
- package/dist/tui/slash-commands.js +22 -6
- package/dist/tui/slash-handler.js +233 -58
- package/dist/tui/text-width.js +38 -16
- package/dist/tui/token-info.js +7 -0
- package/dist/tui/tool-children.js +19 -0
- package/dist/tui/undo-stack.js +1 -1
- package/dist/tui/use-sgr-mouse.js +3 -1
- package/dist/tui-chat.js +276 -40
- package/dist/updater.js +88 -29
- package/dist/xml-search.js +194 -0
- package/docs/API.md +257 -25
- package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
- package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
- package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
- package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
- package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
- package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
- package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
- package/package.json +6 -1
- package/skills/self-config/SKILL.md +90 -0
- package/skills/self-config/reference.md +149 -0
package/dist/tools/bash.js
CHANGED
|
@@ -3,11 +3,30 @@ import { spawn } from "child_process";
|
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { confirm, isDangerousCommand, isAutoApprove } from "../confirm.js";
|
|
5
5
|
import { writeFullToolOutput } from "../tool-output.js";
|
|
6
|
+
import { planSandboxedSpawn } from "../sandbox.js";
|
|
7
|
+
import { getEffectiveConfig } from "../config.js";
|
|
6
8
|
const MAX_STREAM_BYTES = 200 * 1024;
|
|
7
9
|
const MAX_OUTPUT_BYTES = 100_000;
|
|
10
|
+
/** Default wall-clock limit so one hung command cannot stall the whole turn. */
|
|
11
|
+
export const DEFAULT_BASH_TIMEOUT_MS = 10 * 60 * 1000;
|
|
12
|
+
/** Resolve the timeout for a command: explicit > config > default. 0 disables. */
|
|
13
|
+
export function resolveBashTimeout(explicit) {
|
|
14
|
+
if (typeof explicit === "number" && Number.isFinite(explicit) && explicit >= 0)
|
|
15
|
+
return Math.floor(explicit);
|
|
16
|
+
const configured = getEffectiveConfig().tools?.bashTimeoutMs;
|
|
17
|
+
if (typeof configured === "number" && Number.isFinite(configured) && configured >= 0)
|
|
18
|
+
return Math.floor(configured);
|
|
19
|
+
return DEFAULT_BASH_TIMEOUT_MS;
|
|
20
|
+
}
|
|
8
21
|
/** Track active child processes so they can be killed on abort (e.g. ESC). */
|
|
9
22
|
const activeRuns = new Set();
|
|
10
23
|
let sigintRegistered = false;
|
|
24
|
+
/** Sandbox caveats are shown once per process to avoid spamming every command. */
|
|
25
|
+
let sandboxWarningShown = false;
|
|
26
|
+
/** Test hook: allow the sandbox caveat to be shown again. */
|
|
27
|
+
export function resetSandboxWarningShown() {
|
|
28
|
+
sandboxWarningShown = false;
|
|
29
|
+
}
|
|
11
30
|
/**
|
|
12
31
|
* Registered exactly once: Ctrl+C kills all active commands instead of leaking a handler per call.
|
|
13
32
|
* When nothing is running, re-dispatch the signal so the process can exit normally
|
|
@@ -51,13 +70,19 @@ export function killActiveProcesses() {
|
|
|
51
70
|
activeRuns.clear();
|
|
52
71
|
}
|
|
53
72
|
export const bashTool = tool({
|
|
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).
|
|
73
|
+
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). Commands are killed after 10 minutes by default; set `timeout` for anything expected to run longer (or shorter).",
|
|
55
74
|
inputSchema: jsonSchema({
|
|
56
75
|
type: "object",
|
|
57
76
|
properties: {
|
|
58
77
|
command: { type: "string", description: "The shell command to execute" },
|
|
59
|
-
timeout: {
|
|
60
|
-
|
|
78
|
+
timeout: {
|
|
79
|
+
type: "number",
|
|
80
|
+
description: "Timeout in milliseconds. Defaults to 600000 (10 minutes). Set a smaller value for quick commands or a larger one for long builds.",
|
|
81
|
+
},
|
|
82
|
+
cwd: {
|
|
83
|
+
type: "string",
|
|
84
|
+
description: "Working directory for the command (relative to the current directory or absolute)",
|
|
85
|
+
},
|
|
61
86
|
},
|
|
62
87
|
required: ["command"],
|
|
63
88
|
}),
|
|
@@ -74,9 +99,24 @@ export const bashTool = tool({
|
|
|
74
99
|
export async function executeBash(command, timeout, cwd) {
|
|
75
100
|
ensureSigintHandler();
|
|
76
101
|
return new Promise((resolve) => {
|
|
102
|
+
const isWin = process.platform === "win32";
|
|
103
|
+
const resolvedCwd = cwd ? path.resolve(process.cwd(), cwd) : process.cwd();
|
|
104
|
+
const plan = planSandboxedSpawn(command, resolvedCwd, {
|
|
105
|
+
...process.env,
|
|
106
|
+
...(isWin ? { PYTHONIOENCODING: "utf-8" } : {}),
|
|
107
|
+
});
|
|
108
|
+
if ("error" in plan) {
|
|
109
|
+
resolve(plan.error);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
let notice = "";
|
|
113
|
+
if (plan.warning && !sandboxWarningShown) {
|
|
114
|
+
sandboxWarningShown = true;
|
|
115
|
+
notice = `${plan.warning}\n\n`;
|
|
116
|
+
}
|
|
77
117
|
const run = {
|
|
78
118
|
proc: undefined,
|
|
79
|
-
resolve,
|
|
119
|
+
resolve: (value) => resolve(notice + value),
|
|
80
120
|
stdoutChunks: [],
|
|
81
121
|
stderrChunks: [],
|
|
82
122
|
killed: false,
|
|
@@ -85,15 +125,12 @@ export async function executeBash(command, timeout, cwd) {
|
|
|
85
125
|
stderrTruncated: false,
|
|
86
126
|
};
|
|
87
127
|
activeRuns.add(run);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const proc = spawn(actualCommand, [], {
|
|
92
|
-
shell: true,
|
|
93
|
-
cwd: cwd ? path.resolve(process.cwd(), cwd) : process.cwd(),
|
|
128
|
+
const proc = spawn(plan.file, plan.args, {
|
|
129
|
+
shell: plan.shell,
|
|
130
|
+
cwd: plan.cwd,
|
|
94
131
|
stdio: ["ignore", "pipe", "pipe"],
|
|
95
132
|
detached: process.platform !== "win32",
|
|
96
|
-
env:
|
|
133
|
+
env: plan.env,
|
|
97
134
|
});
|
|
98
135
|
run.proc = proc;
|
|
99
136
|
const collect = (chunks, truncated) => (chunk) => {
|
|
@@ -106,15 +143,16 @@ export async function executeBash(command, timeout, cwd) {
|
|
|
106
143
|
};
|
|
107
144
|
proc.stdout?.on("data", collect(run.stdoutChunks, "stdoutTruncated"));
|
|
108
145
|
proc.stderr?.on("data", collect(run.stderrChunks, "stderrTruncated"));
|
|
109
|
-
// Timeout kill (
|
|
110
|
-
|
|
146
|
+
// Timeout kill (explicit value, config default, or the 10 minute fallback)
|
|
147
|
+
const effectiveTimeout = resolveBashTimeout(timeout);
|
|
148
|
+
if (effectiveTimeout > 0) {
|
|
111
149
|
run.timer = setTimeout(() => {
|
|
112
150
|
run.killed = true;
|
|
113
151
|
run.settled = true;
|
|
114
152
|
killProcess(proc.pid);
|
|
115
|
-
resolve(formatOutput(run) +
|
|
116
|
-
`\n\n[Command timed out after ${
|
|
117
|
-
},
|
|
153
|
+
run.resolve(formatOutput(run) +
|
|
154
|
+
`\n\n[Command timed out after ${effectiveTimeout}ms and was killed. Retry with a larger timeout if needed.]`);
|
|
155
|
+
}, effectiveTimeout);
|
|
118
156
|
}
|
|
119
157
|
proc.on("close", (code) => {
|
|
120
158
|
activeRuns.delete(run);
|
|
@@ -125,10 +163,10 @@ export async function executeBash(command, timeout, cwd) {
|
|
|
125
163
|
run.settled = true;
|
|
126
164
|
const output = formatOutput(run);
|
|
127
165
|
if (code === 0) {
|
|
128
|
-
resolve(output || "(no output)");
|
|
166
|
+
run.resolve(output || "(no output)");
|
|
129
167
|
}
|
|
130
168
|
else {
|
|
131
|
-
resolve(`Exit code ${code}\n${output || "(no output)"}`);
|
|
169
|
+
run.resolve(`Exit code ${code}\n${output || "(no output)"}`);
|
|
132
170
|
}
|
|
133
171
|
});
|
|
134
172
|
proc.on("error", (err) => {
|
|
@@ -138,7 +176,7 @@ export async function executeBash(command, timeout, cwd) {
|
|
|
138
176
|
if (run.killed || run.settled)
|
|
139
177
|
return;
|
|
140
178
|
run.settled = true;
|
|
141
|
-
resolve(`Error: ${err.message}`);
|
|
179
|
+
run.resolve(`Error: ${err.message}`);
|
|
142
180
|
});
|
|
143
181
|
});
|
|
144
182
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { tool, jsonSchema } from "ai";
|
|
2
2
|
import { truncateToolOutput } from "../tool-output.js";
|
|
3
|
+
import { getEffectiveSandboxPolicy, networkDeniedMessage } from "../sandbox.js";
|
|
3
4
|
const EXA_MCP_URL = "https://mcp.exa.ai/mcp";
|
|
4
5
|
export const codeSearchTool = tool({
|
|
5
6
|
description: `Search and get relevant context for any programming task using Exa Code API.
|
|
@@ -25,6 +26,11 @@ Usage:
|
|
|
25
26
|
required: ["query"],
|
|
26
27
|
}),
|
|
27
28
|
execute: async ({ query, tokensNum }) => {
|
|
29
|
+
if (getEffectiveSandboxPolicy().network === "deny")
|
|
30
|
+
return `Code search error: ${networkDeniedMessage()}`;
|
|
31
|
+
if (!process.env.EXA_API_KEY?.trim()) {
|
|
32
|
+
return "Code search error: EXA_API_KEY is not set. Configure it in your environment to enable code search.";
|
|
33
|
+
}
|
|
28
34
|
const tokens = Math.max(1000, Math.min(50000, tokensNum ?? 5000));
|
|
29
35
|
try {
|
|
30
36
|
const result = await callExaCode(query, tokens);
|
|
@@ -34,7 +40,7 @@ Usage:
|
|
|
34
40
|
return truncateToolOutput(result, { direction: "head" }).content;
|
|
35
41
|
}
|
|
36
42
|
catch (err) {
|
|
37
|
-
return `Code search error: ${err.message}`;
|
|
43
|
+
return `Code search error: ${err instanceof Error ? err.message : String(err)}`;
|
|
38
44
|
}
|
|
39
45
|
},
|
|
40
46
|
});
|
package/dist/tools/edit.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { tool, jsonSchema } from "ai";
|
|
2
|
-
import { readFile
|
|
3
|
-
import { randomBytes } from "crypto";
|
|
2
|
+
import { readFile } from "fs/promises";
|
|
4
3
|
import path from "path";
|
|
5
|
-
import { confirm,
|
|
4
|
+
import { confirm, isEditAutoApprove } from "../confirm.js";
|
|
5
|
+
import { pathAccessError } from "../sandbox.js";
|
|
6
|
+
import { atomicWriteFile } from "./atomic-file.js";
|
|
6
7
|
export const editTool = tool({
|
|
7
8
|
description: "Edit a file by replacing a specific text block with new content. The oldText must match exactly (including whitespace and indentation). Use this for precise edits instead of rewriting entire files.",
|
|
8
9
|
inputSchema: jsonSchema({
|
|
@@ -19,13 +20,16 @@ export const editTool = tool({
|
|
|
19
20
|
return "Error: oldText must not be empty";
|
|
20
21
|
if (oldText === newText)
|
|
21
22
|
return "No change: oldText equals newText";
|
|
23
|
+
const denied = pathAccessError(filePath, "write");
|
|
24
|
+
if (denied)
|
|
25
|
+
return `Error: ${denied}`;
|
|
22
26
|
const resolved = path.resolve(process.cwd(), filePath);
|
|
23
27
|
let content;
|
|
24
28
|
try {
|
|
25
29
|
content = await readFile(resolved, "utf-8");
|
|
26
30
|
}
|
|
27
31
|
catch (err) {
|
|
28
|
-
return `Error: cannot read ${filePath}: ${err.message}`;
|
|
32
|
+
return `Error: cannot read ${filePath}: ${err instanceof Error ? err.message : String(err)}`;
|
|
29
33
|
}
|
|
30
34
|
const occurrences = content.split(oldText).length - 1;
|
|
31
35
|
if (occurrences === 0) {
|
|
@@ -44,21 +48,18 @@ export const editTool = tool({
|
|
|
44
48
|
return `Error: oldText found ${occurrences} times in ${filePath}. Please provide more context to make the match unique.`;
|
|
45
49
|
}
|
|
46
50
|
// Confirm edit
|
|
47
|
-
if (!
|
|
51
|
+
if (!isEditAutoApprove()) {
|
|
48
52
|
const preview = (s) => (s.length > 80 ? s.slice(0, 80) + "..." : s);
|
|
49
53
|
const approved = await confirm(`Edit ${filePath}:\nreplace "${preview(oldText)}"\nwith "${preview(newText)}"`);
|
|
50
54
|
if (!approved)
|
|
51
55
|
return "Edit rejected by user.";
|
|
52
56
|
}
|
|
53
57
|
const updated = content.replace(oldText, newText);
|
|
54
|
-
const tmpPath = path.join(path.dirname(resolved), `.${path.basename(resolved)}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
|
|
55
58
|
try {
|
|
56
|
-
await
|
|
57
|
-
await rename(tmpPath, resolved);
|
|
59
|
+
await atomicWriteFile(resolved, updated);
|
|
58
60
|
}
|
|
59
61
|
catch (err) {
|
|
60
|
-
|
|
61
|
-
return `Error: cannot write ${filePath}: ${err.message}`;
|
|
62
|
+
return `Error: cannot write ${filePath}: ${err instanceof Error ? err.message : String(err)}`;
|
|
62
63
|
}
|
|
63
64
|
const oldLines = oldText.split("\n").length;
|
|
64
65
|
const newLines = newText.split("\n").length;
|
package/dist/tools/explore.js
CHANGED
|
@@ -5,8 +5,12 @@ import { readTool } from "./read.js";
|
|
|
5
5
|
import { globTool } from "./glob.js";
|
|
6
6
|
import { grepTool } from "./grep.js";
|
|
7
7
|
import { stripThinkingFromAssistantText } from "../assistant-stream.js";
|
|
8
|
+
import { unwrapXmlSearchTags } from "../xml-search.js";
|
|
8
9
|
import { truncateToolOutput } from "../tool-output.js";
|
|
9
|
-
|
|
10
|
+
import { log } from "../logger.js";
|
|
11
|
+
import { getEffectiveConfig } from "../config.js";
|
|
12
|
+
function exploreSystem() {
|
|
13
|
+
return `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
|
|
10
14
|
|
|
11
15
|
Your strengths:
|
|
12
16
|
- Rapidly finding files using glob patterns
|
|
@@ -27,8 +31,14 @@ Complete the search request efficiently and report findings clearly.
|
|
|
27
31
|
|
|
28
32
|
Working directory: ${process.cwd()}
|
|
29
33
|
Platform: ${process.platform}`;
|
|
34
|
+
}
|
|
30
35
|
const EXPLORE_MAX_STEPS = 20;
|
|
31
|
-
|
|
36
|
+
function resolveExploreSteps(explicit) {
|
|
37
|
+
const configured = getEffectiveConfig().agent?.exploreMaxSteps;
|
|
38
|
+
const value = explicit ?? configured ?? EXPLORE_MAX_STEPS;
|
|
39
|
+
return Number.isFinite(value) && value >= 1 ? Math.floor(value) : EXPLORE_MAX_STEPS;
|
|
40
|
+
}
|
|
41
|
+
export function createExploreTool(modelId, abortSignal, onUsage, context = {}) {
|
|
32
42
|
return tool({
|
|
33
43
|
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
44
|
|
|
@@ -53,22 +63,23 @@ Examples:
|
|
|
53
63
|
const level = thoroughness ?? "medium";
|
|
54
64
|
console.error(`\x1b[90m ┌─ Explore (${level}): ${query.slice(0, 60)}\x1b[0m`);
|
|
55
65
|
try {
|
|
56
|
-
const result = await runExploreAgent(query, level, modelId, abortSignal);
|
|
66
|
+
const result = await runExploreAgent(query, level, modelId, abortSignal, context);
|
|
57
67
|
if (result.usage)
|
|
58
68
|
onUsage?.(result.usage);
|
|
59
69
|
console.error(`\x1b[90m └─ ✓ Done\x1b[0m`);
|
|
60
|
-
return truncateToolOutput(result.text, { direction: "
|
|
70
|
+
return truncateToolOutput(result.text, { direction: "tail" }).content;
|
|
61
71
|
}
|
|
62
72
|
catch (err) {
|
|
63
73
|
if (abortSignal?.aborted)
|
|
64
74
|
return "Explore cancelled by user.";
|
|
75
|
+
log("error", `explore agent failed: ${err?.message ?? String(err)}`);
|
|
65
76
|
console.error(`\x1b[90m └─ ✗ Failed: ${err.message}\x1b[0m`);
|
|
66
77
|
return `Explore error: ${err.message}`;
|
|
67
78
|
}
|
|
68
79
|
},
|
|
69
80
|
});
|
|
70
81
|
}
|
|
71
|
-
async function runExploreAgent(query, thoroughness, modelId, abortSignal) {
|
|
82
|
+
async function runExploreAgent(query, thoroughness, modelId, abortSignal, context = {}) {
|
|
72
83
|
const model = resolveModel(modelId);
|
|
73
84
|
// Read-only tools only
|
|
74
85
|
const tools = {
|
|
@@ -88,13 +99,15 @@ Task: ${query}`;
|
|
|
88
99
|
const signal = abortSignal ? AbortSignal.any([abortSignal, controller.signal]) : controller.signal;
|
|
89
100
|
const result = streamText({
|
|
90
101
|
model,
|
|
91
|
-
system:
|
|
102
|
+
system: exploreSystem(),
|
|
92
103
|
messages,
|
|
93
104
|
tools,
|
|
94
|
-
stopWhen: stepCountIs(
|
|
105
|
+
stopWhen: stepCountIs(resolveExploreSteps(context.maxSteps)),
|
|
95
106
|
maxRetries: 2,
|
|
96
107
|
abortSignal: signal,
|
|
97
|
-
onError() {
|
|
108
|
+
onError({ error }) {
|
|
109
|
+
log("warn", `explore step failed (will retry): ${String(error)}`);
|
|
110
|
+
},
|
|
98
111
|
});
|
|
99
112
|
let assistantText = "";
|
|
100
113
|
for await (const event of result.fullStream) {
|
|
@@ -104,10 +117,21 @@ Task: ${query}`;
|
|
|
104
117
|
break;
|
|
105
118
|
case "tool-call":
|
|
106
119
|
console.error(`\x1b[90m │ ⚡ ${event.toolName}\x1b[0m`);
|
|
120
|
+
if (context.shouldStop?.()) {
|
|
121
|
+
log("warn", "explore agent stopped: parent budget reached");
|
|
122
|
+
controller.abort();
|
|
123
|
+
return {
|
|
124
|
+
text: unwrapXmlSearchTags(stripThinkingFromAssistantText(assistantText)) +
|
|
125
|
+
"\n\n[Explore stopped: run budget reached]",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
107
128
|
break;
|
|
108
129
|
case "error":
|
|
130
|
+
log("error", `explore stream error: ${String(event.error)}`);
|
|
109
131
|
controller.abort();
|
|
110
|
-
return {
|
|
132
|
+
return {
|
|
133
|
+
text: unwrapXmlSearchTags(stripThinkingFromAssistantText(assistantText)) || `[Explore error: ${event.error}]`,
|
|
134
|
+
};
|
|
111
135
|
}
|
|
112
136
|
}
|
|
113
137
|
let usage;
|
|
@@ -116,18 +140,54 @@ Task: ${query}`;
|
|
|
116
140
|
}
|
|
117
141
|
catch { }
|
|
118
142
|
return {
|
|
119
|
-
text: stripThinkingFromAssistantText(assistantText) || "(explore agent produced no output)",
|
|
143
|
+
text: unwrapXmlSearchTags(stripThinkingFromAssistantText(assistantText)) || "(explore agent produced no output)",
|
|
120
144
|
usage,
|
|
121
145
|
};
|
|
122
146
|
}
|
|
123
147
|
/* ── Read-only bash: white-listed commands only, no redirection / chaining ── */
|
|
124
148
|
const READ_ONLY_BINS = new Set([
|
|
125
|
-
"ls",
|
|
126
|
-
"
|
|
127
|
-
"
|
|
149
|
+
"ls",
|
|
150
|
+
"find",
|
|
151
|
+
"cat",
|
|
152
|
+
"wc",
|
|
153
|
+
"head",
|
|
154
|
+
"tail",
|
|
155
|
+
"grep",
|
|
156
|
+
"rg",
|
|
157
|
+
"tree",
|
|
158
|
+
"pwd",
|
|
159
|
+
"stat",
|
|
160
|
+
"du",
|
|
161
|
+
"file",
|
|
162
|
+
"which",
|
|
163
|
+
"echo",
|
|
164
|
+
"printf",
|
|
165
|
+
"sort",
|
|
166
|
+
"uniq",
|
|
167
|
+
"cut",
|
|
168
|
+
"tr",
|
|
169
|
+
"awk",
|
|
170
|
+
"sed",
|
|
171
|
+
"basename",
|
|
172
|
+
"dirname",
|
|
173
|
+
"realpath",
|
|
174
|
+
"env",
|
|
175
|
+
"printenv",
|
|
176
|
+
"git",
|
|
128
177
|
]);
|
|
129
178
|
const READ_ONLY_GIT_CMDS = new Set([
|
|
130
|
-
"status",
|
|
179
|
+
"status",
|
|
180
|
+
"log",
|
|
181
|
+
"diff",
|
|
182
|
+
"show",
|
|
183
|
+
"rev-parse",
|
|
184
|
+
"branch",
|
|
185
|
+
"remote",
|
|
186
|
+
"tag",
|
|
187
|
+
"ls-files",
|
|
188
|
+
"help",
|
|
189
|
+
"--version",
|
|
190
|
+
"version",
|
|
131
191
|
]);
|
|
132
192
|
/** True if the command only inspects state (no writes, redirection, pipes, or chaining). */
|
|
133
193
|
export function isReadOnlyCommand(command) {
|
package/dist/tools/glob.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { tool, jsonSchema } from "ai";
|
|
2
2
|
import { glob } from "glob";
|
|
3
3
|
import { truncateToolOutput } from "../tool-output.js";
|
|
4
|
+
import { pathAccessError } from "../sandbox.js";
|
|
4
5
|
export const globTool = tool({
|
|
5
6
|
description: "Find files matching a glob pattern. Returns a list of file paths. Use this to discover project structure and find files.",
|
|
6
7
|
inputSchema: jsonSchema({
|
|
@@ -12,6 +13,9 @@ export const globTool = tool({
|
|
|
12
13
|
required: ["pattern"],
|
|
13
14
|
}),
|
|
14
15
|
execute: async ({ pattern, cwd }) => {
|
|
16
|
+
const denied = pathAccessError(cwd ?? ".", "read");
|
|
17
|
+
if (denied)
|
|
18
|
+
return `Error: ${denied}`;
|
|
15
19
|
try {
|
|
16
20
|
const matches = await glob(pattern, {
|
|
17
21
|
cwd: cwd ?? process.cwd(),
|
package/dist/tools/grep.js
CHANGED
|
@@ -3,6 +3,7 @@ import { readdir, readFile, stat } from "fs/promises";
|
|
|
3
3
|
import { spawn } from "child_process";
|
|
4
4
|
import path from "path";
|
|
5
5
|
import { truncateToolOutput } from "../tool-output.js";
|
|
6
|
+
import { pathAccessError } from "../sandbox.js";
|
|
6
7
|
const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", ".out", "coverage", ".min-agent"]);
|
|
7
8
|
const MAX_RESULTS = 500;
|
|
8
9
|
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
@@ -10,7 +11,10 @@ const MAX_RG_OUTPUT_BYTES = 8 * 1024 * 1024;
|
|
|
10
11
|
const WALK_CONCURRENCY = 8;
|
|
11
12
|
/** Convert a simple glob like "*.ts" into an anchored regex for file names. */
|
|
12
13
|
function globToRegex(glob) {
|
|
13
|
-
const escaped = glob
|
|
14
|
+
const escaped = glob
|
|
15
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
16
|
+
.replace(/\*/g, ".*")
|
|
17
|
+
.replace(/\?/g, ".");
|
|
14
18
|
return new RegExp(`^${escaped}$`);
|
|
15
19
|
}
|
|
16
20
|
/** Fast path: shell out to ripgrep when available. Resolves null if rg is missing or fails. */
|
|
@@ -19,19 +23,19 @@ function rgSearch(pattern, root, includeRegex, ignore) {
|
|
|
19
23
|
"--no-heading",
|
|
20
24
|
"--line-number",
|
|
21
25
|
"-i",
|
|
22
|
-
"-e",
|
|
23
|
-
|
|
24
|
-
"-
|
|
25
|
-
"
|
|
26
|
+
"-e",
|
|
27
|
+
pattern,
|
|
28
|
+
"--max-count",
|
|
29
|
+
"1",
|
|
30
|
+
"-g",
|
|
31
|
+
"!*.lock",
|
|
32
|
+
"--max-filesize",
|
|
33
|
+
String(MAX_FILE_BYTES),
|
|
26
34
|
];
|
|
27
35
|
for (const dir of ignore)
|
|
28
36
|
args.push("-g", `!${dir}/**`);
|
|
29
37
|
if (includeRegex) {
|
|
30
|
-
const glob = includeRegex.source
|
|
31
|
-
.replace(/^\^/, "")
|
|
32
|
-
.replace(/\$$/, "")
|
|
33
|
-
.replace(/\\\./g, ".")
|
|
34
|
-
.replace(/\.\*/g, "*");
|
|
38
|
+
const glob = includeRegex.source.replace(/^\^/, "").replace(/\$$/, "").replace(/\\\./g, ".").replace(/\.\*/g, "*");
|
|
35
39
|
args.push("-g", glob);
|
|
36
40
|
}
|
|
37
41
|
args.push(root);
|
|
@@ -132,6 +136,9 @@ export const grepTool = tool({
|
|
|
132
136
|
const regex = new RegExp(pattern);
|
|
133
137
|
const includeRegex = include ? globToRegex(include) : null;
|
|
134
138
|
const root = path.resolve(process.cwd(), searchPath ?? ".");
|
|
139
|
+
const denied = pathAccessError(root, "read");
|
|
140
|
+
if (denied)
|
|
141
|
+
return `Error: ${denied}`;
|
|
135
142
|
const results = [];
|
|
136
143
|
const info = await stat(root).catch(() => null);
|
|
137
144
|
if (!info)
|
package/dist/tools/index.js
CHANGED
|
@@ -7,11 +7,14 @@ import { globTool } from "./glob.js";
|
|
|
7
7
|
import { grepTool } from "./grep.js";
|
|
8
8
|
import { webSearchTool } from "./web_search.js";
|
|
9
9
|
import { webFetchTool } from "./web_fetch.js";
|
|
10
|
-
import { todoTool } from "./todo.js";
|
|
11
10
|
import { questionTool } from "./question.js";
|
|
12
11
|
import { codeSearchTool } from "./code_search.js";
|
|
13
|
-
/**
|
|
14
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Builtin tools shared by main runs and sub-agents. `todo` is deliberately
|
|
14
|
+
* absent: it must be bound to a per-run TaskState, so callers register their
|
|
15
|
+
* own instance via createTodoTool.
|
|
16
|
+
*/
|
|
17
|
+
export function createTools() {
|
|
15
18
|
const tools = {
|
|
16
19
|
bash: bashTool,
|
|
17
20
|
read: readTool,
|
|
@@ -22,24 +25,6 @@ export function createChatTools() {
|
|
|
22
25
|
grep: grepTool,
|
|
23
26
|
search_web: webSearchTool,
|
|
24
27
|
web_fetch: webFetchTool,
|
|
25
|
-
todo: todoTool,
|
|
26
|
-
question: questionTool,
|
|
27
|
-
};
|
|
28
|
-
return tools;
|
|
29
|
-
}
|
|
30
|
-
/** Code mode: full tool set including task tracking and code search. Todo replaced per-run by the caller. */
|
|
31
|
-
export function createCodeTools() {
|
|
32
|
-
const tools = {
|
|
33
|
-
bash: bashTool,
|
|
34
|
-
read: readTool,
|
|
35
|
-
write: writeTool,
|
|
36
|
-
edit: editTool,
|
|
37
|
-
apply_patch: applyPatchTool,
|
|
38
|
-
glob: globTool,
|
|
39
|
-
grep: grepTool,
|
|
40
|
-
search_web: webSearchTool,
|
|
41
|
-
web_fetch: webFetchTool,
|
|
42
|
-
todo: todoTool,
|
|
43
28
|
question: questionTool,
|
|
44
29
|
};
|
|
45
30
|
if (process.env.EXA_API_KEY) {
|
package/dist/tools/question.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { tool, jsonSchema } from "ai";
|
|
2
2
|
import { askQuestion } from "../confirm.js";
|
|
3
|
+
import { normalizeQuestionOptions, normalizeQuestionPrompt } from "../question-format.js";
|
|
3
4
|
export const questionTool = tool({
|
|
4
5
|
description: `Ask the user a question to get clarification before proceeding. Use this when:
|
|
5
6
|
- The task is ambiguous and you need more information
|
|
@@ -7,28 +8,46 @@ export const questionTool = tool({
|
|
|
7
8
|
- You need confirmation before a potentially destructive action
|
|
8
9
|
- You're unsure about a requirement or preference
|
|
9
10
|
|
|
10
|
-
Provide clear, specific
|
|
11
|
+
Provide a clear, specific question. Optional choices should be an array of strings. Object items with label/description are also accepted.`,
|
|
11
12
|
inputSchema: jsonSchema({
|
|
12
13
|
type: "object",
|
|
13
14
|
properties: {
|
|
14
15
|
question: { type: "string", description: "The question to ask the user" },
|
|
15
16
|
options: {
|
|
16
17
|
type: "array",
|
|
17
|
-
items: {
|
|
18
|
-
|
|
18
|
+
items: {
|
|
19
|
+
anyOf: [
|
|
20
|
+
{ type: "string" },
|
|
21
|
+
{
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
label: { type: "string" },
|
|
25
|
+
description: { type: "string" },
|
|
26
|
+
key: { type: "string" },
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
description: "Optional list of choices. Prefer plain strings; {label, description} objects are also accepted.",
|
|
19
32
|
},
|
|
20
33
|
},
|
|
21
34
|
required: ["question"],
|
|
22
35
|
}),
|
|
23
36
|
execute: async ({ question, options }) => {
|
|
24
|
-
const
|
|
37
|
+
const prompt = normalizeQuestionPrompt(question);
|
|
38
|
+
if (!prompt.trim())
|
|
39
|
+
return "Error: question is required.";
|
|
40
|
+
const choices = normalizeQuestionOptions(options);
|
|
41
|
+
const answer = await askQuestion(prompt, choices.length > 0 ? choices : undefined);
|
|
25
42
|
if (answer === null)
|
|
26
43
|
return "User cancelled the question";
|
|
27
|
-
if (
|
|
28
|
-
const
|
|
29
|
-
if (
|
|
30
|
-
return `User chose: ${
|
|
31
|
-
|
|
44
|
+
if (choices.length > 0) {
|
|
45
|
+
const exact = choices.find((c) => c === answer);
|
|
46
|
+
if (exact)
|
|
47
|
+
return `User chose: ${exact}`;
|
|
48
|
+
const idx = parseInt(answer, 10) - 1;
|
|
49
|
+
if (Number.isInteger(idx) && idx >= 0 && idx < choices.length)
|
|
50
|
+
return `User chose: ${choices[idx]}`;
|
|
32
51
|
}
|
|
33
52
|
return `User answered: ${answer}`;
|
|
34
53
|
},
|
package/dist/tools/read.js
CHANGED
|
@@ -4,9 +4,8 @@ import { stat, readFile } from "fs/promises";
|
|
|
4
4
|
import { createInterface } from "readline";
|
|
5
5
|
import path from "path";
|
|
6
6
|
import { TOOL_OUTPUT_MAX_BYTES, truncateToolOutput } from "../tool-output.js";
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
const instructionTracker = new InstructionTracker();
|
|
7
|
+
import { getActiveInstructionTracker } from "../instructions.js";
|
|
8
|
+
import { pathAccessError } from "../sandbox.js";
|
|
10
9
|
/** Files above this size are streamed instead of loaded into memory. */
|
|
11
10
|
const STREAM_THRESHOLD_BYTES = 20 * 1024 * 1024;
|
|
12
11
|
/** Stream lines [start, end] (1-indexed, inclusive) without loading the whole file. */
|
|
@@ -65,6 +64,9 @@ export const readTool = tool({
|
|
|
65
64
|
required: ["filePath"],
|
|
66
65
|
}),
|
|
67
66
|
execute: async ({ filePath, startLine, endLine }) => {
|
|
67
|
+
const denied = pathAccessError(filePath, "read");
|
|
68
|
+
if (denied)
|
|
69
|
+
return `Error: ${denied}`;
|
|
68
70
|
const resolved = path.resolve(process.cwd(), filePath);
|
|
69
71
|
try {
|
|
70
72
|
const info = await stat(resolved);
|
|
@@ -94,7 +96,7 @@ export const readTool = tool({
|
|
|
94
96
|
result = await readFile(resolved, "utf-8");
|
|
95
97
|
}
|
|
96
98
|
// Context-aware: discover nearby instruction files
|
|
97
|
-
const nearbyInstructions =
|
|
99
|
+
const nearbyInstructions = getActiveInstructionTracker().resolveForFile(resolved);
|
|
98
100
|
if (nearbyInstructions.length > 0) {
|
|
99
101
|
result += "\n\n<system-reminder>\n" + nearbyInstructions.join("\n\n") + "\n</system-reminder>";
|
|
100
102
|
}
|