min-agent 0.2.1 → 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.
Files changed (137) hide show
  1. package/README.md +242 -31
  2. package/dist/agent.js +1233 -485
  3. package/dist/assistant-stream.js +11 -7
  4. package/dist/cli/commands/chat.js +10 -0
  5. package/dist/cli/commands/exec.js +32 -0
  6. package/dist/cli/commands/history.js +58 -0
  7. package/dist/cli/commands/index.js +224 -0
  8. package/dist/cli/commands/init.js +18 -0
  9. package/dist/cli/commands/mcp.js +173 -0
  10. package/dist/cli/commands/memory.js +69 -0
  11. package/dist/cli/commands/models.js +21 -0
  12. package/dist/cli/commands/permission.js +12 -0
  13. package/dist/cli/commands/rules.js +33 -0
  14. package/dist/cli/commands/sandbox.js +13 -0
  15. package/dist/cli/commands/serve.js +9 -0
  16. package/dist/cli/commands/setup.js +4 -0
  17. package/dist/cli/commands/shared.js +16 -0
  18. package/dist/cli/commands/skills.js +119 -0
  19. package/dist/cli/commands/update.js +7 -0
  20. package/dist/cli/commands/write-config.js +30 -0
  21. package/dist/cli/errors.js +36 -0
  22. package/dist/cli/exec-prompt.js +26 -0
  23. package/dist/cli/option-helpers.js +53 -0
  24. package/dist/cli/program.js +180 -0
  25. package/dist/cli.js +7 -632
  26. package/dist/clipboard.js +59 -23
  27. package/dist/code-mode.js +35 -17
  28. package/dist/compaction.js +457 -169
  29. package/dist/config.js +298 -38
  30. package/dist/confirm.js +105 -9
  31. package/dist/context-window.js +156 -75
  32. package/dist/doom-loop.js +268 -26
  33. package/dist/fetch-timeout.js +152 -0
  34. package/dist/http-approvals.js +60 -0
  35. package/dist/http.js +119 -0
  36. package/dist/instructions.js +72 -33
  37. package/dist/logger.js +95 -0
  38. package/dist/markdown.js +35 -50
  39. package/dist/mcp.js +847 -102
  40. package/dist/memory.js +128 -45
  41. package/dist/output.js +42 -31
  42. package/dist/paste-handler.js +3 -3
  43. package/dist/permission-cli.js +43 -0
  44. package/dist/plugins.js +76 -11
  45. package/dist/pricing.js +119 -0
  46. package/dist/provider.js +34 -15
  47. package/dist/question-format.js +60 -0
  48. package/dist/sandbox-cli.js +82 -0
  49. package/dist/sandbox.js +403 -0
  50. package/dist/save-throttle.js +45 -0
  51. package/dist/serve/common.js +404 -0
  52. package/dist/serve/routes-chat.js +347 -0
  53. package/dist/serve/routes-mcp.js +212 -0
  54. package/dist/serve/routes-memory.js +66 -0
  55. package/dist/serve/routes-meta.js +205 -0
  56. package/dist/serve/routes-sessions.js +61 -0
  57. package/dist/serve/routes-skills.js +70 -0
  58. package/dist/serve.js +74 -635
  59. package/dist/sessions.js +197 -15
  60. package/dist/skills.js +531 -77
  61. package/dist/synthetic.js +7 -0
  62. package/dist/title-gen.js +9 -2
  63. package/dist/token-display.js +36 -0
  64. package/dist/tool-display.js +178 -0
  65. package/dist/tool-output.js +53 -46
  66. package/dist/tools/apply_patch.js +265 -0
  67. package/dist/tools/atomic-file.js +35 -0
  68. package/dist/tools/backend.js +61 -0
  69. package/dist/tools/bash.js +186 -71
  70. package/dist/tools/code_search.js +13 -6
  71. package/dist/tools/edit.js +26 -9
  72. package/dist/tools/explore.js +144 -16
  73. package/dist/tools/glob.js +7 -3
  74. package/dist/tools/grep.js +153 -14
  75. package/dist/tools/index.js +9 -24
  76. package/dist/tools/question.js +31 -30
  77. package/dist/tools/read.js +77 -15
  78. package/dist/tools/search-searxng.js +223 -0
  79. package/dist/tools/search-serper.js +189 -0
  80. package/dist/tools/task.js +100 -33
  81. package/dist/tools/todo.js +178 -67
  82. package/dist/tools/web_fetch.js +158 -46
  83. package/dist/tools/web_search.js +217 -29
  84. package/dist/tools/write.js +34 -11
  85. package/dist/tui/App.js +89 -6
  86. package/dist/tui/ConfirmBar.js +57 -4
  87. package/dist/tui/InputBar.js +504 -44
  88. package/dist/tui/MessageList.js +674 -20
  89. package/dist/tui/ModelPicker.js +113 -0
  90. package/dist/tui/QuestionBar.js +136 -0
  91. package/dist/tui/SessionPicker.js +79 -0
  92. package/dist/tui/StatusBar.js +14 -12
  93. package/dist/tui/agent-runner.js +223 -0
  94. package/dist/tui/caret-pos.js +177 -0
  95. package/dist/tui/caret.js +69 -0
  96. package/dist/tui/click-count.js +13 -0
  97. package/dist/tui/diff-view.js +61 -0
  98. package/dist/tui/drag-state.js +49 -0
  99. package/dist/tui/hydrate.js +129 -0
  100. package/dist/tui/index.js +189 -31
  101. package/dist/tui/input-history.js +125 -0
  102. package/dist/tui/layout.js +88 -0
  103. package/dist/tui/mouse.js +46 -0
  104. package/dist/tui/prompt-queue.js +24 -0
  105. package/dist/tui/selection.js +226 -0
  106. package/dist/tui/session-switch.js +28 -0
  107. package/dist/tui/slash-commands.js +106 -0
  108. package/dist/tui/slash-handler.js +545 -0
  109. package/dist/tui/text-width.js +113 -0
  110. package/dist/tui/theme.js +12 -0
  111. package/dist/tui/token-info.js +7 -0
  112. package/dist/tui/tool-children.js +19 -0
  113. package/dist/tui/undo-stack.js +14 -0
  114. package/dist/tui/use-sgr-mouse.js +29 -0
  115. package/dist/tui-chat.js +346 -330
  116. package/dist/updater.js +116 -0
  117. package/dist/xml-search.js +194 -0
  118. package/docs/API.md +410 -32
  119. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
  120. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
  121. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
  122. package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
  123. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
  124. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
  125. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
  126. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
  127. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
  128. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
  129. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
  130. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
  131. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
  132. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
  133. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
  134. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
  135. package/package.json +12 -8
  136. package/skills/self-config/SKILL.md +90 -0
  137. package/skills/self-config/reference.md +149 -0
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Shared plumbing for the HTTP-backed tools (`search_web`, `web_fetch`):
3
+ * base-URL resolution, consistent "how to configure this" copy, and fetch
4
+ * error description. Kept separate so both tools report problems the same way.
5
+ */
6
+ /** Human-readable pointer to both configuration channels. */
7
+ export function configHint(spec) {
8
+ return `Configure a working instance via "${spec.configKey}" in ~/.min-agent/config.json or the ${spec.envName} env var.`;
9
+ }
10
+ /**
11
+ * Normalize a user-supplied base URL: trim whitespace and trailing slashes so
12
+ * callers can always append "/path" without producing a double slash.
13
+ * Returns an error string for anything that is not an absolute http(s) URL.
14
+ */
15
+ export function normalizeBaseURL(raw, source) {
16
+ const trimmed = raw.trim();
17
+ if (trimmed === "")
18
+ return { ok: false, error: `${source} is empty` };
19
+ let parsed;
20
+ try {
21
+ parsed = new URL(trimmed);
22
+ }
23
+ catch {
24
+ return { ok: false, error: `${source} is not a valid absolute URL: ${trimmed}` };
25
+ }
26
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
27
+ return { ok: false, error: `${source} must use http or https, got "${parsed.protocol}"` };
28
+ }
29
+ // Keep any path prefix (instances behind a reverse proxy subpath) but drop
30
+ // query/hash and trailing slashes.
31
+ parsed.search = "";
32
+ parsed.hash = "";
33
+ return { ok: true, base: parsed.toString().replace(/\/+$/, "") };
34
+ }
35
+ /**
36
+ * Resolve a backend base URL. The environment variable wins over the config
37
+ * file so a single run can be redirected from the shell without editing
38
+ * config.json; the built-in default is the last resort.
39
+ */
40
+ export function resolveBackendBase(spec, configValue) {
41
+ const env = process.env[spec.envName];
42
+ if (env?.trim())
43
+ return normalizeBaseURL(env, spec.envName);
44
+ if (configValue?.trim())
45
+ return normalizeBaseURL(configValue, `"${spec.configKey}" in config.json`);
46
+ return normalizeBaseURL(spec.fallback, "the built-in default backend URL");
47
+ }
48
+ /** Turn a thrown fetch error into a short, actionable sentence. */
49
+ export function describeFetchError(err) {
50
+ if (err instanceof Error) {
51
+ // AbortSignal.timeout rejects with a TimeoutError DOMException.
52
+ if (err.name === "TimeoutError" || err.name === "AbortError")
53
+ return "the request timed out";
54
+ const cause = err.cause;
55
+ const causeCode = typeof cause === "object" && cause !== null ? cause.code : undefined;
56
+ if (typeof causeCode === "string")
57
+ return `${err.message} (${causeCode})`;
58
+ return err.message;
59
+ }
60
+ return String(err);
61
+ }
@@ -1,99 +1,214 @@
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
+ import { planSandboxedSpawn } from "../sandbox.js";
7
+ import { getEffectiveConfig } from "../config.js";
8
+ const MAX_STREAM_BYTES = 200 * 1024;
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
+ }
4
21
  /** Track active child processes so they can be killed on abort (e.g. ESC). */
