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.
Files changed (120) hide show
  1. package/README.md +111 -28
  2. package/dist/agent.js +1119 -256
  3. package/dist/cli/commands/chat.js +10 -0
  4. package/dist/cli/commands/exec.js +32 -0
  5. package/dist/cli/commands/history.js +58 -0
  6. package/dist/cli/commands/index.js +224 -0
  7. package/dist/cli/commands/init.js +18 -0
  8. package/dist/cli/commands/mcp.js +173 -0
  9. package/dist/cli/commands/memory.js +69 -0
  10. package/dist/cli/commands/models.js +21 -0
  11. package/dist/cli/commands/permission.js +12 -0
  12. package/dist/cli/commands/rules.js +33 -0
  13. package/dist/cli/commands/sandbox.js +13 -0
  14. package/dist/cli/commands/serve.js +9 -0
  15. package/dist/cli/commands/setup.js +4 -0
  16. package/dist/cli/commands/shared.js +16 -0
  17. package/dist/cli/commands/skills.js +119 -0
  18. package/dist/cli/commands/update.js +7 -0
  19. package/dist/cli/commands/write-config.js +30 -0
  20. package/dist/cli/errors.js +36 -0
  21. package/dist/cli/exec-prompt.js +26 -0
  22. package/dist/cli/option-helpers.js +53 -0
  23. package/dist/cli/program.js +180 -0
  24. package/dist/cli.js +5 -888
  25. package/dist/code-mode.js +32 -14
  26. package/dist/compaction.js +347 -160
  27. package/dist/config.js +119 -10
  28. package/dist/confirm.js +56 -9
  29. package/dist/context-window.js +107 -39
  30. package/dist/doom-loop.js +264 -29
  31. package/dist/fetch-timeout.js +152 -0
  32. package/dist/http-approvals.js +60 -0
  33. package/dist/instructions.js +21 -0
  34. package/dist/logger.js +33 -4
  35. package/dist/markdown.js +37 -11
  36. package/dist/mcp.js +328 -30
  37. package/dist/memory.js +97 -56
  38. package/dist/output.js +7 -5
  39. package/dist/permission-cli.js +43 -0
  40. package/dist/plugins.js +46 -8
  41. package/dist/pricing.js +4 -4
  42. package/dist/provider.js +23 -6
  43. package/dist/question-format.js +60 -0
  44. package/dist/sandbox-cli.js +82 -0
  45. package/dist/sandbox.js +403 -0
  46. package/dist/save-throttle.js +45 -0
  47. package/dist/serve/common.js +404 -0
  48. package/dist/serve/routes-chat.js +347 -0
  49. package/dist/serve/routes-mcp.js +212 -0
  50. package/dist/serve/routes-memory.js +66 -0
  51. package/dist/serve/routes-meta.js +205 -0
  52. package/dist/serve/routes-sessions.js +61 -0
  53. package/dist/serve/routes-skills.js +70 -0
  54. package/dist/serve.js +33 -883
  55. package/dist/sessions.js +53 -9
  56. package/dist/skills.js +82 -18
  57. package/dist/title-gen.js +8 -2
  58. package/dist/token-display.js +36 -0
  59. package/dist/tool-display.js +5 -0
  60. package/dist/tool-output.js +1 -3
  61. package/dist/tools/apply_patch.js +85 -11
  62. package/dist/tools/atomic-file.js +35 -0
  63. package/dist/tools/backend.js +2 -2
  64. package/dist/tools/bash.js +57 -19
  65. package/dist/tools/code_search.js +7 -1
  66. package/dist/tools/edit.js +11 -10
  67. package/dist/tools/explore.js +74 -14
  68. package/dist/tools/glob.js +4 -0
  69. package/dist/tools/grep.js +17 -10
  70. package/dist/tools/index.js +6 -21
  71. package/dist/tools/question.js +28 -9
  72. package/dist/tools/read.js +6 -4
  73. package/dist/tools/search-searxng.js +223 -0
  74. package/dist/tools/search-serper.js +189 -0
  75. package/dist/tools/task.js +84 -30
  76. package/dist/tools/todo.js +120 -19
  77. package/dist/tools/web_fetch.js +11 -3
  78. package/dist/tools/web_search.js +66 -556
  79. package/dist/tools/write.js +23 -6
  80. package/dist/tui/App.js +63 -14
  81. package/dist/tui/ConfirmBar.js +45 -13
  82. package/dist/tui/InputBar.js +150 -35
  83. package/dist/tui/MessageList.js +266 -125
  84. package/dist/tui/ModelPicker.js +8 -3
  85. package/dist/tui/QuestionBar.js +51 -19
  86. package/dist/tui/SessionPicker.js +79 -0
  87. package/dist/tui/StatusBar.js +8 -14
  88. package/dist/tui/agent-runner.js +142 -22
  89. package/dist/tui/caret-pos.js +48 -5
  90. package/dist/tui/caret.js +1 -1
  91. package/dist/tui/click-count.js +13 -0
  92. package/dist/tui/drag-state.js +8 -3
  93. package/dist/tui/hydrate.js +129 -0
  94. package/dist/tui/index.js +42 -13
  95. package/dist/tui/input-history.js +92 -11
  96. package/dist/tui/layout.js +75 -4
  97. package/dist/tui/prompt-queue.js +24 -0
  98. package/dist/tui/selection.js +113 -21
  99. package/dist/tui/session-switch.js +28 -0
  100. package/dist/tui/slash-commands.js +22 -6
  101. package/dist/tui/slash-handler.js +233 -58
  102. package/dist/tui/text-width.js +38 -16
  103. package/dist/tui/token-info.js +7 -0
  104. package/dist/tui/tool-children.js +19 -0
  105. package/dist/tui/undo-stack.js +1 -1
  106. package/dist/tui/use-sgr-mouse.js +3 -1
  107. package/dist/tui-chat.js +276 -40
  108. package/dist/updater.js +88 -29
  109. package/dist/xml-search.js +194 -0
  110. package/docs/API.md +257 -25
  111. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
  112. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
  113. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
  114. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
  115. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
  116. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
  117. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
  118. package/package.json +6 -1
  119. package/skills/self-config/SKILL.md +90 -0
  120. package/skills/self-config/reference.md +149 -0
