Learn Claude Code
Back to Worktree + Task Isolation

Autonomous AgentsWorktree + Task Isolation

s11 (413 LOC) → s12 (395 LOC)

LOC Delta

-18lines

New Tools

12

task_createtask_listtask_gettask_updatetask_bind_worktreeworktree_createworktree_listworktree_statusworktree_runworktree_removeworktree_keepworktree_events
New Classes

3

EventBusTaskManagerWorktreeManager
New Functions

2

detectRepoRootrunCommand

Autonomous Agents

Scan Board, Claim Tasks

413 LOC

14 tools: bash, read_file, write_file, edit_file, send_message, read_inbox, shutdown_response, plan_approval, idle, claim_task, spawn_teammate, list_teammates, broadcast, shutdown_request

collaboration

Worktree + Task Isolation

Isolate by Directory

395 LOC

16 tools: bash, read_file, write_file, edit_file, task_create, task_list, task_get, task_update, task_bind_worktree, worktree_create, worktree_list, worktree_status, worktree_run, worktree_remove, worktree_keep, worktree_events

collaboration

Source Code Diff

s11 (s11_autonomous_agents.ts) -> s12 (s12_worktree_task_isolation.ts)
11#!/usr/bin/env node
22/**
3- * s11_autonomous_agents.ts - Autonomous Agents
3+ * s12_worktree_task_isolation.ts - Worktree + Task Isolation
44 *
5- * Idle polling + auto-claim task board + identity re-injection.
5+ * Task board as control plane, git worktrees as execution plane.
66 */
77
8-import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
98import { spawnSync } from "node:child_process";
9+import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
1010import { resolve } from "node:path";
1111import process from "node:process";
12-import { randomUUID } from "node:crypto";
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 MessageType = "message" | "broadcast" | "shutdown_request" | "shutdown_response" | "plan_approval_response";
17+type TaskStatus = "pending" | "in_progress" | "completed";
1918type ToolName =
2019 | "bash" | "read_file" | "write_file" | "edit_file"
21- | "spawn_teammate" | "list_teammates" | "send_message" | "read_inbox" | "broadcast"
22- | "shutdown_request" | "shutdown_response" | "plan_approval" | "idle" | "claim_task";
20+ | "task_create" | "task_list" | "task_get" | "task_update" | "task_bind_worktree"
21+ | "worktree_create" | "worktree_list" | "worktree_status" | "worktree_run" | "worktree_keep" | "worktree_remove" | "worktree_events";
2322type ToolUseBlock = { id: string; type: "tool_use"; name: ToolName; input: Record<string, unknown> };
2423type TextBlock = { type: "text"; text: string };
2524type ToolResultBlock = { type: "tool_result"; tool_use_id: string; content: string };
2625type Message = { role: "user" | "assistant"; content: string | Array<ToolUseBlock | TextBlock | ToolResultBlock> };
27-type TeamMember = { name: string; role: string; status: "working" | "idle" | "shutdown" };
28-type TeamConfig = { team_name: string; members: TeamMember[] };
29-type TaskRecord = { id: number; subject: string; description?: string; status: string; owner?: string; blockedBy?: number[] };
26+type TaskRecord = {
27+ id: number;
28+ subject: string;
29+ description: string;
30+ status: TaskStatus;
31+ owner: string;
32+ worktree: string;
33+ blockedBy: number[];
34+ created_at: number;
35+ updated_at: number;
36+};
37+type WorktreeRecord = {
38+ name: string;
39+ path: string;
40+ branch: string;
41+ task_id?: number;
42+ status: string;
43+ created_at: number;
44+ removed_at?: number;
45+ kept_at?: number;
46+};
3047
3148const WORKDIR = process.cwd();
3249const MODEL = resolveModel();
33-const TEAM_DIR = resolve(WORKDIR, ".team");
34-const INBOX_DIR = resolve(TEAM_DIR, "inbox");
35-const TASKS_DIR = resolve(WORKDIR, ".tasks");
36-const POLL_INTERVAL = 5_000;
37-const IDLE_TIMEOUT = 60_000;
38-const VALID_MSG_TYPES: MessageType[] = ["message", "broadcast", "shutdown_request", "shutdown_response", "plan_approval_response"];
39-const shutdownRequests: Record<string, { target: string; status: string }> = {};
40-const planRequests: Record<string, { from: string; plan: string; status: string }> = {};
41-const client = createAnthropicClient();
4250
43-const SYSTEM = buildSystemPrompt(`You are a team lead at ${WORKDIR}. Teammates are autonomous -- they find work themselves.`);
44-
45-function sleep(ms: number) {
46- return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
51+function detectRepoRoot(cwd: string) {
52+ const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8", timeout: 10_000 });
53+ if (result.status !== 0) return cwd;
54+ return result.stdout.trim() || cwd;
4755}
4856
57+const REPO_ROOT = detectRepoRoot(WORKDIR);
58+const TASKS_DIR = resolve(REPO_ROOT, ".tasks");
59+const WORKTREES_DIR = resolve(REPO_ROOT, ".worktrees");
60+const EVENTS_PATH = resolve(WORKTREES_DIR, "events.jsonl");
61+const INDEX_PATH = resolve(WORKTREES_DIR, "index.json");
62+
63+const client = createAnthropicClient();
64+
65+const SYSTEM = buildSystemPrompt(`You are a coding agent at ${WORKDIR}. Use task + worktree tools for multi-task work.`);
66+
4967function safePath(relativePath: string) {
5068 const filePath = resolve(WORKDIR, relativePath);
5169 const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`;
5270 if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) throw new Error(`Path escapes workspace: ${relativePath}`);
5371 return filePath;
5472}
5573
56-function runBash(command: string): string {
74+function runCommand(command: string, cwd: string, timeout = 120_000) {
5775 const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"];
5876 if (dangerous.some((item) => command.includes(item))) return "Error: Dangerous command blocked";
5977 const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh";
6078 const args = process.platform === "win32" ? ["/d", "/s", "/c", command] : ["-lc", command];
61- const result = spawnSync(shell, args, { cwd: WORKDIR, encoding: "utf8", timeout: 120_000 });
62- if (result.error?.name === "TimeoutError") return "Error: Timeout (120s)";
79+ const result = spawnSync(shell, args, { cwd, encoding: "utf8", timeout });
80+ if (result.error?.name === "TimeoutError") return `Error: Timeout (${Math.floor(timeout / 1000)}s)`;
6381 const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
6482 return output.slice(0, 50_000) || "(no output)";
6583}
6684
67-function runRead(path: string, limit?: number): string {
85+function runBash(command: string) { return runCommand(command, WORKDIR, 120_000); }
86+function runRead(path: string, limit?: number) {
6887 try {
6988 let lines = readFileSync(safePath(path), "utf8").split(/\r?\n/);
7089 if (limit && limit < lines.length) lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`);
7190 return lines.join("\n").slice(0, 50_000);
7291 } catch (error) {
7392 return `Error: ${error instanceof Error ? error.message : String(error)}`;
7493 }
7594}
76-
77-function runWrite(path: string, content: string): string {
95+function runWrite(path: string, content: string) {
7896 try {
7997 const filePath = safePath(path);
8098 mkdirSync(resolve(filePath, ".."), { recursive: true });
8199 writeFileSync(filePath, content, "utf8");
82100 return `Wrote ${content.length} bytes`;
83101 } catch (error) {
84102 return `Error: ${error instanceof Error ? error.message : String(error)}`;
85103 }
86104}
87-
88-function runEdit(path: string, oldText: string, newText: string): string {
105+function runEdit(path: string, oldText: string, newText: string) {
89106 try {
90107 const filePath = safePath(path);
91108 const content = readFileSync(filePath, "utf8");
92109 if (!content.includes(oldText)) return `Error: Text not found in ${path}`;
93110 writeFileSync(filePath, content.replace(oldText, newText), "utf8");
94111 return `Edited ${path}`;
95112 } catch (error) {
96113 return `Error: ${error instanceof Error ? error.message : String(error)}`;
97114 }
98115}
99116
100-function scanUnclaimedTasks() {
101- mkdirSync(TASKS_DIR, { recursive: true });
102- const tasks: TaskRecord[] = [];
103- for (const entry of readdirSync(TASKS_DIR, { withFileTypes: true })) {
104- if (!entry.isFile() || !/^task_\d+\.json$/.test(entry.name)) continue;
105- const task = JSON.parse(readFileSync(resolve(TASKS_DIR, entry.name), "utf8")) as TaskRecord;
106- if (task.status === "pending" && !task.owner && !(task.blockedBy?.length)) tasks.push(task);
117+class EventBus {
118+ constructor(private eventLogPath: string) {
119+ mkdirSync(resolve(eventLogPath, ".."), { recursive: true });
120+ if (!existsSync(eventLogPath)) writeFileSync(eventLogPath, "", "utf8");
107121 }
108- return tasks.sort((a, b) => a.id - b.id);
109-}
110122
111-function claimTask(taskId: number, owner: string) {
112- const path = resolve(TASKS_DIR, `task_${taskId}.json`);
113- if (!existsSync(path)) return `Error: Task ${taskId} not found`;
114- const task = JSON.parse(readFileSync(path, "utf8")) as TaskRecord;
115- task.owner = owner;
116- task.status = "in_progress";
117- writeFileSync(path, `${JSON.stringify(task, null, 2)}\n`, "utf8");
118- return `Claimed task #${taskId} for ${owner}`;
119-}
123+ emit(event: string, task: Record<string, unknown> = {}, worktree: Record<string, unknown> = {}, error?: string) {
124+ const payload = { event, ts: Date.now() / 1000, task, worktree, ...(error ? { error } : {}) };
125+ appendFileSync(this.eventLogPath, `${JSON.stringify(payload)}\n`, "utf8");
126+ }
120127
121-function makeIdentityBlock(name: string, role: string, teamName: string): Message {
122- return { role: "user", content: `<identity>You are '${name}', role: ${role}, team: ${teamName}. Continue your work.</identity>` };
128+ listRecent(limit = 20) {
129+ const count = Math.max(1, Math.min(limit, 200));
130+ const lines = readFileSync(this.eventLogPath, "utf8").split(/\r?\n/).filter(Boolean).slice(-count);
131+ return JSON.stringify(lines.map((line) => JSON.parse(line)), null, 2);
132+ }
123133}
124134
125-class MessageBus {
126- constructor(private inboxDir: string) {
127- mkdirSync(inboxDir, { recursive: true });
135+class TaskManager {
136+ private nextId: number;
137+
138+ constructor(private tasksDir: string) {
139+ mkdirSync(tasksDir, { recursive: true });
140+ this.nextId = this.maxId() + 1;
128141 }
129142
130- send(sender: string, to: string, content: string, msgType: MessageType = "message", extra?: Record<string, unknown>) {
131- if (!VALID_MSG_TYPES.includes(msgType)) return `Error: Invalid type '${msgType}'.`;
132- const payload = { type: msgType, from: sender, content, timestamp: Date.now() / 1000, ...(extra ?? {}) };
133- appendFileSync(resolve(this.inboxDir, `${to}.jsonl`), `${JSON.stringify(payload)}\n`, "utf8");
134- return `Sent ${msgType} to ${to}`;
143+ private maxId(): number {
144+ return readdirSync(this.tasksDir, { withFileTypes: true })
145+ .filter((entry) => entry.isFile() && /^task_\d+\.json$/.test(entry.name))
146+ .map((entry) => Number(entry.name.match(/\d+/)?.[0] ?? 0))
147+ .reduce((max, id) => Math.max(max, id), 0);
135148 }
136149
137- readInbox(name: string) {
138- const inboxPath = resolve(this.inboxDir, `${name}.jsonl`);
139- if (!existsSync(inboxPath)) return [];
140- const lines = readFileSync(inboxPath, "utf8").split(/\r?\n/).filter(Boolean);
141- writeFileSync(inboxPath, "", "utf8");
142- return lines.map((line) => JSON.parse(line));
150+ private filePath(taskId: number) {
151+ return resolve(this.tasksDir, `task_${taskId}.json`);
143152 }
144153
145- broadcast(sender: string, content: string, teammates: string[]) {
146- let count = 0;
147- for (const name of teammates) {
148- if (name === sender) continue;
149- this.send(sender, name, content, "broadcast");
150- count += 1;
151- }
152- return `Broadcast to ${count} teammates`;
154+ exists(taskId: number) {
155+ return existsSync(this.filePath(taskId));
153156 }
154-}
155157
156-const BUS = new MessageBus(INBOX_DIR);
158+ private load(taskId: number): TaskRecord {
159+ if (!this.exists(taskId)) throw new Error(`Task ${taskId} not found`);
160+ return JSON.parse(readFileSync(this.filePath(taskId), "utf8")) as TaskRecord;
161+ }
157162
158-class TeammateManager {
159- private configPath: string;
160- private config: TeamConfig;
163+ private save(task: TaskRecord) {
164+ writeFileSync(this.filePath(task.id), `${JSON.stringify(task, null, 2)}\n`, "utf8");
165+ }
161166
162- constructor(private teamDir: string) {
163- mkdirSync(teamDir, { recursive: true });
164- this.configPath = resolve(teamDir, "config.json");
165- this.config = this.loadConfig();
167+ create(subject: string, description = "") {
168+ const task: TaskRecord = {
169+ id: this.nextId,
170+ subject,
171+ description,
172+ status: "pending",
173+ owner: "",
174+ worktree: "",
175+ blockedBy: [],
176+ created_at: Date.now() / 1000,
177+ updated_at: Date.now() / 1000,
178+ };
179+ this.save(task);
180+ this.nextId += 1;
181+ return JSON.stringify(task, null, 2);
166182 }
167183
168- private loadConfig(): TeamConfig {
169- if (existsSync(this.configPath)) return JSON.parse(readFileSync(this.configPath, "utf8")) as TeamConfig;
170- return { team_name: "default", members: [] };
184+ get(taskId: number) {
185+ return JSON.stringify(this.load(taskId), null, 2);
171186 }
172187
173- private saveConfig() {
174- writeFileSync(this.configPath, `${JSON.stringify(this.config, null, 2)}\n`, "utf8");
188+ update(taskId: number, status?: string, owner?: string) {
189+ const task = this.load(taskId);
190+ if (status) task.status = status as TaskStatus;
191+ if (typeof owner === "string") task.owner = owner;
192+ task.updated_at = Date.now() / 1000;
193+ this.save(task);
194+ return JSON.stringify(task, null, 2);
175195 }
176196
177- private findMember(name: string) {
178- return this.config.members.find((member) => member.name === name);
197+ bindWorktree(taskId: number, worktree: string, owner = "") {
198+ const task = this.load(taskId);
199+ task.worktree = worktree;
200+ if (owner) task.owner = owner;
201+ if (task.status === "pending") task.status = "in_progress";
202+ task.updated_at = Date.now() / 1000;
203+ this.save(task);
204+ return JSON.stringify(task, null, 2);
179205 }
180206
181- private setStatus(name: string, status: TeamMember["status"]) {
182- const member = this.findMember(name);
183- if (member) {
184- member.status = status;
185- this.saveConfig();
186- }
207+ unbindWorktree(taskId: number) {
208+ const task = this.load(taskId);
209+ task.worktree = "";
210+ task.updated_at = Date.now() / 1000;
211+ this.save(task);
212+ return JSON.stringify(task, null, 2);
187213 }
214+ listAll() {
215+ const tasks = readdirSync(this.tasksDir, { withFileTypes: true })
216+ .filter((entry) => entry.isFile() && /^task_\d+\.json$/.test(entry.name))
217+ .map((entry) => JSON.parse(readFileSync(resolve(this.tasksDir, entry.name), "utf8")) as TaskRecord)
218+ .sort((a, b) => a.id - b.id);
219+ if (!tasks.length) return "No tasks.";
220+ return tasks
221+ .map((task) => {
222+ const marker = { pending: "[ ]", in_progress: "[>]", completed: "[x]" }[task.status] ?? "[?]";
223+ const owner = task.owner ? ` owner=${task.owner}` : "";
224+ const wt = task.worktree ? ` wt=${task.worktree}` : "";
225+ return `${marker} #${task.id}: ${task.subject}${owner}${wt}`;
226+ })
227+ .join("\n");
228+ }
229+}
188230
189- spawn(name: string, role: string, prompt: string) {
190- let member = this.findMember(name);
191- if (member) {
192- if (!["idle", "shutdown"].includes(member.status)) return `Error: '${name}' is currently ${member.status}`;
193- member.status = "working";
194- member.role = role;
195- } else {
196- member = { name, role, status: "working" };
197- this.config.members.push(member);
198- }
199- this.saveConfig();
200- void this.loop(name, role, prompt);
201- return `Spawned '${name}' (role: ${role})`;
231+class WorktreeManager {
232+ constructor(private repoRoot: string, private tasks: TaskManager, private events: EventBus) {
233+ mkdirSync(WORKTREES_DIR, { recursive: true });
234+ if (!existsSync(INDEX_PATH)) writeFileSync(INDEX_PATH, `${JSON.stringify({ worktrees: [] }, null, 2)}\n`, "utf8");
202235 }
203236
204- private async loop(name: string, role: string, prompt: string) {
205- const teamName = this.config.team_name;
206- const sysPrompt = buildSystemPrompt(`You are '${name}', role: ${role}, team: ${teamName}, at ${WORKDIR}. Use idle when you have no more work. You will auto-claim new tasks.`);
207- const messages: Message[] = [{ role: "user", content: prompt }];
208- while (true) {
209- let idleRequested = false;
210- for (let attempt = 0; attempt < 50; attempt += 1) {
211- for (const msg of BUS.readInbox(name)) {
212- if (msg.type === "shutdown_request") {
213- this.setStatus(name, "shutdown");
214- return;
215- }
216- messages.push({ role: "user", content: JSON.stringify(msg) });
217- }
218- const response = await client.messages.create({
219- model: MODEL,
220- system: sysPrompt,
221- messages: messages as Anthropic.Messages.MessageParam[],
222- tools: this.tools() as Anthropic.Messages.Tool[],
223- max_tokens: 8000,
224- }).catch(() => null);
225- if (!response) {
226- this.setStatus(name, "idle");
227- return;
228- }
229- messages.push({ role: "assistant", content: response.content as Array<ToolUseBlock | TextBlock> });
230- if (response.stop_reason !== "tool_use") break;
231- const results: ToolResultBlock[] = [];
232- for (const block of response.content) {
233- if (block.type !== "tool_use") continue;
234- let output = "";
235- if (block.name === "idle") {
236- idleRequested = true;
237- output = "Entering idle phase. Will poll for new tasks.";
238- } else {
239- output = this.exec(name, block.name, block.input as Record<string, unknown>);
240- }
241- console.log(` [${name}] ${block.name}: ${output.slice(0, 120)}`);
242- results.push({ type: "tool_result", tool_use_id: block.id, content: output });
243- }
244- messages.push({ role: "user", content: results });
245- if (idleRequested) break;
246- }
237+ get gitAvailable() {
238+ const result = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: this.repoRoot, encoding: "utf8", timeout: 10_000 });
239+ return result.status === 0;
240+ }
247241
248- this.setStatus(name, "idle");
249- let resume = false;
250- const start = Date.now();
251- while (Date.now() - start < IDLE_TIMEOUT) {
252- await sleep(POLL_INTERVAL);
253- const inbox = BUS.readInbox(name);
254- if (inbox.length) {
255- for (const msg of inbox) {
256- if (msg.type === "shutdown_request") {
257- this.setStatus(name, "shutdown");
258- return;
259- }
260- messages.push({ role: "user", content: JSON.stringify(msg) });
261- }
262- resume = true;
263- break;
264- }
265- const unclaimed = scanUnclaimedTasks();
266- if (unclaimed.length) {
267- const task = unclaimed[0];
268- claimTask(task.id, name);
269- if (messages.length <= 3) {
270- messages.unshift({ role: "assistant", content: `I am ${name}. Continuing.` });
271- messages.unshift(makeIdentityBlock(name, role, teamName));
272- }
273- messages.push({ role: "user", content: `<auto-claimed>Task #${task.id}: ${task.subject}\n${task.description ?? ""}</auto-claimed>` });
274- messages.push({ role: "assistant", content: `Claimed task #${task.id}. Working on it.` });
275- resume = true;
276- break;
277- }
278- }
242+ private loadIndex(): { worktrees: WorktreeRecord[] } {
243+ return JSON.parse(readFileSync(INDEX_PATH, "utf8")) as { worktrees: WorktreeRecord[] };
244+ }
279245
280- if (!resume) {
281- this.setStatus(name, "shutdown");
282- return;
283- }
284- this.setStatus(name, "working");
285- }
246+ private saveIndex(index: { worktrees: WorktreeRecord[] }) {
247+ writeFileSync(INDEX_PATH, `${JSON.stringify(index, null, 2)}\n`, "utf8");
286248 }
287249
288- private tools() {
289- return [
290- { name: "bash", description: shellToolDescription(), input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } },
291- { name: "read_file", description: "Read file contents.", input_schema: { type: "object", properties: { path: { type: "string" }, limit: { type: "integer" } }, required: ["path"] } },
292- { name: "write_file", description: "Write content to file.", input_schema: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] } },
293- { 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"] } },
294- { 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"] } },
295- { name: "read_inbox", description: "Read and drain your inbox.", input_schema: { type: "object", properties: {} } },
296- { 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"] } },
297- { name: "plan_approval", description: "Submit a plan for lead approval.", input_schema: { type: "object", properties: { plan: { type: "string" } }, required: ["plan"] } },
298- { name: "idle", description: "Signal that you have no more work.", input_schema: { type: "object", properties: {} } },
299- { name: "claim_task", description: "Claim a task by ID.", input_schema: { type: "object", properties: { task_id: { type: "integer" } }, required: ["task_id"] } },
300- ];
250+ private find(name: string) {
251+ return this.loadIndex().worktrees.find((worktree) => worktree.name === name);
301252 }
302253
303- private exec(sender: string, toolName: string, input: Record<string, unknown>) {
304- if (toolName === "bash") return runBash(String(input.command ?? ""));
305- if (toolName === "read_file") return runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined);
306- if (toolName === "write_file") return runWrite(String(input.path ?? ""), String(input.content ?? ""));
307- if (toolName === "edit_file") return runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? ""));
308- if (toolName === "send_message") return BUS.send(sender, String(input.to ?? ""), String(input.content ?? ""), (input.msg_type as MessageType | undefined) ?? "message");
309- if (toolName === "read_inbox") return JSON.stringify(BUS.readInbox(sender), null, 2);
310- if (toolName === "shutdown_response") {
311- const requestId = String(input.request_id ?? "");
312- shutdownRequests[requestId] = { ...(shutdownRequests[requestId] ?? { target: sender }), status: input.approve ? "approved" : "rejected" };
313- BUS.send(sender, "lead", String(input.reason ?? ""), "shutdown_response", { request_id: requestId, approve: Boolean(input.approve) });
314- return `Shutdown ${input.approve ? "approved" : "rejected"}`;
254+ create(name: string, taskId?: number, baseRef = "HEAD") {
255+ if (!this.gitAvailable) throw new Error("Not in a git repository. worktree tools require git.");
256+ if (this.find(name)) throw new Error(`Worktree '${name}' already exists`);
257+ if (taskId && !this.tasks.exists(taskId)) throw new Error(`Task ${taskId} not found`);
258+ const path = resolve(WORKTREES_DIR, name);
259+ const branch = `wt/${name}`;
260+ this.events.emit("worktree.create.before", taskId ? { id: taskId } : {}, { name, base_ref: baseRef });
261+ const result = spawnSync("git", ["worktree", "add", "-b", branch, path, baseRef], { cwd: this.repoRoot, encoding: "utf8", timeout: 120_000 });
262+ if (result.status !== 0) {
263+ const message = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() || "git worktree add failed";
264+ this.events.emit("worktree.create.failed", taskId ? { id: taskId } : {}, { name, base_ref: baseRef }, message);
265+ throw new Error(message);
315266 }
316- if (toolName === "plan_approval") {
317- const requestId = randomUUID().slice(0, 8);
318- planRequests[requestId] = { from: sender, plan: String(input.plan ?? ""), status: "pending" };
319- BUS.send(sender, "lead", String(input.plan ?? ""), "plan_approval_response", { request_id: requestId, plan: String(input.plan ?? "") });
320- return `Plan submitted (request_id=${requestId}). Waiting for lead approval.`;
321- }
322- if (toolName === "claim_task") return claimTask(Number(input.task_id ?? 0), sender);
323- return `Unknown tool: ${toolName}`;
267+ const entry: WorktreeRecord = { name, path, branch, task_id: taskId, status: "active", created_at: Date.now() / 1000 };
268+ const index = this.loadIndex();
269+ index.worktrees.push(entry);
270+ this.saveIndex(index);
271+ if (taskId) this.tasks.bindWorktree(taskId, name);
272+ this.events.emit("worktree.create.after", taskId ? { id: taskId } : {}, { name, path, branch, status: "active" });
273+ return JSON.stringify(entry, null, 2);
324274 }
275+
325276 listAll() {
326- if (!this.config.members.length) return "No teammates.";
327- return [`Team: ${this.config.team_name}`, ...this.config.members.map((m) => ` ${m.name} (${m.role}): ${m.status}`)].join("\n");
277+ const worktrees = this.loadIndex().worktrees;
278+ if (!worktrees.length) return "No worktrees in index.";
279+ return worktrees.map((wt) => `[${wt.status}] ${wt.name} -> ${wt.path} (${wt.branch})${wt.task_id ? ` task=${wt.task_id}` : ""}`).join("\n");
328280 }
329281
330- memberNames() {
331- return this.config.members.map((m) => m.name);
282+ status(name: string) {
283+ const wt = this.find(name);
284+ if (!wt) return `Error: Unknown worktree '${name}'`;
285+ return runCommand("git status --short --branch", wt.path, 60_000);
332286 }
333-}
334287
335-const TEAM = new TeammateManager(TEAM_DIR);
288+ run(name: string, command: string) {
289+ const wt = this.find(name);
290+ if (!wt) return `Error: Unknown worktree '${name}'`;
291+ return runCommand(command, wt.path, 300_000);
292+ }
336293
337-function handleShutdownRequest(teammate: string) {
338- const requestId = randomUUID().slice(0, 8);
339- shutdownRequests[requestId] = { target: teammate, status: "pending" };
340- BUS.send("lead", teammate, "Please shut down gracefully.", "shutdown_request", { request_id: requestId });
341- return `Shutdown request ${requestId} sent to '${teammate}' (status: pending)`;
342-}
294+ keep(name: string) {
295+ const index = this.loadIndex();
296+ const worktree = index.worktrees.find((item) => item.name === name);
297+ if (!worktree) return `Error: Unknown worktree '${name}'`;
298+ worktree.status = "kept";
299+ worktree.kept_at = Date.now() / 1000;
300+ this.saveIndex(index);
301+ this.events.emit("worktree.keep", worktree.task_id ? { id: worktree.task_id } : {}, { name, path: worktree.path, status: "kept" });
302+ return JSON.stringify(worktree, null, 2);
303+ }
343304
344-function handlePlanReview(requestId: string, approve: boolean, feedback = "") {
345- const request = planRequests[requestId];
346- if (!request) return `Error: Unknown plan request_id '${requestId}'`;
347- request.status = approve ? "approved" : "rejected";
348- BUS.send("lead", request.from, feedback, "plan_approval_response", { request_id: requestId, approve, feedback });
349- return `Plan ${request.status} for '${request.from}'`;
305+ remove(name: string, force = false, completeTask = false) {
306+ const worktree = this.find(name);
307+ if (!worktree) return `Error: Unknown worktree '${name}'`;
308+ this.events.emit("worktree.remove.before", worktree.task_id ? { id: worktree.task_id } : {}, { name, path: worktree.path });
309+ const args = ["worktree", "remove", ...(force ? ["--force"] : []), worktree.path];
310+ const result = spawnSync("git", args, { cwd: this.repoRoot, encoding: "utf8", timeout: 120_000 });
311+ if (result.status !== 0) {
312+ const message = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim() || "git worktree remove failed";
313+ this.events.emit("worktree.remove.failed", worktree.task_id ? { id: worktree.task_id } : {}, { name, path: worktree.path }, message);
314+ throw new Error(message);
315+ }
316+ if (completeTask && worktree.task_id) {
317+ const before = JSON.parse(this.tasks.get(worktree.task_id)) as TaskRecord;
318+ this.tasks.update(worktree.task_id, "completed");
319+ this.tasks.unbindWorktree(worktree.task_id);
320+ this.events.emit("task.completed", { id: worktree.task_id, subject: before.subject, status: "completed" }, { name });
321+ }
322+ const index = this.loadIndex();
323+ const record = index.worktrees.find((item) => item.name === name);
324+ if (record) {
325+ record.status = "removed";
326+ record.removed_at = Date.now() / 1000;
327+ }
328+ this.saveIndex(index);
329+ this.events.emit("worktree.remove.after", worktree.task_id ? { id: worktree.task_id } : {}, { name, path: worktree.path, status: "removed" });
330+ return `Removed worktree '${name}'`;
331+ }
350332}
351333
334+const EVENTS = new EventBus(EVENTS_PATH);
335+const TASKS = new TaskManager(TASKS_DIR);
336+const WORKTREES = new WorktreeManager(REPO_ROOT, TASKS, EVENTS);
337+
352338const TOOL_HANDLERS: Record<ToolName, (input: Record<string, unknown>) => string> = {
353339 bash: (input) => runBash(String(input.command ?? "")),
354340 read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined),
355341 write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")),
356342 edit_file: (input) => runEdit(String(input.path ?? ""), String(input.old_text ?? ""), String(input.new_text ?? "")),
357- spawn_teammate: (input) => TEAM.spawn(String(input.name ?? ""), String(input.role ?? ""), String(input.prompt ?? "")),
358- list_teammates: () => TEAM.listAll(),
359- send_message: (input) => BUS.send("lead", String(input.to ?? ""), String(input.content ?? ""), (input.msg_type as MessageType | undefined) ?? "message"),
360- read_inbox: () => JSON.stringify(BUS.readInbox("lead"), null, 2),
361- broadcast: (input) => BUS.broadcast("lead", String(input.content ?? ""), TEAM.memberNames()),
362- shutdown_request: (input) => handleShutdownRequest(String(input.teammate ?? "")),
363- shutdown_response: (input) => JSON.stringify(shutdownRequests[String(input.request_id ?? "")] ?? { error: "not found" }),
364- plan_approval: (input) => handlePlanReview(String(input.request_id ?? ""), Boolean(input.approve), String(input.feedback ?? "")),
365- idle: () => "Lead does not idle.",
366- claim_task: (input) => claimTask(Number(input.task_id ?? 0), "lead"),
343+ task_create: (input) => TASKS.create(String(input.subject ?? ""), String(input.description ?? "")),
344+ task_list: () => TASKS.listAll(),
345+ task_get: (input) => TASKS.get(Number(input.task_id ?? 0)),
346+ task_update: (input) => TASKS.update(Number(input.task_id ?? 0), typeof input.status === "string" ? input.status : undefined, typeof input.owner === "string" ? input.owner : undefined),
347+ task_bind_worktree: (input) => TASKS.bindWorktree(Number(input.task_id ?? 0), String(input.worktree ?? ""), String(input.owner ?? "")),
348+ worktree_create: (input) => WORKTREES.create(String(input.name ?? ""), typeof input.task_id === "number" ? input.task_id : undefined, String(input.base_ref ?? "HEAD")),
349+ worktree_list: () => WORKTREES.listAll(),
350+ worktree_status: (input) => WORKTREES.status(String(input.name ?? "")),
351+ worktree_run: (input) => WORKTREES.run(String(input.name ?? ""), String(input.command ?? "")),
352+ worktree_keep: (input) => WORKTREES.keep(String(input.name ?? "")),
353+ worktree_remove: (input) => WORKTREES.remove(String(input.name ?? ""), Boolean(input.force), Boolean(input.complete_task)),
354+ worktree_events: (input) => EVENTS.listRecent(Number(input.limit ?? 20)),
367355};
368356
369357const TOOLS = [
370358 { name: "bash", description: shellToolDescription(), input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } },
371359 { name: "read_file", description: "Read file contents.", input_schema: { type: "object", properties: { path: { type: "string" }, limit: { type: "integer" } }, required: ["path"] } },
372360 { name: "write_file", description: "Write content to file.", input_schema: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] } },
373361 { 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"] } },
374- { name: "spawn_teammate", description: "Spawn an autonomous teammate.", input_schema: { type: "object", properties: { name: { type: "string" }, role: { type: "string" }, prompt: { type: "string" } }, required: ["name", "role", "prompt"] } },
375- { name: "list_teammates", description: "List all teammates.", input_schema: { type: "object", properties: {} } },
376- { 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"] } },
377- { name: "read_inbox", description: "Read and drain the lead inbox.", input_schema: { type: "object", properties: {} } },
378- { name: "broadcast", description: "Send a message to all teammates.", input_schema: { type: "object", properties: { content: { type: "string" } }, required: ["content"] } },
379- { name: "shutdown_request", description: "Request a teammate to shut down.", input_schema: { type: "object", properties: { teammate: { type: "string" } }, required: ["teammate"] } },
380- { name: "shutdown_response", description: "Check shutdown request status.", input_schema: { type: "object", properties: { request_id: { type: "string" } }, required: ["request_id"] } },
381- { 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"] } },
382- { name: "idle", description: "Enter idle state.", input_schema: { type: "object", properties: {} } },
383- { name: "claim_task", description: "Claim a task from the board by ID.", input_schema: { type: "object", properties: { task_id: { type: "integer" } }, required: ["task_id"] } },
362+ { name: "task_create", description: "Create a new task on the shared task board.", input_schema: { type: "object", properties: { subject: { type: "string" }, description: { type: "string" } }, required: ["subject"] } },
363+ { name: "task_list", description: "List all tasks.", input_schema: { type: "object", properties: {} } },
364+ { name: "task_get", description: "Get task details by ID.", input_schema: { type: "object", properties: { task_id: { type: "integer" } }, required: ["task_id"] } },
365+ { name: "task_update", description: "Update task status or owner.", input_schema: { type: "object", properties: { task_id: { type: "integer" }, status: { type: "string", enum: ["pending", "in_progress", "completed"] }, owner: { type: "string" } }, required: ["task_id"] } },
366+ { name: "task_bind_worktree", description: "Bind a task to a worktree name.", input_schema: { type: "object", properties: { task_id: { type: "integer" }, worktree: { type: "string" }, owner: { type: "string" } }, required: ["task_id", "worktree"] } },
367+ { name: "worktree_create", description: "Create a git worktree and optionally bind it to a task.", input_schema: { type: "object", properties: { name: { type: "string" }, task_id: { type: "integer" }, base_ref: { type: "string" } }, required: ["name"] } },
368+ { name: "worktree_list", description: "List worktrees tracked in index.", input_schema: { type: "object", properties: {} } },
369+ { name: "worktree_status", description: "Show git status for one worktree.", input_schema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] } },
370+ { name: "worktree_run", description: shellToolDescription("a named worktree"), input_schema: { type: "object", properties: { name: { type: "string" }, command: { type: "string" } }, required: ["name", "command"] } },
371+ { name: "worktree_remove", description: "Remove a worktree and optionally mark its task completed.", input_schema: { type: "object", properties: { name: { type: "string" }, force: { type: "boolean" }, complete_task: { type: "boolean" } }, required: ["name"] } },
372+ { name: "worktree_keep", description: "Mark a worktree as kept without removing it.", input_schema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] } },
373+ { name: "worktree_events", description: "List recent worktree/task lifecycle events.", input_schema: { type: "object", properties: { limit: { type: "integer" } } } },
384374];
385375
386376function assistantText(content: Array<ToolUseBlock | TextBlock>) {
387377 return content.filter((block): block is TextBlock => block.type === "text").map((block) => block.text).join("\n");
388378}
389379
390380export async function agentLoop(messages: Message[]) {
391381 while (true) {
392- const inbox = BUS.readInbox("lead");
393- if (inbox.length) {
394- messages.push({ role: "user", content: `<inbox>${JSON.stringify(inbox, null, 2)}</inbox>` });
395- messages.push({ role: "assistant", content: "Noted inbox messages." });
396- }
397382 const response = await client.messages.create({
398383 model: MODEL,
399384 system: SYSTEM,
400385 messages: messages as Anthropic.Messages.MessageParam[],
401386 tools: TOOLS as Anthropic.Messages.Tool[],
402387 max_tokens: 8000,
403388 });
404389 messages.push({ role: "assistant", content: response.content as Array<ToolUseBlock | TextBlock> });
405390 if (response.stop_reason !== "tool_use") return;
406391 const results: ToolResultBlock[] = [];
407392 for (const block of response.content) {
408393 if (block.type !== "tool_use") continue;
409394 const handler = TOOL_HANDLERS[block.name as ToolName];
410- const output = handler ? handler(block.input as Record<string, unknown>) : `Unknown tool: ${block.name}`;
395+ let output = "";
396+ try {
397+ output = handler ? handler(block.input as Record<string, unknown>) : `Unknown tool: ${block.name}`;
398+ } catch (error) {
399+ output = `Error: ${error instanceof Error ? error.message : String(error)}`;
400+ }
411401 console.log(`> ${block.name}: ${output.slice(0, 200)}`);
412402 results.push({ type: "tool_result", tool_use_id: block.id, content: output });
413403 }
414404 messages.push({ role: "user", content: results });
415405 }
416406}
417407
418408async function main() {
409+ console.log(`Repo root for s12: ${REPO_ROOT}`);
410+ if (!WORKTREES.gitAvailable) console.log("Note: Not in a git repo. worktree_* tools will return errors.");
419411 const rl = createInterface({ input: process.stdin, output: process.stdout });
420412 const history: Message[] = [];
421413 while (true) {
422414 let query = "";
423415 try {
424- query = await rl.question("\x1b[36ms11 >> \x1b[0m");
416+ query = await rl.question("\x1b[36ms12 >> \x1b[0m");
425417 } catch (error) {
426418 if (
427419 error instanceof Error &&
428420 (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError")
429421 ) {
430422 break;
431423 }
432424 throw error;
433425 }
434426 if (!query.trim() || ["q", "exit"].includes(query.trim().toLowerCase())) break;
435- if (query.trim() === "/team") { console.log(TEAM.listAll()); continue; }
436- if (query.trim() === "/inbox") { console.log(JSON.stringify(BUS.readInbox("lead"), null, 2)); continue; }
437- if (query.trim() === "/tasks") {
438- mkdirSync(TASKS_DIR, { recursive: true });
439- for (const task of scanUnclaimedTasks()) console.log(` [ ] #${task.id}: ${task.subject}`);
440- continue;
441- }
442427 history.push({ role: "user", content: query });
443428 await agentLoop(history);
444429 const last = history[history.length - 1]?.content;
445430 if (Array.isArray(last)) {
446431 const text = assistantText(last as Array<ToolUseBlock | TextBlock>);
447432 if (text) console.log(text);
448433 }
449434 console.log();
450435 }
451436 rl.close();
452437}
453438
454439void main();