5
- const activeProcesses = new Set();
22
+ const activeRuns = new Set();
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
+ }
30
+ /**
31
+ * Registered exactly once: Ctrl+C kills all active commands instead of leaking a handler per call.
32
+ * When nothing is running, re-dispatch the signal so the process can exit normally
33
+ * (single-shot mode must not swallow Ctrl+C).
34
+ */
35
+ function ensureSigintHandler() {
36
+ if (sigintRegistered)
37
+ return;
38
+ sigintRegistered = true;
39
+ const onSigint = () => {
40
+ if (activeRuns.size === 0) {
41
+ process.removeListener("SIGINT", onSigint);
42
+ sigintRegistered = false;
43
+ process.kill(process.pid, "SIGINT");
44
+ return;
45
+ }
46
+ for (const run of [...activeRuns]) {
47
+ interruptRun(run);
48
+ }
49
+ };
50
+ process.on("SIGINT", onSigint);
51
+ }
52
+ function interruptRun(run) {
53
+ run.killed = true;
54
+ run.settled = true;
55
+ if (run.timer)
56
+ clearTimeout(run.timer);
57
+ killProcess(run.proc.pid);
58
+ run.resolve(formatOutput(run) + "\n\n[Command interrupted by user.]");
59
+ }
6
60
  /** Kill all active child processes spawned by the bash tool. */