@@ -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). 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).",
55
74
  inputSchema: jsonSchema({
56
75
  type: "object",
57
76
  properties: {
58
77
  command: { type: "string", description: "The shell command to execute" },
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)" },
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
- // 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(),
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: { ...process.env, ...(isWin ? { PYTHONIOENCODING: "utf-8" } : {}) },
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 (only if timeout is specified)
110
- if (timeout && timeout > 0) {
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 ${timeout}ms and was killed. Retry with a larger timeout if needed.]`);
117
- }, timeout);
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
  });
@@ -1,8 +1,9 @@
1
1
  import { tool, jsonSchema } from "ai";
2
- import { readFile, writeFile, rename, unlink } from "fs/promises";
3
- import { randomBytes } from "crypto";
2
+ import { readFile } from "fs/promises";
4
3
  import path from "path";
5
- 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";
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 (!isAutoApprove()) {
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 writeFile(tmpPath, updated, "utf-8");
57
- await rename(tmpPath, resolved);
59
+ await atomicWriteFile(resolved, updated);
58
60
  }
59
61
  catch (err) {
60
- await unlink(tmpPath).catch(() => { });
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;
@@ -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
- const EXPLORE_SYSTEM = `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
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
- export function createExploreTool(modelId, abortSignal, onUsage) {
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: "head" }).content;
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: EXPLORE_SYSTEM,
102
+ system: exploreSystem(),
92
103
  messages,
93
104
  tools,
94
- stopWhen: stepCountIs(EXPLORE_MAX_STEPS),
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 { text: stripThinkingFromAssistantText(assistantText) || `[Explore error: ${event.error}]` };
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", "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",
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", "log", "diff", "show", "rev-parse", "branch", "remote", "tag", "ls-files", "help", "--version", "version",
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) {
@@ -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(),
@@ -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.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
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", pattern,
23
- "--max-count", "1",
24
- "-g", "!*.lock",
25
- "--max-filesize", String(MAX_FILE_BYTES),
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)
@@ -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
- /** Chat mode: general assistant tools (fewer tools = less token overhead) */
14
- export function createChatTools() {
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) {
@@ -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 questions. Optionally include numbered options for the user to choose from.`,
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: { type: "string" },
18
- description: "Optional list of choices for the user to pick from",
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 answer = await askQuestion(question, options);
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 (options && options.length > 0) {
28
- const idx = parseInt(answer) - 1;
29
- if (idx >= 0 && idx < options.length) {
30
- return `User chose: ${options[idx]}`;
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
  },
@@ -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 { InstructionTracker } from "../instructions.js";
8
- // Shared tracker instance for context-aware instruction discovery
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 = instructionTracker.resolveForFile(resolved);
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
  }