Learn Claude Code
Back to TodoWrite

ToolsTodoWrite

s02 (237 LOC) → s03 (329 LOC)

LOC Delta

+92lines

New Tools

1

todo
New Classes

1

TodoManager
New Functions

0

Tools

One Handler Per Tool

237 LOC

4 tools: bash, read_file, write_file, edit_file

tools

TodoWrite

Plan Before You Act

329 LOC

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

planning

Source Code Diff

s02 (s02_tool_use.ts) -> s03 (s03_todo_write.ts)
11#!/usr/bin/env node
22/**
3- * s02_tool_use.ts - Tools
3+ * s03_todo_write.ts - TodoWrite
44 *
5- * The loop from s01 does not change. We add more tools and a dispatch map:
6- *
7- * { tool_name: handler }
8- *
9- * Key insight: adding a tool means adding one handler.
5+ * The model tracks its own progress through a TodoManager.
6+ * A nag reminder pushes it to keep the plan updated.
107 */
118
129import { spawnSync } from "node:child_process";
1310import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
11+import { resolve } from "node:path";
1412import process from "node:process";
1513import { createInterface } from "node:readline/promises";
16-import { resolve } from "node:path";
1714import type Anthropic from "@anthropic-ai/sdk";
1815import "dotenv/config";
1916import { buildSystemPrompt, createAnthropicClient, resolveModel, shellToolDescription } from "./shared";
2017
21-type ToolUseName = "bash" | "read_file" | "write_file" | "edit_file";
18+type ToolUseName = "bash" | "read_file" | "write_file" | "edit_file" | "todo";
2219
20+type TodoStatus = "pending" | "in_progress" | "completed";
21+
2322type ToolUseBlock = {
2423 id: string;
2524 type: "tool_use";
2625 name: ToolUseName;
2726 input: Record<string, unknown>;
2827};
2928
3029type TextBlock = {
3130 type: "text";
3231 text: string;
3332};
3433
3534type ToolResultBlock = {
3635 type: "tool_result";
3736 tool_use_id: string;
3837 content: string;
3938};
4039
4140type MessageContent = string | Array<ToolUseBlock | TextBlock | ToolResultBlock>;
4241
4342type Message = {
4443 role: "user" | "assistant";
4544 content: MessageContent;
4645};
4746
47+type TodoItem = {
48+ id: string;
49+ text: string;
50+ status: TodoStatus;
51+};
52+
4853const WORKDIR = process.cwd();
4954const MODEL = resolveModel();
5055const client = createAnthropicClient();
5156
52-const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}. Use tools to solve tasks. Act, don't explain.`);
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.`);
5360
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+
54116function safePath(relativePath: string): string {
55117 const filePath = resolve(WORKDIR, relativePath);
56118 const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`;
57119 if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) {
58120 throw new Error(`Path escapes workspace: ${relativePath}`);
59121 }
60122 return filePath;
61123}
62124
63125function runBash(command: string): string {
64126 const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"];
65127 if (dangerous.some((item) => command.includes(item))) {
66128 return "Error: Dangerous command blocked";
67129 }
68130
69131 const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh";
70132 const args = process.platform === "win32"
71133 ? ["/d", "/s", "/c", command]
72134 : ["-lc", command];
73135
74136 const result = spawnSync(shell, args, {
75137 cwd: WORKDIR,
76138 encoding: "utf8",
77139 timeout: 120_000,
78140 });
79141
80142 if (result.error?.name === "TimeoutError") {
81143 return "Error: Timeout (120s)";
82144 }
83145
84146 const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
85147 return output.slice(0, 50_000) || "(no output)";
86148}
87149
88150function runRead(path: string, limit?: number): string {
89151 try {
90152 let lines = readFileSync(safePath(path), "utf8").split(/\r?\n/);
91153 if (limit && limit < lines.length) {
92154 lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`);
93155 }
94156 return lines.join("\n").slice(0, 50_000);
95157 } catch (error) {
96158 return `Error: ${error instanceof Error ? error.message : String(error)}`;
97159 }
98160}
99161
100162function runWrite(path: string, content: string): string {
101163 try {
102164 const filePath = safePath(path);
103165 mkdirSync(resolve(filePath, ".."), { recursive: true });
104166 writeFileSync(filePath, content, "utf8");
105167 return `Wrote ${content.length} bytes`;
106168 } catch (error) {
107169 return `Error: ${error instanceof Error ? error.message : String(error)}`;
108170 }
109171}
110172
111173function runEdit(path: string, oldText: string, newText: string): string {
112174 try {
113175 const filePath = safePath(path);
114176 const content = readFileSync(filePath, "utf8");
115177 if (!content.includes(oldText)) {
116178 return `Error: Text not found in ${path}`;
117179 }
118180 writeFileSync(filePath, content.replace(oldText, newText), "utf8");
119181 return `Edited ${path}`;
120182 } catch (error) {
121183 return `Error: ${error instanceof Error ? error.message : String(error)}`;
122184 }
123185}
124186
125187const TOOL_HANDLERS: Record<ToolUseName, (input: Record<string, unknown>) => string> = {
126188 bash: (input) => runBash(String(input.command ?? "")),
127189 read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined),
128190 write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")),
129191 edit_file: (input) =>
130192 runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")),
193+ todo: (input) => TODO.update(input.items),
131194};
132195
133196const TOOLS = [
134197 {
135198 name: "bash",
136199 description: shellToolDescription(),
137200 input_schema: {
138201 type: "object",
139202 properties: {
140203 command: { type: "string" },
141204 },
142205 required: ["command"],
143206 },
144207 },
145208 {
146209 name: "read_file",
147210 description: "Read file contents.",
148211 input_schema: {
149212 type: "object",
150213 properties: {
151214 path: { type: "string" },
152215 limit: { type: "integer" },
153216 },
154217 required: ["path"],
155218 },
156219 },
157220 {
158221 name: "write_file",
159222 description: "Write content to file.",
160223 input_schema: {
161224 type: "object",
162225 properties: {
163226 path: { type: "string" },
164227 content: { type: "string" },
165228 },
166229 required: ["path", "content"],
167230 },
168231 },
169232 {
170233 name: "edit_file",
171234 description: "Replace exact text in file.",
172235 input_schema: {
173236 type: "object",
174237 properties: {
175238 path: { type: "string" },
176239 old_text: { type: "string" },
177240 new_text: { type: "string" },
178241 },
179242 required: ["path", "old_text", "new_text"],
180243 },
181244 },
245+ {
246+ name: "todo",
247+ description: "Update task list. Track progress on multi-step tasks.",
248+ input_schema: {
249+ type: "object",
250+ 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+ },
266+ },
267+ required: ["items"],
268+ },
269+ },
182270];
183271
184272function assistantText(content: Array<ToolUseBlock | TextBlock | ToolResultBlock>) {
185273 return content
186274 .filter((block): block is TextBlock => block.type === "text")
187275 .map((block) => block.text)
188276 .join("\n");
189277}
190278
191279export async function agentLoop(messages: Message[]) {
280+ let roundsSinceTodo = 0;
281+
192282 while (true) {
193283 const response = await client.messages.create({
194284 model: MODEL,
195285 system: SYSTEM,
196286 messages: messages as Anthropic.Messages.MessageParam[],
197287 tools: TOOLS as Anthropic.Messages.Tool[],
198288 max_tokens: 8000,
199289 });
200290
201291 messages.push({
202292 role: "assistant",
203293 content: response.content as Array<ToolUseBlock | TextBlock>,
204294 });
205295
206296 if (response.stop_reason !== "tool_use") {
207297 return;
208298 }
209299
210- const results: ToolResultBlock[] = [];
300+ const results: Array<TextBlock | ToolResultBlock> = [];
301+ let usedTodo = false;
211302
212303 for (const block of response.content) {
213304 if (block.type !== "tool_use") continue;
214305
215306 const handler = TOOL_HANDLERS[block.name as ToolUseName];
216- const output = handler
217- ? handler(block.input as Record<string, unknown>)
218- : `Unknown tool: ${block.name}`;
307+ let output: string;
219308
309+ try {
310+ output = handler
311+ ? handler(block.input as Record<string, unknown>)
312+ : `Unknown tool: ${block.name}`;
313+ } catch (error) {
314+ output = `Error: ${error instanceof Error ? error.message : String(error)}`;
315+ }
316+
220317 console.log(`> ${block.name}: ${output.slice(0, 200)}`);
221318 results.push({
222319 type: "tool_result",
223320 tool_use_id: block.id,
224321 content: output,
225322 });
323+
324+ if (block.name === "todo") {
325+ usedTodo = true;
326+ }
226327 }
227328
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+
228337 messages.push({
229338 role: "user",
230339 content: results,
231340 });
232341 }
233342}
234343
235344async function main() {
236345 const rl = createInterface({
237346 input: process.stdin,
238347 output: process.stdout,
239348 });
240349
241350 const history: Message[] = [];
242351
243352 while (true) {
244353 let query = "";
245354 try {
246- query = await rl.question("\x1b[36ms02 >> \x1b[0m");
355+ query = await rl.question("\x1b[36ms03 >> \x1b[0m");
247356 } catch (error) {
248357 if (
249358 error instanceof Error &&
250359 (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError")
251360 ) {
252361 break;
253362 }
254363 throw error;
255364 }
256365 if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) {
257366 break;
258367 }
259368
260369 history.push({ role: "user", content: query });
261370 await agentLoop(history);
262371
263372 const last = history[history.length - 1]?.content;
264373 if (Array.isArray(last)) {
265374 const text = assistantText(last);
266375 if (text) console.log(text);
267376 }
268377 console.log();
269378 }
270379
271380 rl.close();
272381}
273382
274383void main();