Learn Claude Code
Back to Agent Teams

Background TasksAgent Teams

s08 (223 LOC) → s09 (278 LOC)

LOC Delta

+55lines

New Tools

5

send_messageread_inboxspawn_teammatelist_teammatesbroadcast
New Classes

2

MessageBusTeammateManager
New Functions

0

Background Tasks

Background Threads + Notifications

223 LOC

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

concurrency

Agent Teams

Teammates + Mailboxes

278 LOC

9 tools: bash, read_file, write_file, edit_file, send_message, read_inbox, spawn_teammate, list_teammates, broadcast

collaboration

Source Code Diff

s08 (s08_background_tasks.ts) -> s09 (s09_agent_teams.ts)
11#!/usr/bin/env node
22/**
3- * s08_background_tasks.ts - Background Tasks
3+ * s09_agent_teams.ts - Agent Teams
44 *
5- * Run commands in background child processes and inject notifications later.
5+ * Persistent teammates with JSONL inboxes.
66 */
77
8-import { spawn, spawnSync } from "node:child_process";
9-import { randomUUID } from "node:crypto";
10-import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
8+import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
119import { resolve } from "node:path";
1210import process from "node:process";
11+import { spawnSync } from "node:child_process";
1312import { createInterface } from "node:readline/promises";
1413import type Anthropic from "@anthropic-ai/sdk";
1514import "dotenv/config";
1615import { buildSystemPrompt, createAnthropicClient, resolveModel, shellToolDescription } from "./shared";
1716
18-type ToolName = "bash" | "read_file" | "write_file" | "edit_file" | "background_run" | "check_background";
17+type ToolName =
18+ | "bash" | "read_file" | "write_file" | "edit_file"
19+ | "spawn_teammate" | "list_teammates" | "send_message" | "read_inbox" | "broadcast";
20+type MessageType = "message" | "broadcast" | "shutdown_request" | "shutdown_response" | "plan_approval_response";
1921type ToolUseBlock = { id: string; type: "tool_use"; name: ToolName; input: Record<string, unknown> };
2022type TextBlock = { type: "text"; text: string };
2123type ToolResultBlock = { type: "tool_result"; tool_use_id: string; content: string };
2224type Message = { role: "user" | "assistant"; content: string | Array<ToolUseBlock | TextBlock | ToolResultBlock> };
25+type TeamMember = { name: string; role: string; status: "working" | "idle" | "shutdown" };
26+type TeamConfig = { team_name: string; members: TeamMember[] };
2327
24-type BackgroundTask = {
25- status: "running" | "completed" | "timeout" | "error";
26- result: string | null;
27- command: string;
28-};
29-
3028const WORKDIR = process.cwd();
3129const MODEL = resolveModel();
30+const TEAM_DIR = resolve(WORKDIR, ".team");
31+const INBOX_DIR = resolve(TEAM_DIR, "inbox");
32+const VALID_MSG_TYPES: MessageType[] = ["message", "broadcast", "shutdown_request", "shutdown_response", "plan_approval_response"];
3233const client = createAnthropicClient();
3334
34-const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}. Use background_run for long-running commands.`);
35+const SYSTEM = buildSystemPrompt(`You are a team lead at ${WORKDIR}. Spawn teammates and communicate via inboxes.`);
3536
3637function safePath(relativePath: string) {
3738 const filePath = resolve(WORKDIR, relativePath);
3839 const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`;
3940 if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) throw new Error(`Path escapes workspace: ${relativePath}`);
4041 return filePath;
4142}
4243
43-function runCommand(command: string, cwd: string, timeout = 120_000): string {
44+function runBash(command: string): string {
4445 const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"];
4546 if (dangerous.some((item) => command.includes(item))) return "Error: Dangerous command blocked";
4647 const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh";
4748 const args = process.platform === "win32" ? ["/d", "/s", "/c", command] : ["-lc", command];
48- const result = spawnSync(shell, args, { cwd, encoding: "utf8", timeout });
49- if (result.error?.name === "TimeoutError") return `Error: Timeout (${Math.floor(timeout / 1000)}s)`;
49+ const result = spawnSync(shell, args, { cwd: WORKDIR, encoding: "utf8", timeout: 120_000 });
50+ if (result.error?.name === "TimeoutError") return "Error: Timeout (120s)";
5051 const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
5152 return output.slice(0, 50_000) || "(no output)";
5253}
5354
54-function runBash(command: string): string {
55- return runCommand(command, WORKDIR, 120_000);
56-}
57-
5855function runRead(path: string, limit?: number): string {
5956 try {
6057 let lines = readFileSync(safePath(path), "utf8").split(/\r?\n/);
6158 if (limit && limit < lines.length) lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`);
6259 return lines.join("\n").slice(0, 50_000);
6360 } catch (error) {
6461 return `Error: ${error instanceof Error ? error.message : String(error)}`;
6562 }
6663}
6764
6865function runWrite(path: string, content: string): string {
6966 try {
7067 const filePath = safePath(path);
7168 mkdirSync(resolve(filePath, ".."), { recursive: true });
7269 writeFileSync(filePath, content, "utf8");
7370 return `Wrote ${content.length} bytes`;
7471 } catch (error) {
7572 return `Error: ${error instanceof Error ? error.message : String(error)}`;
7673 }
7774}
7875
7976function runEdit(path: string, oldText: string, newText: string): string {
8077 try {
8178 const filePath = safePath(path);
8279 const content = readFileSync(filePath, "utf8");
8380 if (!content.includes(oldText)) return `Error: Text not found in ${path}`;
8481 writeFileSync(filePath, content.replace(oldText, newText), "utf8");
8582 return `Edited ${path}`;
8683 } catch (error) {
8784 return `Error: ${error instanceof Error ? error.message : String(error)}`;
8885 }
8986}
9087
91-class BackgroundManager {
92- tasks: Record<string, BackgroundTask> = {};
93- private notificationQueue: Array<{ task_id: string; status: string; command: string; result: string }> = [];
88+class MessageBus {
89+ constructor(private inboxDir: string) {
90+ mkdirSync(inboxDir, { recursive: true });
91+ }
9492
95- run(command: string): string {
96- const taskId = randomUUID().slice(0, 8);
97- this.tasks[taskId] = { status: "running", result: null, command };
93+ send(sender: string, to: string, content: string, msgType: MessageType = "message", extra?: Record<string, unknown>) {
94+ if (!VALID_MSG_TYPES.includes(msgType)) return `Error: Invalid type '${msgType}'.`;
95+ const payload = { type: msgType, from: sender, content, timestamp: Date.now() / 1000, ...(extra ?? {}) };
96+ appendFileSync(resolve(this.inboxDir, `${to}.jsonl`), `${JSON.stringify(payload)}\n`, "utf8");
97+ return `Sent ${msgType} to ${to}`;
98+ }
9899
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"] });
100+ readInbox(name: string) {
101+ const inboxPath = resolve(this.inboxDir, `${name}.jsonl`);
102+ if (!existsSync(inboxPath)) return [];
103+ const lines = readFileSync(inboxPath, "utf8").split(/\r?\n/).filter(Boolean);
104+ writeFileSync(inboxPath, "", "utf8");
105+ return lines.map((line) => JSON.parse(line));
106+ }
102107
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);
108+ broadcast(sender: string, content: string, teammates: string[]) {
109+ let count = 0;
110+ for (const name of teammates) {
111+ if (name === sender) continue;
112+ this.send(sender, name, content, "broadcast");
113+ count += 1;
114+ }
115+ return `Broadcast to ${count} teammates`;
116+ }
117+}
115118
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- });
119+const BUS = new MessageBus(INBOX_DIR);
142120
143- return `Background task ${taskId} started: ${command.slice(0, 80)}`;
121+class TeammateManager {
122+ private configPath: string;
123+ private config: TeamConfig;
124+
125+ constructor(private teamDir: string) {
126+ mkdirSync(teamDir, { recursive: true });
127+ this.configPath = resolve(teamDir, "config.json");
128+ this.config = this.loadConfig();
144129 }
145130
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)"}`;
131+ private loadConfig(): TeamConfig {
132+ if (existsSync(this.configPath)) return JSON.parse(readFileSync(this.configPath, "utf8")) as TeamConfig;
133+ return { team_name: "default", members: [] };
134+ }
135+
136+ private saveConfig() {
137+ writeFileSync(this.configPath, `${JSON.stringify(this.config, null, 2)}\n`, "utf8");
138+ }
139+
140+ private findMember(name: string) {
141+ return this.config.members.find((member) => member.name === name);
142+ }
143+
144+ spawn(name: string, role: string, prompt: string) {
145+ let member = this.findMember(name);
146+ if (member) {
147+ if (!["idle", "shutdown"].includes(member.status)) return `Error: '${name}' is currently ${member.status}`;
148+ member.status = "working";
149+ member.role = role;
150+ } else {
151+ member = { name, role, status: "working" };
152+ this.config.members.push(member);
151153 }
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.";
154+ this.saveConfig();
155+ void this.teammateLoop(name, role, prompt);
156+ return `Spawned '${name}' (role: ${role})`;
154157 }
155158
156- drainNotifications() {
157- const notifications = [...this.notificationQueue];
158- this.notificationQueue = [];
159- return notifications;
159+ private async teammateLoop(name: string, role: string, prompt: string) {
160+ const sysPrompt = buildSystemPrompt(`You are '${name}', role: ${role}, at ${WORKDIR}. Use send_message to communicate. Complete your task.`);
161+ const messages: Message[] = [{ role: "user", content: prompt }];
162+
163+ for (let attempt = 0; attempt < 50; attempt += 1) {
164+ const inbox = BUS.readInbox(name);
165+ for (const message of inbox) messages.push({ role: "user", content: JSON.stringify(message) });
166+
167+ const response = await client.messages.create({
168+ model: MODEL,
169+ system: sysPrompt,
170+ messages: messages as Anthropic.Messages.MessageParam[],
171+ tools: this.teammateTools() as Anthropic.Messages.Tool[],
172+ max_tokens: 8000,
173+ }).catch(() => null);
174+ if (!response) break;
175+
176+ messages.push({ role: "assistant", content: response.content as Array<ToolUseBlock | TextBlock> });
177+ if (response.stop_reason !== "tool_use") break;
178+
179+ const results: ToolResultBlock[] = [];
180+ for (const block of response.content) {
181+ if (block.type !== "tool_use") continue;
182+ const output = this.exec(name, block.name, block.input as Record<string, unknown>);
183+ console.log(` [${name}] ${block.name}: ${output.slice(0, 120)}`);
184+ results.push({ type: "tool_result", tool_use_id: block.id, content: output });
185+ }
186+ messages.push({ role: "user", content: results });
187+ }
188+
189+ const member = this.findMember(name);
190+ if (member && member.status !== "shutdown") {
191+ member.status = "idle";
192+ this.saveConfig();
193+ }
160194 }
195+
196+ private teammateTools() {
197+ return [
198+ { name: "bash", description: shellToolDescription(), input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } },
199+ { name: "read_file", description: "Read file contents.", input_schema: { type: "object", properties: { path: { type: "string" }, limit: { type: "integer" } }, required: ["path"] } },
200+ { name: "write_file", description: "Write content to file.", input_schema: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] } },
201+ { 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"] } },
202+ { name: "send_message", description: "Send message to a teammate.", input_schema: { type: "object", properties: { to: { type: "string" }, content: { type: "string" }, msg_type: { type: "string", enum: VALID_MSG_TYPES } }, required: ["to", "content"] } },
203+ { name: "read_inbox", description: "Read and drain your inbox.", input_schema: { type: "object", properties: {} } },
204+ ];
205+ }
206+
207+ private exec(sender: string, toolName: string, input: Record<string, unknown>) {
208+ if (toolName === "bash") return runBash(String(input.command ?? ""));
209+ if (toolName === "read_file") return runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined);
210+ if (toolName === "write_file") return runWrite(String(input.path ?? ""), String(input.content ?? ""));
211+ if (toolName === "edit_file") return runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? ""));
212+ if (toolName === "send_message") return BUS.send(sender, String(input.to ?? ""), String(input.content ?? ""), (input.msg_type as MessageType | undefined) ?? "message");
213+ if (toolName === "read_inbox") return JSON.stringify(BUS.readInbox(sender), null, 2);
214+ return `Unknown tool: ${toolName}`;
215+ }
216+
217+ listAll() {
218+ if (!this.config.members.length) return "No teammates.";
219+ return [`Team: ${this.config.team_name}`, ...this.config.members.map((m) => ` ${m.name} (${m.role}): ${m.status}`)].join("\n");
220+ }
221+
222+ memberNames() {
223+ return this.config.members.map((m) => m.name);
224+ }
161225}
162226
163-const BG = new BackgroundManager();
227+const TEAM = new TeammateManager(TEAM_DIR);
164228
165229const TOOL_HANDLERS: Record<ToolName, (input: Record<string, unknown>) => string> = {
166230 bash: (input) => runBash(String(input.command ?? "")),
167231 read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined),
168232 write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")),
169233 edit_file: (input) => runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")),
170- background_run: (input) => BG.run(String(input.command ?? "")),
171- check_background: (input) => BG.check(typeof input.task_id === "string" ? input.task_id : undefined),
234+ spawn_teammate: (input) => TEAM.spawn(String(input.name ?? ""), String(input.role ?? ""), String(input.prompt ?? "")),
235+ list_teammates: () => TEAM.listAll(),
236+ send_message: (input) => BUS.send("lead", String(input.to ?? ""), String(input.content ?? ""), (input.msg_type as MessageType | undefined) ?? "message"),
237+ read_inbox: () => JSON.stringify(BUS.readInbox("lead"), null, 2),
238+ broadcast: (input) => BUS.broadcast("lead", String(input.content ?? ""), TEAM.memberNames()),
172239};
173240
174241const TOOLS = [
175242 { name: "bash", description: shellToolDescription(), input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } },
176243 { name: "read_file", description: "Read file contents.", input_schema: { type: "object", properties: { path: { type: "string" }, limit: { type: "integer" } }, required: ["path"] } },
177244 { name: "write_file", description: "Write content to file.", input_schema: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] } },
178245 { 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"] } },
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" } } } },
246+ { name: "spawn_teammate", description: "Spawn a persistent teammate that runs in its own loop.", input_schema: { type: "object", properties: { name: { type: "string" }, role: { type: "string" }, prompt: { type: "string" } }, required: ["name", "role", "prompt"] } },
247+ { name: "list_teammates", description: "List all teammates with name, role, status.", input_schema: { type: "object", properties: {} } },
248+ { name: "send_message", description: "Send a message to a teammate inbox.", input_schema: { type: "object", properties: { to: { type: "string" }, content: { type: "string" }, msg_type: { type: "string", enum: VALID_MSG_TYPES } }, required: ["to", "content"] } },
249+ { name: "read_inbox", description: "Read and drain the lead inbox.", input_schema: { type: "object", properties: {} } },
250+ { name: "broadcast", description: "Send a message to all teammates.", input_schema: { type: "object", properties: { content: { type: "string" } }, required: ["content"] } },
181251];
182252
183253function assistantText(content: Array<ToolUseBlock | TextBlock>) {
184254 return content.filter((block): block is TextBlock => block.type === "text").map((block) => block.text).join("\n");
185255}
186256
187257export async function agentLoop(messages: Message[]) {
188258 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." });
259+ const inbox = BUS.readInbox("lead");
260+ if (inbox.length) {
261+ messages.push({ role: "user", content: `<inbox>${JSON.stringify(inbox, null, 2)}</inbox>` });
262+ messages.push({ role: "assistant", content: "Noted inbox messages." });
194263 }
195264
196265 const response = await client.messages.create({
197266 model: MODEL,
198267 system: SYSTEM,
199268 messages: messages as Anthropic.Messages.MessageParam[],
200269 tools: TOOLS as Anthropic.Messages.Tool[],
201270 max_tokens: 8000,
202271 });
203272 messages.push({ role: "assistant", content: response.content as Array<ToolUseBlock | TextBlock> });
204273 if (response.stop_reason !== "tool_use") return;
205274
206275 const results: ToolResultBlock[] = [];
207276 for (const block of response.content) {
208277 if (block.type !== "tool_use") continue;
209278 const handler = TOOL_HANDLERS[block.name as ToolName];
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- }
279+ const output = handler ? handler(block.input as Record<string, unknown>) : `Unknown tool: ${block.name}`;
216280 console.log(`> ${block.name}: ${output.slice(0, 200)}`);
217281 results.push({ type: "tool_result", tool_use_id: block.id, content: output });
218282 }
219283 messages.push({ role: "user", content: results });
220284 }
221285}
222286
223287async function main() {
224288 const rl = createInterface({ input: process.stdin, output: process.stdout });
225289 const history: Message[] = [];
226290 while (true) {
227291 let query = "";
228292 try {
229- query = await rl.question("\x1b[36ms08 >> \x1b[0m");
293+ query = await rl.question("\x1b[36ms09 >> \x1b[0m");
230294 } catch (error) {
231295 if (
232296 error instanceof Error &&
233297 (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError")
234298 ) {
235299 break;
236300 }
237301 throw error;
238302 }
239303 if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) break;
304+ if (query.trim() === "/team") { console.log(TEAM.listAll()); continue; }
305+ if (query.trim() === "/inbox") { console.log(JSON.stringify(BUS.readInbox("lead"), null, 2)); continue; }
240306 history.push({ role: "user", content: query });
241307 await agentLoop(history);
242308 const last = history[history.length - 1]?.content;
243309 if (Array.isArray(last)) {
244310 const text = assistantText(last as Array<ToolUseBlock | TextBlock>);
245311 if (text) console.log(text);
246312 }
247313 console.log();
248314 }
249315 rl.close();
250316}
251317
252318void main();