7
61
  export function killActiveProcesses() {
8
- for (const proc of activeProcesses) {
9
- killProcess(proc.pid);
62
+ for (const run of activeRuns) {
63
+ run.killed = true;
64
+ run.settled = true;
65
+ if (run.timer)
66
+ clearTimeout(run.timer);
67
+ killProcess(run.proc.pid);
68
+ run.resolve(formatOutput(run) + "\n\n[Command interrupted by user.]");
10
69
  }
11
- activeProcesses.clear();
70
+ activeRuns.clear();
12
71
  }
13
72
  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).",
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).",
15
74
  inputSchema: jsonSchema({
16
75
  type: "object",
17
76
  properties: {
18
77
  command: { type: "string", description: "The shell command to execute" },
19
- 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." },
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
+ },
20
86
  },
21
87
  required: ["command"],
22
88
  }),
23
- execute: async ({ command, timeout }) => {
89
+ execute: async ({ command, timeout, cwd }) => {
24
90
  if (!isAutoApprove() && isDangerousCommand(command)) {
25
91
  const approved = await confirm(`Execute dangerous command: ${command}`);
26
92
  if (!approved)
27
93
  return "Command rejected by user.";
28
94
  }
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);
95
+ return executeBash(command, timeout, cwd);
96
+ },
97
+ });
98
+ /** Shared bash execution engine (no permission prompt). Used by bashTool and the read-only variant. */
99
+ export async function executeBash(command, timeout, cwd) {
100
+ ensureSigintHandler();
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
+ }
117
+ const run = {
118
+ proc: undefined,
119
+ resolve: (value) => resolve(notice + value),
120
+ stdoutChunks: [],
121
+ stderrChunks: [],
122
+ killed: false,
123
+ settled: false,
124
+ stdoutTruncated: false,
125
+ stderrTruncated: false,
126
+ };
127
+ activeRuns.add(run);
128
+ const proc = spawn(plan.file, plan.args, {
129
+ shell: plan.shell,
130
+ cwd: plan.cwd,
131
+ stdio: ["ignore", "pipe", "pipe"],
132
+ detached: process.platform !== "win32",
133
+ env: plan.env,
134
+ });
135
+ run.proc = proc;
136
+ const collect = (chunks, truncated) => (chunk) => {
137
+ const total = chunks.reduce((sum, c) => sum + c.byteLength, 0);
138
+ if (total >= MAX_STREAM_BYTES) {
139
+ run[truncated] = true;
140
+ return;
54
141
  }
55
- // Allow user to interrupt with Ctrl+C (SIGINT)
56
- const sigintHandler = () => {
57
- killed = true;
58
- if (timer)
59
- clearTimeout(timer);
142
+ chunks.push(chunk);
143
+ };
144
+ proc.stdout?.on("data", collect(run.stdoutChunks, "stdoutTruncated"));
145
+ proc.stderr?.on("data", collect(run.stderrChunks, "stderrTruncated"));
146
+ // Timeout kill (explicit value, config default, or the 10 minute fallback)
147
+ const effectiveTimeout = resolveBashTimeout(timeout);
148
+ if (effectiveTimeout > 0) {
149
+ run.timer = setTimeout(() => {
150
+ run.killed = true;
151
+ run.settled = true;
60
152
  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
- });
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);
156
+ }
157
+ proc.on("close", (code) => {
158
+ activeRuns.delete(run);
159
+ if (run.timer)
160
+ clearTimeout(run.timer);
161
+ if (run.killed || run.settled)
162
+ return;
163
+ run.settled = true;
164
+ const output = formatOutput(run);
165
+ if (code === 0) {
166
+ run.resolve(output || "(no output)");
167
+ }
168
+ else {
169
+ run.resolve(`Exit code ${code}\n${output || "(no output)"}`);
170
+ }
88
171
  });
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);
172
+ proc.on("error", (err) => {
173
+ activeRuns.delete(run);
174
+ if (run.timer)
175
+ clearTimeout(run.timer);
176
+ if (run.killed || run.settled)
177
+ return;
178
+ run.settled = true;
179
+ run.resolve(`Error: ${err.message}`);
180
+ });
181
+ });
182
+ }
183
+ /** Merge stdout + stderr, saved to disk when too large for the response budget. */
184
+ function formatOutput(run) {
185
+ const out = Buffer.concat(run.stdoutChunks).toString("utf-8").trim();
186
+ const err = Buffer.concat(run.stderrChunks).toString("utf-8").trim();
187
+ let text = out;
188
+ if (err)
189
+ text = text ? `${text}\n\n--- stderr ---\n${err}` : err;
190
+ if (run.stdoutTruncated || run.stderrTruncated) {
191
+ text += `\n\n[Output capped at ${MAX_STREAM_BYTES} bytes; the remainder was discarded.]`;
95
192
  }
96
- return output;
193
+ const total = Buffer.byteLength(text, "utf-8");
194
+ if (total <= MAX_OUTPUT_BYTES)
195
+ return text;
196
+ const filePath = writeFullToolOutput(text);
197
+ return `${truncateUtf8(text, MAX_OUTPUT_BYTES)}\n\nFull output saved to: ${filePath}`;
198
+ }
199
+ function truncateUtf8(text, maxBytes) {
200
+ const buf = Buffer.from(text, "utf-8");
201
+ const headLen = Math.floor(maxBytes * 0.5);
202
+ const tailLen = Math.floor(maxBytes * 0.1);
203
+ let h = headLen;
204
+ while (h > 0 && (buf[h] & 0xc0) === 0x80)
205
+ h--;
206
+ let t = buf.length - tailLen;
207
+ while (t < buf.length && (buf[t] & 0xc0) === 0x80)
208
+ t++;
209
+ return (buf.subarray(0, h).toString("utf-8") +
210
+ `\n\n...(truncated, ${buf.length} bytes total)...\n\n` +
211
+ buf.subarray(Math.max(t, h)).toString("utf-8"));
97
212
  }
