Learn Claude Code
Back to Team Protocols

Agent TeamsTeam Protocols

s09 (278 LOC) → s10 (317 LOC)

LOC Delta

+39lines

New Tools

3

shutdown_responseplan_approvalshutdown_request
New Classes

0

New Functions

2

handleShutdownRequesthandlePlanReview

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

Team Protocols

Shared Communication Rules

317 LOC

12 tools: bash, read_file, write_file, edit_file, send_message, read_inbox, shutdown_response, plan_approval, spawn_teammate, list_teammates, broadcast, shutdown_request

collaboration

Source Code Diff

s09 (s09_agent_teams.ts) -> s10 (s10_team_protocols.ts)
11#!/usr/bin/env node
22/**
3- * s09_agent_teams.ts - Agent Teams
3+ * s10_team_protocols.ts - Team Protocols
44 *
5- * Persistent teammates with JSONL inboxes.
5+ * request_id based shutdown and plan approval protocols.
66 */
77
88import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9+import { spawnSync } from "node:child_process";
910import { resolve } from "node:path";
1011import process from "node:process";
11-import { spawnSync } from "node:child_process";
12+import { randomUUID } from "node:crypto";
1213import { createInterface } from "node:readline/promises";
1314import type Anthropic from "@anthropic-ai/sdk";
1415import "dotenv/config";
1516import { buildSystemPrompt, createAnthropicClient, resolveModel, shellToolDescription } from "./shared";
1617
18+type MessageType = "message" | "broadcast" | "shutdown_request" | "shutdown_response" | "plan_approval_response";
1719type ToolName =
1820 | "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";
21+ | "spawn_teammate" | "list_teammates" | "send_message" | "read_inbox" | "broadcast"
22+ | "shutdown_request" | "shutdown_response" | "plan_approval";
2123type ToolUseBlock = { id: string; type: "tool_use"; name: ToolName; input: Record<string, unknown> };
2224type TextBlock = { type: "text"; text: string };
2325type ToolResultBlock = { type: "tool_result"; tool_use_id: string; content: string };
2426type Message = { role: "user" | "assistant"; content: string | Array<ToolUseBlock | TextBlock | ToolResultBlock> };
2527type TeamMember = { name: string; role: string; status: "working" | "idle" | "shutdown" };
2628type TeamConfig = { team_name: string; members: TeamMember[] };
2729
2830const WORKDIR = process.cwd();
2931const MODEL = resolveModel();
3032const TEAM_DIR = resolve(WORKDIR, ".team");
3133const INBOX_DIR = resolve(TEAM_DIR, "inbox");
3234const VALID_MSG_TYPES: MessageType[] = ["message", "broadcast", "shutdown_request", "shutdown_response", "plan_approval_response"];
35+const shutdownRequests: Record<string, { target: string; status: string }> = {};
36+const planRequests: Record<string, { from: string; plan: string; status: string }> = {};
3337const client = createAnthropicClient();
3438
35-const SYSTEM = buildSystemPrompt(`You are a team lead at ${WORKDIR}. Spawn teammates and communicate via inboxes.`);
39+const SYSTEM = buildSystemPrompt(`You are a team lead at ${WORKDIR}. Manage teammates with shutdown and plan approval protocols.`);
3640
3741function safePath(relativePath: string) {
3842 const filePath = resolve(WORKDIR, relativePath);
3943 const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`;
4044 if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) throw new Error(`Path escapes workspace: ${relativePath}`);
4145 return filePath;
4246}
4347
4448function runBash(command: string): string {
4549 const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"];
4650 if (dangerous.some((item) => command.includes(item))) return "Error: Dangerous command blocked";
4751 const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh";
4852 const args = process.platform === "win32" ? ["/d", "/s", "/c", command] : ["-lc", command];
4953 const result = spawnSync(shell, args, { cwd: WORKDIR, encoding: "utf8", timeout: 120_000 });
5054 if (result.error?.name === "TimeoutError") return "Error: Timeout (120s)";
5155 const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
5256 return output.slice(0, 50_000) || "(no output)";
5357}
5458
5559function runRead(path: string, limit?: number): string {
5660 try {
5761 let lines = readFileSync(safePath(path), "utf8").split(/\r?\n/);
5862 if (limit && limit < lines.length) lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`);
5963 return lines.join("\n").slice(0, 50_000);
6064 } catch (error) {
6165 return `Error: ${error instanceof Error ? error.message : String(error)}`;
6266 }
6367}
6468
6569function runWrite(path: string, content: string): string {
6670 try {
6771 const filePath = safePath(path);
6872 mkdirSync(resolve(filePath, ".."), { recursive: true });
6973 writeFileSync(filePath, content, "utf8");
7074 return `Wrote ${content.length} bytes`;
7175 } catch (error) {
7276 return `Error: ${error instanceof Error ? error.message : String(error)}`;
7377 }
7478}
7579
7680function runEdit(path: string, oldText: string, newText: string): string {
7781 try {
7882 const filePath = safePath(path);
7983 const content = readFileSync(filePath, "utf8");
8084 if (!content.includes(oldText)) return `Error: Text not found in ${path}`;
8185 writeFileSync(filePath, content.replace(oldText, newText), "utf8");
8286 return `Edited ${path}`;
8387 } catch (error) {
8488 return `Error: ${error instanceof Error ? error.message : String(error)}`;
8589 }
8690}
8791
8892class MessageBus {
8993 constructor(private inboxDir: string) {
9094 mkdirSync(inboxDir, { recursive: true });
9195 }
9296
9397 send(sender: string, to: string, content: string, msgType: MessageType = "message", extra?: Record<string, unknown>) {
9498 if (!VALID_MSG_TYPES.includes(msgType)) return `Error: Invalid type '${msgType}'.`;
9599 const payload = { type: msgType, from: sender, content, timestamp: Date.now() / 1000, ...(extra ?? {}) };
96100 appendFileSync(resolve(this.inboxDir, `${to}.jsonl`), `${JSON.stringify(payload)}\n`, "utf8");
97101 return `Sent ${msgType} to ${to}`;
98102 }
99103
100104 readInbox(name: string) {
101105 const inboxPath = resolve(this.inboxDir, `${name}.jsonl`);
102106 if (!existsSync(inboxPath)) return [];
103107 const lines = readFileSync(inboxPath, "utf8").split(/\r?\n/).filter(Boolean);
104108 writeFileSync(inboxPath, "", "utf8");
105109 return lines.map((line) => JSON.parse(line));
106110 }
107111
108112 broadcast(sender: string, content: string, teammates: string[]) {
109113 let count = 0;
110114 for (const name of teammates) {
111115 if (name === sender) continue;
112116 this.send(sender, name, content, "broadcast");
113117 count += 1;
114118 }
115119 return `Broadcast to ${count} teammates`;
116120 }
117121}
118122
119123const BUS = new MessageBus(INBOX_DIR);
120124
121125class TeammateManager {
122126 private configPath: string;
123127 private config: TeamConfig;
124128
125129 constructor(private teamDir: string) {
126130 mkdirSync(teamDir, { recursive: true });
127131 this.configPath = resolve(teamDir, "config.json");
128132 this.config = this.loadConfig();
129133 }
130134
131135 private loadConfig(): TeamConfig {
132136 if (existsSync(this.configPath)) return JSON.parse(readFileSync(this.configPath, "utf8")) as TeamConfig;
133137 return { team_name: "default", members: [] };
134138 }
135139
136140 private saveConfig() {
137141 writeFileSync(this.configPath, `${JSON.stringify(this.config, null, 2)}\n`, "utf8");
138142 }
139143
140144 private findMember(name: string) {
141145 return this.config.members.find((member) => member.name === name);
142146 }
143147
144148 spawn(name: string, role: string, prompt: string) {
145149 let member = this.findMember(name);
146150 if (member) {
147151 if (!["idle", "shutdown"].includes(member.status)) return `Error: '${name}' is currently ${member.status}`;
148152 member.status = "working";
149153 member.role = role;
150154 } else {
151155 member = { name, role, status: "working" };
152156 this.config.members.push(member);
153157 }
154158 this.saveConfig();
155159 void this.teammateLoop(name, role, prompt);
156160 return `Spawned '${name}' (role: ${role})`;
157161 }
158162
159163 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.`);
164+ const sysPrompt = buildSystemPrompt(`You are '${name}', role: ${role}, at ${WORKDIR}. Submit plans via plan_approval before major work. Respond to shutdown_request with shutdown_response.`);
161165 const messages: Message[] = [{ role: "user", content: prompt }];
162-
166+ let shouldExit = false;
163167 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-
168+ for (const msg of BUS.readInbox(name)) messages.push({ role: "user", content: JSON.stringify(msg) });
169+ if (shouldExit) break;
167170 const response = await client.messages.create({
168171 model: MODEL,
169172 system: sysPrompt,
170173 messages: messages as Anthropic.Messages.MessageParam[],
171- tools: this.teammateTools() as Anthropic.Messages.Tool[],
174+ tools: this.tools() as Anthropic.Messages.Tool[],
172175 max_tokens: 8000,
173176 }).catch(() => null);
174177 if (!response) break;
175-
176178 messages.push({ role: "assistant", content: response.content as Array<ToolUseBlock | TextBlock> });
177179 if (response.stop_reason !== "tool_use") break;
178-
179180 const results: ToolResultBlock[] = [];
180181 for (const block of response.content) {
181182 if (block.type !== "tool_use") continue;
182183 const output = this.exec(name, block.name, block.input as Record<string, unknown>);
184+ if (block.name === "shutdown_response" && block.input.approve) shouldExit = true;
183185 console.log(` [${name}] ${block.name}: ${output.slice(0, 120)}`);
184186 results.push({ type: "tool_result", tool_use_id: block.id, content: output });
185187 }
186188 messages.push({ role: "user", content: results });
187189 }
188-
189190 const member = this.findMember(name);
190- if (member && member.status !== "shutdown") {
191- member.status = "idle";
191+ if (member) {
192+ member.status = shouldExit ? "shutdown" : "idle";
192193 this.saveConfig();
193194 }
194195 }
195196
196- private teammateTools() {
197+ private tools() {
197198 return [
198199 { name: "bash", description: shellToolDescription(), input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } },
199200 { name: "read_file", description: "Read file contents.", input_schema: { type: "object", properties: { path: { type: "string" }, limit: { type: "integer" } }, required: ["path"] } },
200201 { name: "write_file", description: "Write content to file.", input_schema: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] } },
201202 { 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"] } },
202203 { 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"] } },
203204 { name: "read_inbox", description: "Read and drain your inbox.", input_schema: { type: "object", properties: {} } },
205+ { name: "shutdown_response", description: "Respond to a shutdown request.", input_schema: { type: "object", properties: { request_id: { type: "string" }, approve: { type: "boolean" }, reason: { type: "string" } }, required: ["request_id", "approve"] } },
206+ { name: "plan_approval", description: "Submit a plan for lead approval.", input_schema: { type: "object", properties: { plan: { type: "string" } }, required: ["plan"] } },
204207 ];
205208 }
206209
207210 private exec(sender: string, toolName: string, input: Record<string, unknown>) {
208211 if (toolName === "bash") return runBash(String(input.command ?? ""));
209212 if (toolName === "read_file") return runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined);
210213 if (toolName === "write_file") return runWrite(String(input.path ?? ""), String(input.content ?? ""));
211214 if (toolName === "edit_file") return runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? ""));
212215 if (toolName === "send_message") return BUS.send(sender, String(input.to ?? ""), String(input.content ?? ""), (input.msg_type as MessageType | undefined) ?? "message");
213216 if (toolName === "read_inbox") return JSON.stringify(BUS.readInbox(sender), null, 2);
217+ if (toolName === "shutdown_response") {
218+ const requestId = String(input.request_id ?? "");
219+ shutdownRequests[requestId] = { ...(shutdownRequests[requestId] ?? { target: sender }), status: input.approve ? "approved" : "rejected" };
220+ BUS.send(sender, "lead", String(input.reason ?? ""), "shutdown_response", { request_id: requestId, approve: Boolean(input.approve) });
221+ return `Shutdown ${input.approve ? "approved" : "rejected"}`;
222+ }
223+ if (toolName === "plan_approval") {
224+ const requestId = randomUUID().slice(0, 8);
225+ planRequests[requestId] = { from: sender, plan: String(input.plan ?? ""), status: "pending" };
226+ BUS.send(sender, "lead", String(input.plan ?? ""), "plan_approval_response", { request_id: requestId, plan: String(input.plan ?? "") });
227+ return `Plan submitted (request_id=${requestId}). Waiting for lead approval.`;
228+ }
214229 return `Unknown tool: ${toolName}`;
215230 }
216-
217231 listAll() {
218232 if (!this.config.members.length) return "No teammates.";
219233 return [`Team: ${this.config.team_name}`, ...this.config.members.map((m) => ` ${m.name} (${m.role}): ${m.status}`)].join("\n");
220234 }
221235
222236 memberNames() {
223237 return this.config.members.map((m) => m.name);
224238 }
225239}
226240
227241const TEAM = new TeammateManager(TEAM_DIR);
228242
243+function handleShutdownRequest(teammate: string) {
244+ const requestId = randomUUID().slice(0, 8);
245+ shutdownRequests[requestId] = { target: teammate, status: "pending" };
246+ BUS.send("lead", teammate, "Please shut down gracefully.", "shutdown_request", { request_id: requestId });
247+ return `Shutdown request ${requestId} sent to '${teammate}' (status: pending)`;
248+}
249+
250+function handlePlanReview(requestId: string, approve: boolean, feedback = "") {
251+ const request = planRequests[requestId];
252+ if (!request) return `Error: Unknown plan request_id '${requestId}'`;
253+ request.status = approve ? "approved" : "rejected";
254+ BUS.send("lead", request.from, feedback, "plan_approval_response", { request_id: requestId, approve, feedback });
255+ return `Plan ${request.status} for '${request.from}'`;
256+}
257+
229258const TOOL_HANDLERS: Record<ToolName, (input: Record<string, unknown>) => string> = {
230259 bash: (input) => runBash(String(input.command ?? "")),
231260 read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined),
232261 write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")),
233262 edit_file: (input) => runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")),
234263 spawn_teammate: (input) => TEAM.spawn(String(input.name ?? ""), String(input.role ?? ""), String(input.prompt ?? "")),
235264 list_teammates: () => TEAM.listAll(),
236265 send_message: (input) => BUS.send("lead", String(input.to ?? ""), String(input.content ?? ""), (input.msg_type as MessageType | undefined) ?? "message"),
237266 read_inbox: () => JSON.stringify(BUS.readInbox("lead"), null, 2),
238267 broadcast: (input) => BUS.broadcast("lead", String(input.content ?? ""), TEAM.memberNames()),
268+ shutdown_request: (input) => handleShutdownRequest(String(input.teammate ?? "")),
269+ shutdown_response: (input) => JSON.stringify(shutdownRequests[String(input.request_id ?? "")] ?? { error: "not found" }),
270+ plan_approval: (input) => handlePlanReview(String(input.request_id ?? ""), Boolean(input.approve), String(input.feedback ?? "")),
239271};
240272
241273const TOOLS = [
242274 { name: "bash", description: shellToolDescription(), input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } },
243275 { name: "read_file", description: "Read file contents.", input_schema: { type: "object", properties: { path: { type: "string" }, limit: { type: "integer" } }, required: ["path"] } },
244276 { name: "write_file", description: "Write content to file.", input_schema: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] } },
245277 { 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"] } },
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"] } },
278+ { name: "spawn_teammate", description: "Spawn a persistent teammate.", input_schema: { type: "object", properties: { name: { type: "string" }, role: { type: "string" }, prompt: { type: "string" } }, required: ["name", "role", "prompt"] } },
279+ { name: "list_teammates", description: "List all teammates.", input_schema: { type: "object", properties: {} } },
280+ { name: "send_message", description: "Send a 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"] } },
249281 { name: "read_inbox", description: "Read and drain the lead inbox.", input_schema: { type: "object", properties: {} } },
250282 { name: "broadcast", description: "Send a message to all teammates.", input_schema: { type: "object", properties: { content: { type: "string" } }, required: ["content"] } },
283+ { name: "shutdown_request", description: "Request a teammate to shut down gracefully.", input_schema: { type: "object", properties: { teammate: { type: "string" } }, required: ["teammate"] } },
284+ { name: "shutdown_response", description: "Check shutdown request status by request_id.", input_schema: { type: "object", properties: { request_id: { type: "string" } }, required: ["request_id"] } },
285+ { name: "plan_approval", description: "Approve or reject a teammate plan.", input_schema: { type: "object", properties: { request_id: { type: "string" }, approve: { type: "boolean" }, feedback: { type: "string" } }, required: ["request_id", "approve"] } },
251286];
252287
253288function assistantText(content: Array<ToolUseBlock | TextBlock>) {
254289 return content.filter((block): block is TextBlock => block.type === "text").map((block) => block.text).join("\n");
255290}
256291
257292export async function agentLoop(messages: Message[]) {
258293 while (true) {
259294 const inbox = BUS.readInbox("lead");
260295 if (inbox.length) {
261296 messages.push({ role: "user", content: `<inbox>${JSON.stringify(inbox, null, 2)}</inbox>` });
262297 messages.push({ role: "assistant", content: "Noted inbox messages." });
263298 }
264-
265299 const response = await client.messages.create({
266300 model: MODEL,
267301 system: SYSTEM,
268302 messages: messages as Anthropic.Messages.MessageParam[],
269303 tools: TOOLS as Anthropic.Messages.Tool[],
270304 max_tokens: 8000,
271305 });
272306 messages.push({ role: "assistant", content: response.content as Array<ToolUseBlock | TextBlock> });
273307 if (response.stop_reason !== "tool_use") return;
274-
275308 const results: ToolResultBlock[] = [];
276309 for (const block of response.content) {
277310 if (block.type !== "tool_use") continue;
278311 const handler = TOOL_HANDLERS[block.name as ToolName];
279312 const output = handler ? handler(block.input as Record<string, unknown>) : `Unknown tool: ${block.name}`;
280313 console.log(`> ${block.name}: ${output.slice(0, 200)}`);
281314 results.push({ type: "tool_result", tool_use_id: block.id, content: output });
282315 }
283316 messages.push({ role: "user", content: results });
284317 }
285318}
286319
287320async function main() {
288321 const rl = createInterface({ input: process.stdin, output: process.stdout });
289322 const history: Message[] = [];
290323 while (true) {
291324 let query = "";
292325 try {
293- query = await rl.question("\x1b[36ms09 >> \x1b[0m");
326+ query = await rl.question("\x1b[36ms10 >> \x1b[0m");
294327 } catch (error) {
295328 if (
296329 error instanceof Error &&
297330 (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError")
298331 ) {
299332 break;
300333 }
301334 throw error;
302335 }
303336 if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) break;
304337 if (query.trim() === "/team") { console.log(TEAM.listAll()); continue; }
305338 if (query.trim() === "/inbox") { console.log(JSON.stringify(BUS.readInbox("lead"), null, 2)); continue; }
306339 history.push({ role: "user", content: query });
307340 await agentLoop(history);
308341 const last = history[history.length - 1]?.content;
309342 if (Array.isArray(last)) {
310343 const text = assistantText(last as Array<ToolUseBlock | TextBlock>);
311344 if (text) console.log(text);
312345 }
313346 console.log();
314347 }
315348 rl.close();
316349}
317350
318351void main();