min-agent 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +146 -18
  2. package/dist/agent.js +293 -408
  3. package/dist/assistant-stream.js +11 -7
  4. package/dist/cli.js +403 -140
  5. package/dist/clipboard.js +59 -23
  6. package/dist/code-mode.js +3 -3
  7. package/dist/compaction.js +182 -81
  8. package/dist/config.js +186 -35
  9. package/dist/confirm.js +55 -6
  10. package/dist/context-window.js +67 -54
  11. package/dist/doom-loop.js +19 -12
  12. package/dist/http.js +119 -0
  13. package/dist/instructions.js +51 -33
  14. package/dist/logger.js +66 -0
  15. package/dist/markdown.js +3 -44
  16. package/dist/mcp.js +547 -100
  17. package/dist/memory.js +48 -6
  18. package/dist/output.js +36 -27
  19. package/dist/paste-handler.js +3 -3
  20. package/dist/plugins.js +33 -6
  21. package/dist/pricing.js +119 -0
  22. package/dist/provider.js +17 -15
  23. package/dist/serve.js +658 -369
  24. package/dist/sessions.js +151 -13
  25. package/dist/skills.js +466 -76
  26. package/dist/synthetic.js +7 -0
  27. package/dist/title-gen.js +2 -1
  28. package/dist/tool-display.js +173 -0
  29. package/dist/tool-output.js +54 -45
  30. package/dist/tools/apply_patch.js +191 -0
  31. package/dist/tools/backend.js +61 -0
  32. package/dist/tools/bash.js +147 -70
  33. package/dist/tools/code_search.js +6 -5
  34. package/dist/tools/edit.js +23 -7
  35. package/dist/tools/explore.js +80 -12
  36. package/dist/tools/glob.js +3 -3
  37. package/dist/tools/grep.js +146 -14
  38. package/dist/tools/index.js +7 -7
  39. package/dist/tools/question.js +4 -22
  40. package/dist/tools/read.js +71 -11
  41. package/dist/tools/task.js +33 -20
  42. package/dist/tools/todo.js +83 -73
  43. package/dist/tools/web_fetch.js +150 -46
  44. package/dist/tools/web_search.js +706 -28
  45. package/dist/tools/write.js +13 -7
  46. package/dist/tui/App.js +40 -6
  47. package/dist/tui/ConfirmBar.js +24 -3
  48. package/dist/tui/InputBar.js +390 -45
  49. package/dist/tui/MessageList.js +533 -20
  50. package/dist/tui/ModelPicker.js +108 -0
  51. package/dist/tui/QuestionBar.js +104 -0
  52. package/dist/tui/StatusBar.js +19 -11
  53. package/dist/tui/agent-runner.js +103 -0
  54. package/dist/tui/caret-pos.js +134 -0
  55. package/dist/tui/caret.js +69 -0
  56. package/dist/tui/diff-view.js +61 -0
  57. package/dist/tui/drag-state.js +44 -0
  58. package/dist/tui/index.js +153 -24
  59. package/dist/tui/input-history.js +44 -0
  60. package/dist/tui/layout.js +17 -0
  61. package/dist/tui/mouse.js +46 -0
  62. package/dist/tui/selection.js +134 -0
  63. package/dist/tui/slash-commands.js +90 -0
  64. package/dist/tui/slash-handler.js +370 -0
  65. package/dist/tui/text-width.js +91 -0
  66. package/dist/tui/theme.js +12 -0
  67. package/dist/tui/undo-stack.js +14 -0
  68. package/dist/tui/use-sgr-mouse.js +27 -0
  69. package/dist/tui-chat.js +111 -331
  70. package/dist/updater.js +57 -0
  71. package/docs/API.md +160 -14
  72. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
  73. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
  74. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
  75. package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
  76. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
  77. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
  78. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
  79. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
  80. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
  81. package/package.json +7 -8
@@ -1,8 +1,123 @@
1
1
  import { tool, jsonSchema } from "ai";
2
- import { execSync } from "child_process";
2
+ import { readdir, readFile, stat } from "fs/promises";
3
+ import { spawn } from "child_process";
4
+ import path from "path";
3
5
  import { truncateToolOutput } from "../tool-output.js";
