Learn Claude Code
Back to Subagents

TodoWriteSubagents

s03 (329 LOC) → s04 (288 LOC)

LOC Delta

-41lines

New Tools

1

task
New Classes

0

New Functions

1

runSubagent

TodoWrite

Plan Before You Act

329 LOC

5 tools: bash, read_file, write_file, edit_file, todo

planning

Subagents

Clean Context Per Subtask

288 LOC

5 tools: bash, read_file, write_file, edit_file, task

planning

Source Code Diff

s03 (s03_todo_write.ts) -> s04 (s04_subagent.ts)
11#!/usr/bin/env node
22/**
3- * s03_todo_write.ts - TodoWrite
3+ * s04_subagent.ts - Subagents
44 *
5- * The model tracks its own progress through a TodoManager.
6- * A nag reminder pushes it to keep the plan updated.
5+ * Spawn a child agent with fresh messages=[].
6+ * The child shares the filesystem, but returns only a short summary.
77 */
88
99import { spawnSync } from "node:child_process";
1010import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1111import { resolve } from "node:path";
1212import process from "node:process";
1313import { createInterface } from "node:readline/promises";
1414import type Anthropic from "@anthropic-ai/sdk";
1515import "dotenv/config";
1616import { buildSystemPrompt, createAnthropicClient, resolveModel, shellToolDescription } from "./shared";
1717
18-type ToolUseName = "bash" | "read_file" | "write_file" | "edit_file" | "todo";
18+type BaseToolName = "bash" | "read_file" | "write_file" | "edit_file";
19+type ParentToolName = BaseToolName | "task";
1920
20-type TodoStatus = "pending" | "in_progress" | "completed";
21-
2221type ToolUseBlock = {
2322 id: string;
2423 type: "tool_use";
25- name: ToolUseName;
24+ name: ParentToolName;
2625 input: Record<string, unknown>;
2726};
2827
2928type TextBlock = {
3029 type: "text";
3130 text: string;
3231};
3332
3433type ToolResultBlock = {
3534 type: "tool_result";
3635 tool_use_id: string;
3736 content: string;
3837};
3938
4039type MessageContent = string | Array<ToolUseBlock | TextBlock | ToolResultBlock>;
4140
4241type Message = {
4342 role: "user" | "assistant";
4443 content: MessageContent;
4544};
4645
47-type TodoItem = {
48- id: string;
49- text: string;
50- status: TodoStatus;
51-};
52-
5346const WORKDIR = process.cwd();
5447const MODEL = resolveModel();
5548const client = createAnthropicClient();
5649
57-const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}.
58-Use the todo tool to plan multi-step tasks. Mark in_progress before starting, completed when done.
59-Prefer tools over prose.`);
50+const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}. Use the task tool to delegate exploration or subtasks.`);
51+const SUBAGENT_SYSTEM = buildSystemPrompt(`You are a coding subagent at ${WORKDIR}. Complete the given task, then summarize your findings.`);
6052
61-class TodoManager {
62- private items: TodoItem[] = [];
63-
64- update(items: unknown): string {
65- if (!Array.isArray(items)) {
66- throw new Error("items must be an array");
67- }
68- if (items.length > 20) {
69- throw new Error("Max 20 todos allowed");
70- }
71-
72- let inProgressCount = 0;
73- const validated = items.map((item, index) => {
74- const record = (item ?? {}) as Record<string, unknown>;
75- const text = String(record.text ?? "").trim();
76- const status = String(record.status ?? "pending").toLowerCase() as TodoStatus;
77- const id = String(record.id ?? index + 1);
78-
79- if (!text) throw new Error(`Item ${id}: text required`);
80- if (!["pending", "in_progress", "completed"].includes(status)) {
81- throw new Error(`Item ${id}: invalid status '${status}'`);
82- }
83- if (status === "in_progress") inProgressCount += 1;
84-
85- return { id, text, status };
86- });
87-
88- if (inProgressCount > 1) {
89- throw new Error("Only one task can be in_progress at a time");
90- }
91-
92- this.items = validated;
93- return this.render();
94- }
95-
96- render(): string {
97- if (this.items.length === 0) return "No todos.";
98-
99- const lines = this.items.map((item) => {
100- const marker = {
101- pending: "[ ]",
102- in_progress: "[>]",
103- completed: "[x]",
104- }[item.status];
105- return `${marker} #${item.id}: ${item.text}`;
106- });
107-
108- const done = this.items.filter((item) => item.status === "completed").length;
109- lines.push(`\n(${done}/${this.items.length} completed)`);
110- return lines.join("\n");
111- }
112-}
113-
114-const TODO = new TodoManager();
115-
11653function safePath(relativePath: string): string {
11754 const filePath = resolve(WORKDIR, relativePath);
11855 const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`;
11956 if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) {
12057 throw new Error(`Path escapes workspace: ${relativePath}`);
12158 }
12259 return filePath;
12360}
12461
12562function runBash(command: string): string {
12663 const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"];
12764 if (dangerous.some((item) => command.includes(item))) {
12865 return "Error: Dangerous command blocked";
12966 }
13067
13168 const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh";
13269 const args = process.platform === "win32"
13370 ? ["/d", "/s", "/c", command]
13471 : ["-lc", command];
13572
13673 const result = spawnSync(shell, args, {
13774 cwd: WORKDIR,
13875 encoding: "utf8",
13976 timeout: 120_000,
14077 });
14178
14279 if (result.error?.name === "TimeoutError") {
14380 return "Error: Timeout (120s)";
14481 }
14582
14683 const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
14784 return output.slice(0, 50_000) || "(no output)";
14885}
14986
15087function runRead(path: string, limit?: number): string {
15188 try {
15289 let lines = readFileSync(safePath(path), "utf8").split(/\r?\n/);
15390 if (limit && limit < lines.length) {
15491 lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`);
15592 }
15693 return lines.join("\n").slice(0, 50_000);
15794 } catch (error) {
15895 return `Error: ${error instanceof Error ? error.message : String(error)}`;
15996 }
16097}
16198
16299function runWrite(path: string, content: string): string {
163100 try {
164101 const filePath = safePath(path);
165102 mkdirSync(resolve(filePath, ".."), { recursive: true });
166103 writeFileSync(filePath, content, "utf8");
167104 return `Wrote ${content.length} bytes`;
168105 } catch (error) {
169106 return `Error: ${error instanceof Error ? error.message : String(error)}`;
170107 }
171108}
172109
173110function runEdit(path: string, oldText: string, newText: string): string {
174111 try {
175112 const filePath = safePath(path);
176113 const content = readFileSync(filePath, "utf8");
177114 if (!content.includes(oldText)) {
178115 return `Error: Text not found in ${path}`;
179116 }
180117 writeFileSync(filePath, content.replace(oldText, newText), "utf8");
181118 return `Edited ${path}`;
182119 } catch (error) {
183120 return `Error: ${error instanceof Error ? error.message : String(error)}`;
184121 }
185122}
186123
187-const TOOL_HANDLERS: Record<ToolUseName, (input: Record<string, unknown>) => string> = {
124+const TOOL_HANDLERS: Record<BaseToolName, (input: Record<string, unknown>) => string> = {
188125 bash: (input) => runBash(String(input.command ?? "")),
189126 read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined),
190127 write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")),
191128 edit_file: (input) =>
192129 runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")),
193- todo: (input) => TODO.update(input.items),
194130};
195131
196-const TOOLS = [
132+const CHILD_TOOLS = [
197133 {
198134 name: "bash",
199135 description: shellToolDescription(),
200136 input_schema: {
201137 type: "object",
202- properties: {
203- command: { type: "string" },
204- },
138+ properties: { command: { type: "string" } },
205139 required: ["command"],
206140 },
207141 },
208142 {
209143 name: "read_file",
210144 description: "Read file contents.",
211145 input_schema: {
212146 type: "object",
213- properties: {
214- path: { type: "string" },
215- limit: { type: "integer" },
216- },
147+ properties: { path: { type: "string" }, limit: { type: "integer" } },
217148 required: ["path"],
218149 },
219150 },
220151 {
221152 name: "write_file",
222153 description: "Write content to file.",
223154 input_schema: {
224155 type: "object",
225- properties: {
226- path: { type: "string" },
227- content: { type: "string" },
228- },
156+ properties: { path: { type: "string" }, content: { type: "string" } },
229157 required: ["path", "content"],
230158 },
231159 },
232160 {
233161 name: "edit_file",
234162 description: "Replace exact text in file.",
235163 input_schema: {
236164 type: "object",
237165 properties: {
238166 path: { type: "string" },
239167 old_text: { type: "string" },
240168 new_text: { type: "string" },
241169 },
242170 required: ["path", "old_text", "new_text"],
243171 },
244172 },
173+];
174+
175+const PARENT_TOOLS = [
176+ ...CHILD_TOOLS,
245177 {
246- name: "todo",
247- description: "Update task list. Track progress on multi-step tasks.",
178+ name: "task",
179+ description: "Spawn a subagent with fresh context. It shares the filesystem but not conversation history.",
248180 input_schema: {
249181 type: "object",
250182 properties: {
251- items: {
252- type: "array",
253- items: {
254- type: "object",
255- properties: {
256- id: { type: "string" },
257- text: { type: "string" },
258- status: {
259- type: "string",
260- enum: ["pending", "in_progress", "completed"],
261- },
262- },
263- required: ["id", "text", "status"],
264- },
265- },
183+ prompt: { type: "string" },
184+ description: { type: "string" },
266185 },
267- required: ["items"],
186+ required: ["prompt"],
268187 },
269188 },
270189];
271190
272191function assistantText(content: Array<ToolUseBlock | TextBlock | ToolResultBlock>) {
273192 return content
274193 .filter((block): block is TextBlock => block.type === "text")
275194 .map((block) => block.text)
276195 .join("\n");
277196}
278197
279-export async function agentLoop(messages: Message[]) {
280- let roundsSinceTodo = 0;
198+async function runSubagent(prompt: string): Promise<string> {
199+ const subMessages: Message[] = [{ role: "user", content: prompt }];
200+ let response: Anthropic.Messages.Message | null = null;
281201
202+ for (let attempt = 0; attempt < 30; attempt += 1) {
203+ response = await client.messages.create({
204+ model: MODEL,
205+ system: SUBAGENT_SYSTEM,
206+ messages: subMessages as Anthropic.Messages.MessageParam[],
207+ tools: CHILD_TOOLS as Anthropic.Messages.Tool[],
208+ max_tokens: 8000,
209+ });
210+
211+ subMessages.push({
212+ role: "assistant",
213+ content: response.content as Array<ToolUseBlock | TextBlock>,
214+ });
215+
216+ if (response.stop_reason !== "tool_use") {
217+ break;
218+ }
219+
220+ const results: ToolResultBlock[] = [];
221+ for (const block of response.content) {
222+ if (block.type !== "tool_use" || block.name === "task") continue;
223+ const handler = TOOL_HANDLERS[block.name as BaseToolName];
224+ const output = handler
225+ ? handler(block.input as Record<string, unknown>)
226+ : `Unknown tool: ${block.name}`;
227+ results.push({
228+ type: "tool_result",
229+ tool_use_id: block.id,
230+ content: String(output).slice(0, 50_000),
231+ });
232+ }
233+
234+ subMessages.push({ role: "user", content: results });
235+ }
236+
237+ if (!response) return "(no summary)";
238+ const texts: string[] = [];
239+ for (const block of response.content) {
240+ if (block.type === "text") texts.push(block.text);
241+ }
242+ return texts.join("") || "(no summary)";
243+}
244+
245+export async function agentLoop(messages: Message[]) {
282246 while (true) {
283247 const response = await client.messages.create({
284248 model: MODEL,
285249 system: SYSTEM,
286250 messages: messages as Anthropic.Messages.MessageParam[],
287- tools: TOOLS as Anthropic.Messages.Tool[],
251+ tools: PARENT_TOOLS as Anthropic.Messages.Tool[],
288252 max_tokens: 8000,
289253 });
290254
291255 messages.push({
292256 role: "assistant",
293257 content: response.content as Array<ToolUseBlock | TextBlock>,
294258 });
295259
296260 if (response.stop_reason !== "tool_use") {
297261 return;
298262 }
299263
300- const results: Array<TextBlock | ToolResultBlock> = [];
301- let usedTodo = false;
302-
264+ const results: ToolResultBlock[] = [];
303265 for (const block of response.content) {
304266 if (block.type !== "tool_use") continue;
305267
306- const handler = TOOL_HANDLERS[block.name as ToolUseName];
307268 let output: string;
308-
309- try {
269+ if (block.name === "task") {
270+ const input = block.input as { description?: string; prompt?: string };
271+ const description = String(input.description ?? "subtask");
272+ console.log(`> task (${description}): ${String(input.prompt ?? "").slice(0, 80)}`);
273+ output = await runSubagent(String(input.prompt ?? ""));
274+ } else {
275+ const handler = TOOL_HANDLERS[block.name as BaseToolName];
310276 output = handler
311277 ? handler(block.input as Record<string, unknown>)
312278 : `Unknown tool: ${block.name}`;
313- } catch (error) {
314- output = `Error: ${error instanceof Error ? error.message : String(error)}`;
315279 }
316280
317- console.log(`> ${block.name}: ${output.slice(0, 200)}`);
281+ console.log(` ${output.slice(0, 200)}`);
318282 results.push({
319283 type: "tool_result",
320284 tool_use_id: block.id,
321285 content: output,
322286 });
323-
324- if (block.name === "todo") {
325- usedTodo = true;
326- }
327287 }
328288
329- roundsSinceTodo = usedTodo ? 0 : roundsSinceTodo + 1;
330- if (roundsSinceTodo >= 3) {
331- results.unshift({
332- type: "text",
333- text: "<reminder>Update your todos.</reminder>",
334- });
335- }
336-
337- messages.push({
338- role: "user",
339- content: results,
340- });
289+ messages.push({ role: "user", content: results });
341290 }
342291}
343292
344293async function main() {
345294 const rl = createInterface({
346295 input: process.stdin,
347296 output: process.stdout,
348297 });
349298
350299 const history: Message[] = [];
351300
352301 while (true) {
353302 let query = "";
354303 try {
355- query = await rl.question("\x1b[36ms03 >> \x1b[0m");
304+ query = await rl.question("\x1b[36ms04 >> \x1b[0m");
356305 } catch (error) {
357306 if (
358307 error instanceof Error &&
359308 (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError")
360309 ) {
361310 break;
362311 }
363312 throw error;
364313 }
365314 if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) {
366315 break;
367316 }
368317
369318 history.push({ role: "user", content: query });
370319 await agentLoop(history);
371320
372321 const last = history[history.length - 1]?.content;
373322 if (Array.isArray(last)) {
374323 const text = assistantText(last);
375324 if (text) console.log(text);
376325 }
377326 console.log();
378327 }
379328
380329 rl.close();
381330}
382331
383332void main();