98
213
  function killProcess(pid) {
99
214
  if (!pid)
@@ -1,8 +1,7 @@
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
+ import { getEffectiveSandboxPolicy, networkDeniedMessage } from "../sandbox.js";
4
+ const EXA_MCP_URL = "https://mcp.exa.ai/mcp";
6
5
  export const codeSearchTool = tool({
7
6
  description: `Search and get relevant context for any programming task using Exa Code API.
8
7
  Provides high-quality, fresh context for libraries, SDKs, and APIs.
@@ -27,6 +26,11 @@ Usage:
27
26
  required: ["query"],
28
27
  }),
29
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
+ }
30
34
  const tokens = Math.max(1000, Math.min(50000, tokensNum ?? 5000));
31
35
  try {
32
36
  const result = await callExaCode(query, tokens);
@@ -36,7 +40,7 @@ Usage:
36
40
  return truncateToolOutput(result, { direction: "head" }).content;
37
41
  }
38
42
  catch (err) {
39
- return `Code search error: ${err.message}`;
43
+ return `Code search error: ${err instanceof Error ? err.message : String(err)}`;
40
44
  }
41
45
  },
42
46
  });
@@ -59,6 +63,7 @@ async function callExaCode(query, tokensNum) {
59
63
  headers: {
60
64
  "Content-Type": "application/json",
61
65
  Accept: "application/json, text/event-stream",
66
+ ...(process.env.EXA_API_KEY ? { Authorization: `Bearer ${process.env.EXA_API_KEY}` } : {}),
62
67
  },
63
68
  body,
64
69
  signal: AbortSignal.timeout(30000),
@@ -66,8 +71,10 @@ async function callExaCode(query, tokensNum) {
66
71
  if (!response.ok) {
67
72
  throw new Error(`Exa API returned ${response.status}`);
68
73
  }
69
- const text = await response.text();
70
- // Parse SSE response: look for "data: {...}" lines
74
+ return await parseExaResponse(await response.text());
75
+ }
76
+ /** Parse either an SSE stream or a direct JSON body into the tool result text. */
77
+ async function parseExaResponse(text) {
71
78
  for (const line of text.split("\n")) {
72
79
  if (!line.startsWith("data: "))
73
80
  continue;
@@ -1,7 +1,9 @@
1
1
  import { tool, jsonSchema } from "ai";
2
- import { readFileSync, writeFileSync, existsSync } from "fs";
2
+ import { readFile } from "fs/promises";
3
3
  import path from "path";
4
- import { confirm, isAutoApprove } from "../confirm.js";
4
+ import { confirm, isEditAutoApprove } from "../confirm.js";
5
+ import { pathAccessError } from "../sandbox.js";
6
+ import { atomicWriteFile } from "./atomic-file.js";
5
7
  export const editTool = tool({
6
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.",
7
9
  inputSchema: jsonSchema({
@@ -14,11 +16,21 @@ export const editTool = tool({
14
16
  required: ["filePath", "oldText", "newText"],
15
17
  }),
16
18
  execute: async ({ filePath, oldText, newText }) => {
19
+ if (!oldText)
20
+ return "Error: oldText must not be empty";
21
+ if (oldText === newText)
22
+ return "No change: oldText equals newText";
23
+ const denied = pathAccessError(filePath, "write");
24
+ if (denied)
25
+ return `Error: ${denied}`;
17
26
  const resolved = path.resolve(process.cwd(), filePath);
18
- if (!existsSync(resolved)) {
19
- return `Error: File not found: ${filePath}`;
27
+ let content;
28
+ try {
29
+ content = await readFile(resolved, "utf-8");
30
+ }
31
+ catch (err) {
32
+ return `Error: cannot read ${filePath}: ${err instanceof Error ? err.message : String(err)}`;
20
33
  }
21
- const content = readFileSync(resolved, "utf-8");
22
34
  const occurrences = content.split(oldText).length - 1;
23
35
  if (occurrences === 0) {
24
36
  // Try to help: show nearby content
@@ -36,14 +48,19 @@ export const editTool = tool({
36
48
  return `Error: oldText found ${occurrences} times in ${filePath}. Please provide more context to make the match unique.`;
37
49
  }
38
50
  // Confirm edit
39
- if (!isAutoApprove()) {
40
- const preview = oldText.length > 80 ? oldText.slice(0, 80) + "..." : oldText;
41
- const approved = await confirm(`Edit ${filePath}: replace "${preview}"`);
51
+ if (!isEditAutoApprove()) {
52
+ const preview = (s) => (s.length > 80 ? s.slice(0, 80) + "..." : s);
53
+ const approved = await confirm(`Edit ${filePath}:\nreplace "${preview(oldText)}"\nwith "${preview(newText)}"`);
42
54
  if (!approved)
43
55
  return "Edit rejected by user.";
44
56
  }
45
57
  const updated = content.replace(oldText, newText);
46
- writeFileSync(resolved, updated, "utf-8");
58
+ try {
59
+ await atomicWriteFile(resolved, updated);
60
+ }
61
+ catch (err) {
62
+ return `Error: cannot write ${filePath}: ${err instanceof Error ? err.message : String(err)}`;
63
+ }
47
64
  const oldLines = oldText.split("\n").length;
48
65
  const newLines = newText.split("\n").length;
49
66
  return `Edited ${filePath}: replaced ${oldLines} line(s) with ${newLines} line(s)`;