6
+ const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", ".out", "coverage", ".min-agent"]);
7
+ const MAX_RESULTS = 500;
8
+ const MAX_FILE_BYTES = 5 * 1024 * 1024;
9
+ const MAX_RG_OUTPUT_BYTES = 8 * 1024 * 1024;
10
+ const WALK_CONCURRENCY = 8;
11
+ /** Convert a simple glob like "*.ts" into an anchored regex for file names. */
12
+ function globToRegex(glob) {
13
+ const escaped = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
14
+ return new RegExp(`^${escaped}$`);
15
+ }
16
+ /** Fast path: shell out to ripgrep when available. Resolves null if rg is missing or fails. */
17
+ function rgSearch(pattern, root, includeRegex, ignore) {
18
+ const args = [
19
+ "--no-heading",
20
+ "--line-number",
21
+ "-i",
22
+ "-e", pattern,
23
+ "--max-count", "1",
24
+ "-g", "!*.lock",
25
+ "--max-filesize", String(MAX_FILE_BYTES),
26
+ ];
27
+ for (const dir of ignore)
28
+ args.push("-g", `!${dir}/**`);
29
+ if (includeRegex) {
30
+ const glob = includeRegex.source
31
+ .replace(/^\^/, "")
32
+ .replace(/\$$/, "")
33
+ .replace(/\\\./g, ".")
34
+ .replace(/\.\*/g, "*");
35
+ args.push("-g", glob);
36
+ }
37
+ args.push(root);
38
+ return new Promise((resolve) => {
39
+ const child = spawn("rg", args, { stdio: ["ignore", "pipe", "pipe"] });
40
+ const chunks = [];
41
+ let total = 0;
42
+ let settled = false;
43
+ const finish = (value) => {
44
+ if (settled)
45
+ return;
46
+ settled = true;
47
+ clearTimeout(timer);
48
+ resolve(value);
49
+ };
50
+ const timer = setTimeout(() => {
51
+ child.kill("SIGKILL");
52
+ finish(null);
53
+ }, 15000);
54
+ child.stdout.on("data", (chunk) => {
55
+ total += chunk.byteLength;
56
+ if (total > MAX_RG_OUTPUT_BYTES) {
57
+ child.kill("SIGKILL");
58
+ finish(null);
59
+ return;
60
+ }
61
+ chunks.push(chunk);
62
+ });
63
+ child.on("error", () => finish(null));
64
+ child.on("close", (code) => {
65
+ if (code === 1)
66
+ finish([]);
67
+ else if (code === 0)
68
+ finish(Buffer.concat(chunks).toString("utf-8").split("\n").filter(Boolean).slice(0, MAX_RESULTS));
69
+ else
70
+ finish(null);
71
+ });
72
+ });
73
+ }
74
+ async function walk(dir, regex, includeRegex, results) {
75
+ const dirQueue = [dir];
76
+ const worker = async () => {
77
+ while (dirQueue.length > 0 && results.length < MAX_RESULTS) {
78
+ const current = dirQueue.pop();
79
+ let entries;
80
+ try {
81
+ entries = await readdir(current, { withFileTypes: true });
82
+ }
83
+ catch {
84
+ continue;
85
+ }
86
+ await Promise.all(entries.map(async (entry) => {
87
+ const full = path.join(current, entry.name);
88
+ if (entry.isDirectory()) {
89
+ if (!IGNORED_DIRS.has(entry.name) && results.length < MAX_RESULTS)
90
+ dirQueue.push(full);
91
+ return;
92
+ }
93
+ if (!entry.isFile())
94
+ return;
95
+ if (includeRegex && !includeRegex.test(entry.name))
96
+ return;
97
+ try {
98
+ const info = await stat(full);
99
+ if (info.size > MAX_FILE_BYTES)
100
+ return;
101
+ const content = await readFile(full, "utf-8");
102
+ if (content.includes("\0"))
103
+ return;
104
+ const lines = content.split("\n");
105
+ for (let i = 0; i < lines.length && results.length < MAX_RESULTS; i++) {
106
+ if (regex.test(lines[i])) {
107
+ results.push(`${path.relative(process.cwd(), full)}:${i + 1}:${lines[i]}`);
108
+ }
109
+ }
110
+ }
111
+ catch {
112
+ // unreadable file — skip
113
+ }
114
+ }));
115
+ }
116
+ };
117
+ await Promise.all(Array.from({ length: WALK_CONCURRENCY }, worker));
118
+ }
4
119
  export const grepTool = tool({
5
- description: "Search for a pattern in files using grep. Returns matching lines with file paths and line numbers. Use this to find code references, usages, and definitions.",
120
+ description: "Search for a pattern in files using regex. Returns matching lines with file paths and line numbers. Use this to find code references, usages, and definitions.",
6
121
  inputSchema: jsonSchema({
7
122
  type: "object",
8
123
  properties: {
@@ -13,22 +128,39 @@ export const grepTool = tool({
13
128
  required: ["pattern"],
14
129
  }),
15
130
  execute: async ({ pattern, path: searchPath, include }) => {
16
- const target = searchPath ?? ".";
17
- const includeFlag = include ? `--include='${include}'` : "";
18
- const cmd = `grep -rn ${includeFlag} --color=never -E '${pattern.replace(/'/g, "'\\''")}' '${target}' 2>/dev/null | head -50`;
19
131
  try {
20
- const output = execSync(cmd, {
21
- encoding: "utf-8",
22
- cwd: process.cwd(),
23
- timeout: 10000,
24
- maxBuffer: 512 * 1024,
25
- });
26
- const text = output.trim() || "No matches found";
132
+ const regex = new RegExp(pattern);
133
+ const includeRegex = include ? globToRegex(include) : null;
134
+ const root = path.resolve(process.cwd(), searchPath ?? ".");
135
+ const results = [];
136
+ const info = await stat(root).catch(() => null);
137
+ if (!info)
138
+ return `Error: path not found: ${searchPath ?? "."}`;
139
+ if (info.isFile()) {
140
+ try {
141
+ const content = await readFile(root, "utf-8");
142
+ if (!content.includes("\0")) {
143
+ content.split("\n").forEach((line, i) => {
144
+ if (regex.test(line))
145
+ results.push(`${root}:${i + 1}:${line}`);
146
+ });
147
+ }
148
+ }
149
+ catch { }
150
+ }
151
+ else {
152
+ const rgResults = await rgSearch(pattern, root, includeRegex, [...IGNORED_DIRS]);
153
+ if (rgResults !== null) {
154
+ results.push(...rgResults);
155
+ }
156
+ else {
157
+ await walk(root, regex, includeRegex, results);
158
+ }
159
+ }
160
+ const text = results.length > 0 ? results.join("\n") : "No matches found";
27
161
  return truncateToolOutput(text, { direction: "head" }).content;
28
162
  }
29
163
  catch (err) {
30
- if (err.status === 1)
31
- return "No matches found";
32
164
  return `Error: ${err.message}`;
33
165
  }
34
166
  },
@@ -2,6 +2,7 @@ import { bashTool } from "./bash.js";
2
2
  import { readTool } from "./read.js";
3
3
  import { writeTool } from "./write.js";
4
4
  import { editTool } from "./edit.js";
5
+ import { applyPatchTool } from "./apply_patch.js";
5
6
  import { globTool } from "./glob.js";
6
7
  import { grepTool } from "./grep.js";
7
8
  import { webSearchTool } from "./web_search.js";
@@ -16,24 +17,27 @@ export function createChatTools() {
16
17
  read: readTool,
17
18
  write: writeTool,
18
19
  edit: editTool,
20
+ apply_patch: applyPatchTool,
19
21
  glob: globTool,
20
22
  grep: grepTool,
21
- web_search: webSearchTool,
23
+ search_web: webSearchTool,
22
24
  web_fetch: webFetchTool,
25
+ todo: todoTool,
23
26
  question: questionTool,
24
27
  };
25
28
  return tools;
26
29
  }
27
- /** Code mode: full tool set including task tracking and code search */
30
+ /** Code mode: full tool set including task tracking and code search. Todo replaced per-run by the caller. */
28
31
  export function createCodeTools() {
29
32
  const tools = {
30
33
  bash: bashTool,
31
34
  read: readTool,
32
35
  write: writeTool,
33
36
  edit: editTool,
37
+ apply_patch: applyPatchTool,
34
38
  glob: globTool,
35
39
  grep: grepTool,
36
- web_search: webSearchTool,
40
+ search_web: webSearchTool,
37
41
  web_fetch: webFetchTool,
38
42
  todo: todoTool,
39
43
  question: questionTool,
@@ -43,7 +47,3 @@ export function createCodeTools() {
43
47
  }
44
48
  return tools;
45
49
  }
46
- /** @deprecated Use createChatTools or createCodeTools */
47
- export function createTools() {
48
- return createChatTools();
49
- }
@@ -1,5 +1,5 @@
1
1
  import { tool, jsonSchema } from "ai";
2
- import readline from "readline";
2
+ import { askQuestion } from "../confirm.js";
3
3
  export const questionTool = tool({
4
4
  description: `Ask the user a question to get clarification before proceeding. Use this when:
5
5
  - The task is ambiguous and you need more information
@@ -21,15 +21,9 @@ Provide clear, specific questions. Optionally include numbered options for the u
21
21
  required: ["question"],
22
22
  }),
23
23
  execute: async ({ question, options }) => {
24
- console.log();
25
- console.log(`\x1b[33m❓ ${question}\x1b[0m`);
26
- if (options && options.length > 0) {
27
- for (let i = 0; i < options.length; i++) {
28
- console.log(`\x1b[90m ${i + 1}. ${options[i]}\x1b[0m`);
29
- }
30
- }
31
- const answer = await askUser();
32
- // If user picked a number and options exist, resolve it
24
+ const answer = await askQuestion(question, options);
25
+ if (answer === null)
26
+ return "User cancelled the question";
33
27
  if (options && options.length > 0) {
34
28
  const idx = parseInt(answer) - 1;
35
29
  if (idx >= 0 && idx < options.length) {
@@ -39,15 +33,3 @@ Provide clear, specific questions. Optionally include numbered options for the u
39
33
  return `User answered: ${answer}`;
40
34
  },
41
35
  });
42
- function askUser() {
43
- return new Promise((resolve) => {
44
- const rl = readline.createInterface({
45
- input: process.stdin,
46
- output: process.stdout,
47
- });
48
- rl.question("\x1b[36m → \x1b[0m", (answer) => {
49
- rl.close();
50
- resolve(answer.trim() || "(no answer)");
51
- });
52
- });
53
- }
@@ -1,10 +1,58 @@
1
1
  import { tool, jsonSchema } from "ai";
2
- import { readFileSync, statSync } from "fs";
2
+ import { createReadStream } from "fs";
3
+ import { stat, readFile } from "fs/promises";
4
+ import { createInterface } from "readline";
3
5
  import path from "path";
4
- import { truncateToolOutput } from "../tool-output.js";
6
+ import { TOOL_OUTPUT_MAX_BYTES, truncateToolOutput } from "../tool-output.js";
5
7
  import { InstructionTracker } from "../instructions.js";
6
8
  // Shared tracker instance for context-aware instruction discovery
7
9
  const instructionTracker = new InstructionTracker();
10
+ /** Files above this size are streamed instead of loaded into memory. */
11
+ const STREAM_THRESHOLD_BYTES = 20 * 1024 * 1024;
12
+ /** Stream lines [start, end] (1-indexed, inclusive) without loading the whole file. */
13
+ function readLineRange(filePath, start, end) {
14
+ return new Promise((resolve, reject) => {
15
+ const lines = [];
16
+ const rl = createInterface({ input: createReadStream(filePath), crlfDelay: Infinity });
17
+ let i = 0;
18
+ rl.on("line", (line) => {
19
+ i++;
20
+ if (i >= start && i <= end)
21
+ lines.push(line);
22
+ if (i > end)
23
+ rl.close();
24
+ });
25
+ rl.on("close", () => resolve(lines.join("\n")));
26
+ rl.on("error", reject);
27
+ });
28
+ }
29
+ /** Stream the first maxBytes of a file, cut on a UTF-8 safe boundary. */
30
+ function readStreamHead(filePath, maxBytes) {
31
+ return new Promise((resolve, reject) => {
32
+ const chunks = [];
33
+ let total = 0;
34
+ let truncated = false;
35
+ let totalLines = 0;
36
+ const stream = createReadStream(filePath);
37
+ stream.on("data", (chunk) => {
38
+ const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
39
+ total += buf.byteLength;
40
+ if (total > maxBytes) {
41
+ const keep = buf.byteLength - (total - maxBytes);
42
+ chunks.push(buf.subarray(0, keep));
43
+ truncated = true;
44
+ stream.destroy();
45
+ return;
46
+ }
47
+ chunks.push(buf);
48
+ });
49
+ stream.on("close", () => {
50
+ totalLines = Buffer.concat(chunks).toString("utf-8").split("\n").length;
51
+ resolve({ text: Buffer.concat(chunks).toString("utf-8"), truncated, totalLines });
52
+ });
53
+ stream.on("error", reject);
54
+ });
55
+ }
8
56
  export const readTool = tool({
9
57
  description: "Read the contents of a file. Returns the file content as text. Use this to understand code, check configurations, etc.",
10
58
  inputSchema: jsonSchema({
@@ -19,19 +67,31 @@ export const readTool = tool({
19
67
  execute: async ({ filePath, startLine, endLine }) => {
20
68
  const resolved = path.resolve(process.cwd(), filePath);
21
69
  try {
22
- const stat = statSync(resolved);
23
- if (stat.isDirectory())
70
+ const info = await stat(resolved);
71
+ if (info.isDirectory())
24
72
  return `Error: ${filePath} is a directory, not a file`;
25
- const content = readFileSync(resolved, "utf-8");
73
+ if (info.size > STREAM_THRESHOLD_BYTES && !startLine && !endLine) {
74
+ const { text, truncated, totalLines } = await readStreamHead(resolved, TOOL_OUTPUT_MAX_BYTES);
75
+ let result = text;
76
+ if (truncated) {
77
+ result += `\n\n...(file too large: ${info.size} bytes, ${totalLines}+ lines — use startLine/endLine to read specific ranges)...`;
78
+ }
79
+ return result;
80
+ }
26
81
  let result;
27
- if (startLine || endLine) {
28
- const lines = content.split("\n");
29
- const start = (startLine ?? 1) - 1;
30
- const end = endLine ?? lines.length;
31
- result = lines.slice(start, end).join("\n");
82
+ if (startLine != null || endLine != null) {
83
+ const start = startLine ?? 1;
84
+ const end = endLine ?? Number.MAX_SAFE_INTEGER;
85
+ if (start < 1)
86
+ return `Error: startLine must be >= 1`;
87
+ if (endLine != null && end < start)
88
+ return `Error: endLine (${end}) must be >= startLine (${start})`;
89
+ result = await readLineRange(resolved, start, end);
90
+ if (!result)
91
+ return `Error: no lines found in range ${start}-${end}`;
32
92
  }
33
93
  else {
34
- result = content;
94
+ result = await readFile(resolved, "utf-8");
35
95
  }
36
96
  // Context-aware: discover nearby instruction files
37
97
  const nearbyInstructions = instructionTracker.resolveForFile(resolved);
@@ -1,8 +1,8 @@
1
1
  import { tool, jsonSchema, streamText, stepCountIs } from "ai";
2
2
  import { resolveModel } from "../provider.js";
3
- import { createTools } from "./index.js";
3
+ import { createCodeTools } from "./index.js";
4
4
  import { getMcpTools } from "../mcp.js";
5
- import { getSkillsTool, getSkills } from "../skills.js";
5
+ import { attachSkills } from "../skills.js";
6
6
  import { loadPluginTools } from "../plugins.js";
7
7
  import { DoomLoopDetector } from "../doom-loop.js";
8
8
  import { stripThinkingFromAssistantText } from "../assistant-stream.js";
@@ -19,7 +19,7 @@ Rules:
19
19
  Working directory: ${process.cwd()}
20
20
  Platform: ${process.platform}
21
21
  Date: ${new Date().toDateString()}`;
22
- export function createTaskTool(modelId) {
22
+ export function createTaskTool(modelId, abortSignal, onUsage) {
23
23
  return tool({
24
24
  description: `Launch a sub-agent to execute a task independently. The sub-agent has its own context and tools. Use this for:
25
25
  - Parallel execution: call multiple tasks at once for independent work
@@ -37,42 +37,48 @@ Call multiple tasks in parallel when the work is independent.`,
37
37
  required: ["description", "prompt"],
38
38
  }),
39
39
  execute: async ({ description, prompt }) => {
40
- console.log(`\x1b[90m ┌─ Sub-agent: ${description}\x1b[0m`);
40
+ console.error(`\x1b[90m ┌─ Sub-agent: ${description}\x1b[0m`);
41
41
  try {
42
- const result = await runSubAgent(prompt, modelId);
43
- console.log(`\x1b[90m └─ ✓ Done\x1b[0m`);
44
- return truncateToolOutput(result, { direction: "head" }).content;
42
+ const result = await runSubAgent(prompt, modelId, abortSignal);
43
+ if (result.usage)
44
+ onUsage?.(result.usage);
45
+ console.error(`\x1b[90m └─ ✓ Done\x1b[0m`);
46
+ return truncateToolOutput(result.text, { direction: "head" }).content;
45
47
  }
46
48
  catch (err) {
47
- console.log(`\x1b[90m └─ ✗ Failed: ${err.message}\x1b[0m`);
49
+ if (abortSignal?.aborted)
50
+ return "Sub-agent cancelled by user.";
51
+ console.error(`\x1b[90m └─ ✗ Failed: ${err.message}\x1b[0m`);
48
52
  return `Sub-agent error: ${err.message}`;
49
53
  }
50
54
  },
51
55
  });
52
56
  }
53
- async function runSubAgent(prompt, modelId) {
57
+ async function runSubAgent(prompt, modelId, abortSignal) {
54
58
  const model = resolveModel(modelId);
55
- // Build tools for sub-agent (no task tool to prevent recursion)
56
- const builtinTools = createTools();
59
+ // Build tools for sub-agent (code-mode tool set, minus recursion-prone tools)
60
+ const builtinTools = createCodeTools();
57
61
  const mcpTools = getMcpTools();
58
62
  const pluginTools = await loadPluginTools();
59
- const skills = getSkills();
60
63
  const allTools = { ...builtinTools, ...pluginTools };
61
64
  for (const [id, t] of Object.entries(mcpTools))
62
65
  allTools[id] = t;
63
- if (skills.length > 0)
64
- allTools["skill"] = getSkillsTool();
65
- // Remove task tool from sub-agent to prevent infinite recursion
66
+ const skillsPrompt = attachSkills(allTools);
67
+ // Remove task/explore tools from sub-agent to prevent infinite recursion
66
68
  delete allTools["task"];
69
+ delete allTools["explore"];
67
70
  const messages = [{ role: "user", content: prompt }];
68
71
  const doomLoop = new DoomLoopDetector();
72
+ const controller = new AbortController();
73
+ const signal = abortSignal ? AbortSignal.any([abortSignal, controller.signal]) : controller.signal;
69
74
  const result = streamText({
70
75
  model,
71
- system: SUB_AGENT_SYSTEM,
76
+ system: skillsPrompt ? `${SUB_AGENT_SYSTEM}\n\n${skillsPrompt}` : SUB_AGENT_SYSTEM,
72
77
  messages,
73
78
  tools: allTools,
74
79
  stopWhen: stepCountIs(SUB_AGENT_MAX_STEPS),
75
80
  maxRetries: 2,
81
+ abortSignal: signal,
76
82
  onError() { },
77
83
  });
78
84
  let assistantText = "";
@@ -83,16 +89,23 @@ async function runSubAgent(prompt, modelId) {
83
89
  break;
84
90
  case "tool-call":
85
91
  if (doomLoop.record(event.toolName, event.input)) {
86
- return assistantText + "\n\n[Sub-agent stopped: doom loop detected]";
92
+ controller.abort();
93
+ return { text: assistantText + "\n\n[Sub-agent stopped: doom loop detected]" };
87
94
  }
88
- console.log(`\x1b[90m │ ⚡ ${event.toolName}\x1b[0m`);
95
+ console.error(`\x1b[90m │ ⚡ ${event.toolName}\x1b[0m`);
89
96
  break;
90
97
  case "tool-result":
91
98
  break;
92
99
  case "error":
93
- return assistantText + `\n\n[Sub-agent error: ${event.error}]`;
100
+ controller.abort();
101
+ return { text: assistantText + `\n\n[Sub-agent error: ${event.error}]` };
94
102
  }
95
103
  }
96
104
  const cleaned = stripThinkingFromAssistantText(assistantText);
97
- return cleaned || "(sub-agent produced no output)";
105
+ let usage;
106
+ try {
107
+ usage = await result.usage;
108
+ }
109
+ catch { }
110
+ return { text: cleaned || "(sub-agent produced no output)", usage };
98
111
  }
@@ -1,43 +1,40 @@
1
1
  import { tool, jsonSchema } from "ai";
2
- let todos = [];
3
- let nextId = 1;
4
- export function getTodos() {
5
- return todos;
6
- }
7
- export function resetTodos() {
8
- todos = [];
9
- nextId = 1;
10
- }
11
- function formatTodos() {
12
- if (todos.length === 0)
13
- return "No tasks.";
14
- const icons = {
15
- pending: "",
16
- in_progress: "◐",
17
- done: "●",
18
- cancelled: "✕",
2
+ export const TODO_STATUSES = ["pending", "in_progress", "done", "cancelled"];
3
+ /** Create an isolated todo tool for one agent run. */
4
+ export function createTodoTool(opts) {
5
+ const silent = opts?.silent ?? false;
6
+ let todos = [];
7
+ let nextId = 1;
8
+ const formatTodos = () => {
9
+ if (todos.length === 0)
10
+ return "No tasks.";
11
+ const icons = {
12
+ pending: "○",
13
+ in_progress: "",
14
+ done: "●",
15
+ cancelled: "",
16
+ };
17
+ return todos
18
+ .map((t) => ` ${icons[t.status]} #${t.id} ${t.text}`)
19
+ .join("\n");
19
20
  };
20
- return todos
21
- .map((t) => ` ${icons[t.status]} #${t.id} ${t.text}`)
22
- .join("\n");
23
- }
24
- function printTodos() {
25
- if (todos.length === 0)
26
- return;
27
- console.log(`\x1b[90m ┌─ Tasks${"".repeat(36)}\x1b[0m`);
28
- for (const t of todos) {
29
- const icon = t.status === "done" ? "\x1b[32m●\x1b[0m"
30
- : t.status === "in_progress" ? "\x1b[33m◐\x1b[0m"
31
- : t.status === "cancelled" ? "\x1b[90m✕\x1b[0m"
32
- : "\x1b[90m○\x1b[0m";
33
- const dim = t.status === "done" || t.status === "cancelled" ? "\x1b[90m" : "";
34
- const reset = dim ? "\x1b[0m" : "";
35
- console.log(`\x1b[90m │\x1b[0m ${icon} ${dim}#${t.id} ${t.text}${reset}`);
36
- }
37
- console.log(`\x1b[90m └${"─".repeat(44)}\x1b[0m`);
38
- }
39
- export const todoTool = tool({
40
- description: `Create or update tasks to track progress. Use this FREQUENTLY to:
21
+ const printTodos = () => {
22
+ if (silent || todos.length === 0)
23
+ return;
24
+ console.log(`\x1b[90m ┌─ Tasks${"─".repeat(36)}\x1b[0m`);
25
+ for (const t of todos) {
26
+ const icon = t.status === "done" ? "\x1b[32m●\x1b[0m"
27
+ : t.status === "in_progress" ? "\x1b[33m◐\x1b[0m"
28
+ : t.status === "cancelled" ? "\x1b[90m✕\x1b[0m"
29
+ : "\x1b[90m○\x1b[0m";
30
+ const dim = t.status === "done" || t.status === "cancelled" ? "\x1b[90m" : "";
31
+ const reset = dim ? "\x1b[0m" : "";
32
+ console.log(`\x1b[90m │\x1b[0m ${icon} ${dim}#${t.id} ${t.text}${reset}`);
33
+ }
34
+ console.log(`\x1b[90m └${"".repeat(44)}\x1b[0m`);
35
+ };
36
+ return tool({
37
+ description: `Create or update tasks to track progress. Use this FREQUENTLY to:
41
38
  - Plan multi-step work by creating tasks upfront
42
39
  - Mark tasks as in_progress when starting them
43
40
  - Mark tasks as done when completed
@@ -46,43 +43,56 @@ export const todoTool = tool({
46
43
  To create new tasks: provide items with "text" and optionally "status" (defaults to "pending").
47
44
  To update existing tasks: provide items with "id" and "status".
48
45
  You can mix creates and updates in one call.`,
49
- inputSchema: jsonSchema({
50
- type: "object",
51
- properties: {
52
- todos: {
53
- type: "array",
54
- description: "List of tasks to create or update",
55
- items: {
56
- type: "object",
57
- properties: {
58
- text: { type: "string", description: "Task description (for new tasks)" },
59
- status: { type: "string", description: "Status: pending, in_progress, done, cancelled" },
60
- id: { type: "number", description: "Task ID (for updating existing tasks)" },
46
+ inputSchema: jsonSchema({
47
+ type: "object",
48
+ properties: {
49
+ todos: {
50
+ type: "array",
51
+ description: "List of tasks to create or update",
52
+ items: {
53
+ type: "object",
54
+ properties: {
55
+ text: { type: "string", description: "Task description (for new tasks)" },
56
+ status: { type: "string", description: "Status: pending, in_progress, done, cancelled" },
57
+ id: { type: "number", description: "Task ID (for updating existing tasks)" },
58
+ },
61
59
  },
62
60
  },
63
61
  },
64
- },
65
- required: ["todos"],
66
- }),
67
- execute: async ({ todos: items }) => {
68
- for (const item of items) {
69
- if (item.id) {
70
- // Update existing
71
- const existing = todos.find((t) => t.id === item.id);
72
- if (existing && item.status) {
73
- existing.status = item.status;
62
+ required: ["todos"],
63
+ }),
64
+ execute: async ({ todos: items }) => {
65
+ const errors = [];
66
+ for (const item of items) {
67
+ if (item.id != null) {
68
+ const existing = todos.find((t) => t.id === item.id);
69
+ if (!existing) {
70
+ errors.push(`task #${item.id} not found`);
71
+ continue;
72
+ }
73
+ if (item.status) {
74
+ if (!TODO_STATUSES.includes(item.status)) {
75
+ errors.push(`invalid status "${item.status}" for task #${item.id}`);
76
+ continue;
77
+ }
78
+ existing.status = item.status;
79
+ }
80
+ }
81
+ else if (item.text) {
82
+ todos.push({
83
+ id: nextId++,
84
+ text: item.text,
85
+ status: TODO_STATUSES.includes(item.status) ? item.status : "pending",
86
+ });
87
+ }
88
+ else {
89
+ errors.push("item needs either an id (update) or text (create)");
74
90
  }
75
91
  }
76
- else if (item.text) {
77
- // Create new
78
- todos.push({
79
- id: nextId++,
80
- text: item.text,
81
- status: item.status ?? "pending",
82
- });
83
- }
84
- }
85
- printTodos();
86
- return formatTodos();
87
- },
88
- });
92
+ printTodos();
93
+ return errors.length > 0 ? `${formatTodos()}\n\nWarnings: ${errors.join("; ")}` : formatTodos();
94
+ },
95
+ });
96
+ }
97
+ /** Shared placeholder — callers in code mode replace it with a per-run instance. */
98
+ export const todoTool = createTodoTool();