Learn Claude Code
Back to Background Tasks

TasksBackground Tasks

s07 (260 LOC) → s08 (223 LOC)

LOC Delta

-37lines

New Tools

2

background_runcheck_background
New Classes

1

BackgroundManager
New Functions

1

runCommand

Tasks

Task Graph + Dependencies

260 LOC

8 tools: bash, read_file, write_file, edit_file, task_create, task_update, task_list, task_get

planning

Background Tasks

Background Threads + Notifications

223 LOC

6 tools: bash, read_file, write_file, edit_file, background_run, check_background

concurrency

Source Code Diff

s07 (s07_task_system.ts) -> s08 (s08_background_tasks.ts)
11#!/usr/bin/env node
22/**
3- * s07_task_system.ts - Tasks
3+ * s08_background_tasks.ts - Background Tasks
44 *
5- * Persistent task graph stored in .tasks/.
5+ * Run commands in background child processes and inject notifications later.
66 */
77
8-import { spawnSync } from "node:child_process";
9-import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
8+import { spawn, spawnSync } from "node:child_process";
9+import { randomUUID } from "node:crypto";
10+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
1011import { resolve } from "node:path";
1112import process from "node:process";
1213import { createInterface } from "node:readline/promises";
1314import type Anthropic from "@anthropic-ai/sdk";
1415import "dotenv/config";
1516import { buildSystemPrompt, createAnthropicClient, resolveModel, shellToolDescription } from "./shared";
1617
17-type TaskStatus = "pending" | "in_progress" | "completed";
18-type ToolName =
19- | "bash"
20- | "read_file"
21- | "write_file"
22- | "edit_file"
23- | "task_create"
24- | "task_update"
25- | "task_list"
26- | "task_get";
27-
28-type Task = {
29- id: number;
30- subject: string;
31- description: string;
32- status: TaskStatus;
33- blockedBy: number[];
34- blocks: number[];
35- owner: string;
36-};
37-
18+type ToolName = "bash" | "read_file" | "write_file" | "edit_file" | "background_run" | "check_background";
3819type ToolUseBlock = { id: string; type: "tool_use"; name: ToolName; input: Record<string, unknown> };
3920type TextBlock = { type: "text"; text: string };
4021type ToolResultBlock = { type: "tool_result"; tool_use_id: string; content: string };
4122type Message = { role: "user" | "assistant"; content: string | Array<ToolUseBlock | TextBlock | ToolResultBlock> };
4223
24+type BackgroundTask = {
25+ status: "running" | "completed" | "timeout" | "error";
26+ result: string | null;
27+ command: string;
28+};
29+
4330const WORKDIR = process.cwd();
4431const MODEL = resolveModel();
45-const TASKS_DIR = resolve(WORKDIR, ".tasks");
4632const client = createAnthropicClient();
4733
48-const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}. Use task tools to plan and track work.`);
34+const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}. Use background_run for long-running commands.`);
4935
5036function safePath(relativePath: string) {
5137 const filePath = resolve(WORKDIR, relativePath);
5238 const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`;
53- if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) {
54- throw new Error(`Path escapes workspace: ${relativePath}`);
55- }
39+ if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) throw new Error(`Path escapes workspace: ${relativePath}`);
5640 return filePath;
5741}
5842
59-function runBash(command: string): string {
43+function runCommand(command: string, cwd: string, timeout = 120_000): string {
6044 const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"];
6145 if (dangerous.some((item) => command.includes(item))) return "Error: Dangerous command blocked";
6246 const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh";
6347 const args = process.platform === "win32" ? ["/d", "/s", "/c", command] : ["-lc", command];
64- const result = spawnSync(shell, args, { cwd: WORKDIR, encoding: "utf8", timeout: 120_000 });
65- if (result.error?.name === "TimeoutError") return "Error: Timeout (120s)";
48+ const result = spawnSync(shell, args, { cwd, encoding: "utf8", timeout });
49+ if (result.error?.name === "TimeoutError") return `Error: Timeout (${Math.floor(timeout / 1000)}s)`;
6650 const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
6751 return output.slice(0, 50_000) || "(no output)";
6852}
6953
54+function runBash(command: string): string {
55+ return runCommand(command, WORKDIR, 120_000);
56+}
57+
7058function runRead(path: string, limit?: number): string {
7159 try {
7260 let lines = readFileSync(safePath(path), "utf8").split(/\r?\n/);
7361 if (limit && limit < lines.length) lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`);
7462 return lines.join("\n").slice(0, 50_000);
7563 } catch (error) {
7664 return `Error: ${error instanceof Error ? error.message : String(error)}`;
7765 }
7866}
7967
8068function runWrite(path: string, content: string): string {
8169 try {
8270 const filePath = safePath(path);
8371 mkdirSync(resolve(filePath, ".."), { recursive: true });
8472 writeFileSync(filePath, content, "utf8");
8573 return `Wrote ${content.length} bytes`;
8674 } catch (error) {
8775 return `Error: ${error instanceof Error ? error.message : String(error)}`;
8876 }
8977}
9078
9179function runEdit(path: string, oldText: string, newText: string): string {
9280 try {
9381 const filePath = safePath(path);
9482 const content = readFileSync(filePath, "utf8");
9583 if (!content.includes(oldText)) return `Error: Text not found in ${path}`;
9684 writeFileSync(filePath, content.replace(oldText, newText), "utf8");
9785 return `Edited ${path}`;
9886 } catch (error) {
9987 return `Error: ${error instanceof Error ? error.message : String(error)}`;
10088 }
10189}
10290
103-class TaskManager {
104- private nextId: number;
91+class BackgroundManager {
92+ tasks: Record<string, BackgroundTask> = {};
93+ private notificationQueue: Array<{ task_id: string; status: string; command: string; result: string }> = [];
10594
106- constructor(private tasksDir: string) {
107- mkdirSync(tasksDir, { recursive: true });
108- this.nextId = this.maxId() + 1;
109- }
95+ run(command: string): string {
96+ const taskId = randomUUID().slice(0, 8);
97+ this.tasks[taskId] = { status: "running", result: null, command };
11098
111- private maxId(): number {
112- return readdirSync(this.tasksDir, { withFileTypes: true })
113- .filter((entry) => entry.isFile() && /^task_\d+\.json$/.test(entry.name))
114- .map((entry) => Number(entry.name.match(/\d+/)?.[0] ?? 0))
115- .reduce((max, id) => Math.max(max, id), 0);
116- }
99+ const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh";
100+ const args = process.platform === "win32" ? ["/d", "/s", "/c", command] : ["-lc", command];
101+ const child = spawn(shell, args, { cwd: WORKDIR, stdio: ["ignore", "pipe", "pipe"] });
117102
118- private filePath(taskId: number) {
119- return resolve(this.tasksDir, `task_${taskId}.json`);
120- }
103+ let output = "";
104+ const timer = setTimeout(() => {
105+ child.kill();
106+ this.tasks[taskId].status = "timeout";
107+ this.tasks[taskId].result = "Error: Timeout (300s)";
108+ this.notificationQueue.push({
109+ task_id: taskId,
110+ status: "timeout",
111+ command: command.slice(0, 80),
112+ result: "Error: Timeout (300s)",
113+ });
114+ }, 300_000);
121115
122- private load(taskId: number): Task {
123- const path = this.filePath(taskId);
124- if (!existsSync(path)) throw new Error(`Task ${taskId} not found`);
125- return JSON.parse(readFileSync(path, "utf8")) as Task;
126- }
116+ child.stdout.on("data", (chunk) => { output += String(chunk); });
117+ child.stderr.on("data", (chunk) => { output += String(chunk); });
118+ child.on("error", (error) => {
119+ clearTimeout(timer);
120+ this.tasks[taskId].status = "error";
121+ this.tasks[taskId].result = `Error: ${error.message}`;
122+ this.notificationQueue.push({
123+ task_id: taskId,
124+ status: "error",
125+ command: command.slice(0, 80),
126+ result: `Error: ${error.message}`.slice(0, 500),
127+ });
128+ });
129+ child.on("close", () => {
130+ if (this.tasks[taskId].status !== "running") return;
131+ clearTimeout(timer);
132+ const result = output.trim().slice(0, 50_000) || "(no output)";
133+ this.tasks[taskId].status = "completed";
134+ this.tasks[taskId].result = result;
135+ this.notificationQueue.push({
136+ task_id: taskId,
137+ status: "completed",
138+ command: command.slice(0, 80),
139+ result: result.slice(0, 500),
140+ });
141+ });
127142
128- private save(task: Task) {
129- writeFileSync(this.filePath(task.id), `${JSON.stringify(task, null, 2)}\n`, "utf8");
143+ return `Background task ${taskId} started: ${command.slice(0, 80)}`;
130144 }
131145
132- create(subject: string, description = "") {
133- const task: Task = {
134- id: this.nextId,
135- subject,
136- description,
137- status: "pending",
138- blockedBy: [],
139- blocks: [],
140- owner: "",
141- };
142- this.save(task);
143- this.nextId += 1;
144- return JSON.stringify(task, null, 2);
145- }
146-
147- get(taskId: number) {
148- return JSON.stringify(this.load(taskId), null, 2);
149- }
150-
151- private clearDependency(completedId: number) {
152- for (const entry of readdirSync(this.tasksDir, { withFileTypes: true })) {
153- if (!entry.isFile() || !/^task_\d+\.json$/.test(entry.name)) continue;
154- const path = resolve(this.tasksDir, entry.name);
155- const task = JSON.parse(readFileSync(path, "utf8")) as Task;
156- if (task.blockedBy.includes(completedId)) {
157- task.blockedBy = task.blockedBy.filter((id) => id !== completedId);
158- this.save(task);
159- }
146+ check(taskId?: string): string {
147+ if (taskId) {
148+ const task = this.tasks[taskId];
149+ if (!task) return `Error: Unknown task ${taskId}`;
150+ return `[${task.status}] ${task.command.slice(0, 60)}\n${task.result ?? "(running)"}`;
160151 }
152+ const lines = Object.entries(this.tasks).map(([id, task]) => `${id}: [${task.status}] ${task.command.slice(0, 60)}`);
153+ return lines.length ? lines.join("\n") : "No background tasks.";
161154 }
162155
163- update(taskId: number, status?: string, addBlockedBy?: number[], addBlocks?: number[]) {
164- const task = this.load(taskId);
165- if (status) {
166- if (!["pending", "in_progress", "completed"].includes(status)) {
167- throw new Error(`Invalid status: ${status}`);
168- }
169- task.status = status as TaskStatus;
170- if (task.status === "completed") this.clearDependency(taskId);
171- }
172- if (addBlockedBy?.length) task.blockedBy = [...new Set(task.blockedBy.concat(addBlockedBy))];
173- if (addBlocks?.length) {
174- task.blocks = [...new Set(task.blocks.concat(addBlocks))];
175- for (const blockedId of addBlocks) {
176- try {
177- const blocked = this.load(blockedId);
178- if (!blocked.blockedBy.includes(taskId)) {
179- blocked.blockedBy.push(taskId);
180- this.save(blocked);
181- }
182- } catch {}
183- }
184- }
185- this.save(task);
186- return JSON.stringify(task, null, 2);
156+ drainNotifications() {
157+ const notifications = [...this.notificationQueue];
158+ this.notificationQueue = [];
159+ return notifications;
187160 }
188-
189- listAll() {
190- const tasks = readdirSync(this.tasksDir, { withFileTypes: true })
191- .filter((entry) => entry.isFile() && /^task_\d+\.json$/.test(entry.name))
192- .map((entry) => JSON.parse(readFileSync(resolve(this.tasksDir, entry.name), "utf8")) as Task)
193- .sort((a, b) => a.id - b.id);
194- if (!tasks.length) return "No tasks.";
195- return tasks
196- .map((task) => {
197- const marker = { pending: "[ ]", in_progress: "[>]", completed: "[x]" }[task.status] ?? "[?]";
198- const blocked = task.blockedBy.length ? ` (blocked by: ${JSON.stringify(task.blockedBy)})` : "";
199- return `${marker} #${task.id}: ${task.subject}${blocked}`;
200- })
201- .join("\n");
202- }
203161}
204162
205-const TASKS = new TaskManager(TASKS_DIR);
163+const BG = new BackgroundManager();
206164
207165const TOOL_HANDLERS: Record<ToolName, (input: Record<string, unknown>) => string> = {
208166 bash: (input) => runBash(String(input.command ?? "")),
209167 read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined),
210168 write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")),
211169 edit_file: (input) => runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")),
212- task_create: (input) => TASKS.create(String(input.subject ?? ""), String(input.description ?? "")),
213- task_update: (input) => TASKS.update(
214- Number(input.task_id ?? 0),
215- typeof input.status === "string" ? input.status : undefined,
216- Array.isArray(input.addBlockedBy) ? input.addBlockedBy.map(Number) : undefined,
217- Array.isArray(input.addBlocks) ? input.addBlocks.map(Number) : undefined,
218- ),
219- task_list: () => TASKS.listAll(),
220- task_get: (input) => TASKS.get(Number(input.task_id ?? 0)),
170+ background_run: (input) => BG.run(String(input.command ?? "")),
171+ check_background: (input) => BG.check(typeof input.task_id === "string" ? input.task_id : undefined),
221172};
222173
223174const TOOLS = [
224175 { name: "bash", description: shellToolDescription(), input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } },
225176 { name: "read_file", description: "Read file contents.", input_schema: { type: "object", properties: { path: { type: "string" }, limit: { type: "integer" } }, required: ["path"] } },
226177 { name: "write_file", description: "Write content to file.", input_schema: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] } },
227178 { name: "edit_file", description: "Replace exact text in file.", input_schema: { type: "object", properties: { path: { type: "string" }, old_text: { type: "string" }, new_text: { type: "string" } }, required: ["path", "old_text", "new_text"] } },
228- { name: "task_create", description: "Create a new task.", input_schema: { type: "object", properties: { subject: { type: "string" }, description: { type: "string" } }, required: ["subject"] } },
229- { name: "task_update", description: "Update a task's status or dependencies.", input_schema: { type: "object", properties: { task_id: { type: "integer" }, status: { type: "string", enum: ["pending", "in_progress", "completed"] }, addBlockedBy: { type: "array", items: { type: "integer" } }, addBlocks: { type: "array", items: { type: "integer" } } }, required: ["task_id"] } },
230- { name: "task_list", description: "List all tasks with status summary.", input_schema: { type: "object", properties: {} } },
231- { name: "task_get", description: "Get full details of a task by ID.", input_schema: { type: "object", properties: { task_id: { type: "integer" } }, required: ["task_id"] } },
179+ { name: "background_run", description: "Run command in background. Returns task_id immediately.", input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } },
180+ { name: "check_background", description: "Check background task status. Omit task_id to list all.", input_schema: { type: "object", properties: { task_id: { type: "string" } } } },
232181];
233182
234183function assistantText(content: Array<ToolUseBlock | TextBlock>) {
235184 return content.filter((block): block is TextBlock => block.type === "text").map((block) => block.text).join("\n");
236185}
237186
238187export async function agentLoop(messages: Message[]) {
239188 while (true) {
189+ const notifications = BG.drainNotifications();
190+ if (notifications.length && messages.length) {
191+ const notifText = notifications.map((n) => `[bg:${n.task_id}] ${n.status}: ${n.result}`).join("\n");
192+ messages.push({ role: "user", content: `<background-results>\n${notifText}\n</background-results>` });
193+ messages.push({ role: "assistant", content: "Noted background results." });
194+ }
195+
240196 const response = await client.messages.create({
241197 model: MODEL,
242198 system: SYSTEM,
243199 messages: messages as Anthropic.Messages.MessageParam[],
244200 tools: TOOLS as Anthropic.Messages.Tool[],
245201 max_tokens: 8000,
246202 });
247203 messages.push({ role: "assistant", content: response.content as Array<ToolUseBlock | TextBlock> });
248204 if (response.stop_reason !== "tool_use") return;
249205
250206 const results: ToolResultBlock[] = [];
251207 for (const block of response.content) {
252208 if (block.type !== "tool_use") continue;
253209 const handler = TOOL_HANDLERS[block.name as ToolName];
254- const output = handler ? handler(block.input as Record<string, unknown>) : `Unknown tool: ${block.name}`;
210+ let output = "";
211+ try {
212+ output = handler ? handler(block.input as Record<string, unknown>) : `Unknown tool: ${block.name}`;
213+ } catch (error) {
214+ output = `Error: ${error instanceof Error ? error.message : String(error)}`;
215+ }
255216 console.log(`> ${block.name}: ${output.slice(0, 200)}`);
256217 results.push({ type: "tool_result", tool_use_id: block.id, content: output });
257218 }
258219 messages.push({ role: "user", content: results });
259220 }
260221}
261222
262223async function main() {
263224 const rl = createInterface({ input: process.stdin, output: process.stdout });
264225 const history: Message[] = [];
265226 while (true) {
266227 let query = "";
267228 try {
268- query = await rl.question("\x1b[36ms07 >> \x1b[0m");
229+ query = await rl.question("\x1b[36ms08 >> \x1b[0m");
269230 } catch (error) {
270231 if (
271232 error instanceof Error &&
272233 (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError")
273234 ) {
274235 break;
275236 }
276237 throw error;
277238 }
278239 if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) break;
279240 history.push({ role: "user", content: query });
280241 await agentLoop(history);
281242 const last = history[history.length - 1]?.content;
282243 if (Array.isArray(last)) {
283244 const text = assistantText(last as Array<ToolUseBlock | TextBlock>);
284245 if (text) console.log(text);
285246 }
286247 console.log();
287248 }
288249 rl.close();
289250}
290251
291252void main();