Back to Worktree + Task Isolation
Autonomous Agents → Worktree + 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
collaborationWorktree + 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
collaborationSource Code Diff
s11 (s11_autonomous_agents.ts) -> s12 (s12_worktree_task_isolation.ts)
| 1 | 1 | #!/usr/bin/env node | |
| 2 | 2 | /** | |
| 3 | - | * s11_autonomous_agents.ts - Autonomous Agents | |
| 3 | + | * s12_worktree_task_isolation.ts - Worktree + Task Isolation | |
| 4 | 4 | * | |
| 5 | - | * Idle polling + auto-claim task board + identity re-injection. | |
| 5 | + | * Task board as control plane, git worktrees as execution plane. | |
| 6 | 6 | */ | |
| 7 | 7 | ||
| 8 | - | import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; | |
| 9 | 8 | import { spawnSync } from "node:child_process"; | |
| 9 | + | import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; | |
| 10 | 10 | import { resolve } from "node:path"; | |
| 11 | 11 | import process from "node:process"; | |
| 12 | - | import { randomUUID } from "node:crypto"; | |
| 13 | 12 | import { createInterface } from "node:readline/promises"; | |
| 14 | 13 | import type Anthropic from "@anthropic-ai/sdk"; | |
| 15 | 14 | import "dotenv/config"; | |
| 16 | 15 | import { buildSystemPrompt, createAnthropicClient, resolveModel, shellToolDescription } from "./shared"; | |
| 17 | 16 | ||
| 18 | - | type MessageType = "message" | "broadcast" | "shutdown_request" | "shutdown_response" | "plan_approval_response"; | |
| 17 | + | type TaskStatus = "pending" | "in_progress" | "completed"; | |
| 19 | 18 | type ToolName = | |
| 20 | 19 | | "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"; | |
| 23 | 22 | type ToolUseBlock = { id: string; type: "tool_use"; name: ToolName; input: Record<string, unknown> }; | |
| 24 | 23 | type TextBlock = { type: "text"; text: string }; | |
| 25 | 24 | type ToolResultBlock = { type: "tool_result"; tool_use_id: string; content: string }; | |
| 26 | 25 | type 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 | + | }; | |
| 30 | 47 | ||
| 31 | 48 | const WORKDIR = process.cwd(); | |
| 32 | 49 | const 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(); | |
| 42 | 50 | ||
| 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; | |
| 47 | 55 | } | |
| 48 | 56 | ||
| 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 | + | ||
| 49 | 67 | function safePath(relativePath: string) { | |
| 50 | 68 | const filePath = resolve(WORKDIR, relativePath); | |
| 51 | 69 | const normalizedWorkdir = `${WORKDIR}${process.platform === "win32" ? "\\" : "/"}`; | |
| 52 | 70 | if (filePath !== WORKDIR && !filePath.startsWith(normalizedWorkdir)) throw new Error(`Path escapes workspace: ${relativePath}`); | |
| 53 | 71 | return filePath; | |
| 54 | 72 | } | |
| 55 | 73 | ||
| 56 | - | function runBash(command: string): string { | |
| 74 | + | function runCommand(command: string, cwd: string, timeout = 120_000) { | |
| 57 | 75 | const dangerous = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]; | |
| 58 | 76 | if (dangerous.some((item) => command.includes(item))) return "Error: Dangerous command blocked"; | |
| 59 | 77 | const shell = process.platform === "win32" ? "cmd.exe" : "/bin/sh"; | |
| 60 | 78 | 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)`; | |
| 63 | 81 | const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim(); | |
| 64 | 82 | return output.slice(0, 50_000) || "(no output)"; | |
| 65 | 83 | } | |
| 66 | 84 | ||
| 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) { | |
| 68 | 87 | try { | |
| 69 | 88 | let lines = readFileSync(safePath(path), "utf8").split(/\r?\n/); | |
| 70 | 89 | if (limit && limit < lines.length) lines = lines.slice(0, limit).concat(`... (${lines.length - limit} more)`); | |
| 71 | 90 | return lines.join("\n").slice(0, 50_000); | |
| 72 | 91 | } catch (error) { | |
| 73 | 92 | return `Error: ${error instanceof Error ? error.message : String(error)}`; | |
| 74 | 93 | } | |
| 75 | 94 | } | |
| 76 | - | ||
| 77 | - | function runWrite(path: string, content: string): string { | |
| 95 | + | function runWrite(path: string, content: string) { | |
| 78 | 96 | try { | |
| 79 | 97 | const filePath = safePath(path); | |
| 80 | 98 | mkdirSync(resolve(filePath, ".."), { recursive: true }); | |
| 81 | 99 | writeFileSync(filePath, content, "utf8"); | |
| 82 | 100 | return `Wrote ${content.length} bytes`; | |
| 83 | 101 | } catch (error) { | |
| 84 | 102 | return `Error: ${error instanceof Error ? error.message : String(error)}`; | |
| 85 | 103 | } | |
| 86 | 104 | } | |
| 87 | - | ||
| 88 | - | function runEdit(path: string, oldText: string, newText: string): string { | |
| 105 | + | function runEdit(path: string, oldText: string, newText: string) { | |
| 89 | 106 | try { | |
| 90 | 107 | const filePath = safePath(path); | |
| 91 | 108 | const content = readFileSync(filePath, "utf8"); | |
| 92 | 109 | if (!content.includes(oldText)) return `Error: Text not found in ${path}`; | |
| 93 | 110 | writeFileSync(filePath, content.replace(oldText, newText), "utf8"); | |
| 94 | 111 | return `Edited ${path}`; | |
| 95 | 112 | } catch (error) { | |
| 96 | 113 | return `Error: ${error instanceof Error ? error.message : String(error)}`; | |
| 97 | 114 | } | |
| 98 | 115 | } | |
| 99 | 116 | ||
| 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"); | |
| 107 | 121 | } | |
| 108 | - | return tasks.sort((a, b) => a.id - b.id); | |
| 109 | - | } | |
| 110 | 122 | ||
| 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 | + | } | |
| 120 | 127 | ||
| 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 | + | } | |
| 123 | 133 | } | |
| 124 | 134 | ||
| 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; | |
| 128 | 141 | } | |
| 129 | 142 | ||
| 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); | |
| 135 | 148 | } | |
| 136 | 149 | ||
| 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`); | |
| 143 | 152 | } | |
| 144 | 153 | ||
| 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)); | |
| 153 | 156 | } | |
| 154 | - | } | |
| 155 | 157 | ||
| 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 | + | } | |
| 157 | 162 | ||
| 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 | + | } | |
| 161 | 166 | ||
| 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); | |
| 166 | 182 | } | |
| 167 | 183 | ||
| 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); | |
| 171 | 186 | } | |
| 172 | 187 | ||
| 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); | |
| 175 | 195 | } | |
| 176 | 196 | ||
| 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); | |
| 179 | 205 | } | |
| 180 | 206 | ||
| 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); | |
| 187 | 213 | } | |
| 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 | + | } | |
| 188 | 230 | ||
| 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"); | |
| 202 | 235 | } | |
| 203 | 236 | ||
| 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 | + | } | |
| 247 | 241 | ||
| 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 | + | } | |
| 279 | 245 | ||
| 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"); | |
| 286 | 248 | } | |
| 287 | 249 | ||
| 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); | |
| 301 | 252 | } | |
| 302 | 253 | ||
| 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); | |
| 315 | 266 | } | |
| 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); | |
| 324 | 274 | } | |
| 275 | + | ||
| 325 | 276 | 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"); | |
| 328 | 280 | } | |
| 329 | 281 | ||
| 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); | |
| 332 | 286 | } | |
| 333 | - | } | |
| 334 | 287 | ||
| 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 | + | } | |
| 336 | 293 | ||
| 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 | + | } | |
| 343 | 304 | ||
| 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 | + | } | |
| 350 | 332 | } | |
| 351 | 333 | ||
| 334 | + | const EVENTS = new EventBus(EVENTS_PATH); | |
| 335 | + | const TASKS = new TaskManager(TASKS_DIR); | |
| 336 | + | const WORKTREES = new WorktreeManager(REPO_ROOT, TASKS, EVENTS); | |
| 337 | + | ||
| 352 | 338 | const TOOL_HANDLERS: Record<ToolName, (input: Record<string, unknown>) => string> = { | |
| 353 | 339 | bash: (input) => runBash(String(input.command ?? "")), | |
| 354 | 340 | read_file: (input) => runRead(String(input.path ?? ""), Number(input.limit ?? 0) || undefined), | |
| 355 | 341 | write_file: (input) => runWrite(String(input.path ?? ""), String(input.content ?? "")), | |
| 356 | 342 | 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)), | |
| 367 | 355 | }; | |
| 368 | 356 | ||
| 369 | 357 | const TOOLS = [ | |
| 370 | 358 | { name: "bash", description: shellToolDescription(), input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] } }, | |
| 371 | 359 | { name: "read_file", description: "Read file contents.", input_schema: { type: "object", properties: { path: { type: "string" }, limit: { type: "integer" } }, required: ["path"] } }, | |
| 372 | 360 | { name: "write_file", description: "Write content to file.", input_schema: { type: "object", properties: { path: { type: "string" }, content: { type: "string" } }, required: ["path", "content"] } }, | |
| 373 | 361 | { 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" } } } }, | |
| 384 | 374 | ]; | |
| 385 | 375 | ||
| 386 | 376 | function assistantText(content: Array<ToolUseBlock | TextBlock>) { | |
| 387 | 377 | return content.filter((block): block is TextBlock => block.type === "text").map((block) => block.text).join("\n"); | |
| 388 | 378 | } | |
| 389 | 379 | ||
| 390 | 380 | export async function agentLoop(messages: Message[]) { | |
| 391 | 381 | 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 | - | } | |
| 397 | 382 | const response = await client.messages.create({ | |
| 398 | 383 | model: MODEL, | |
| 399 | 384 | system: SYSTEM, | |
| 400 | 385 | messages: messages as Anthropic.Messages.MessageParam[], | |
| 401 | 386 | tools: TOOLS as Anthropic.Messages.Tool[], | |
| 402 | 387 | max_tokens: 8000, | |
| 403 | 388 | }); | |
| 404 | 389 | messages.push({ role: "assistant", content: response.content as Array<ToolUseBlock | TextBlock> }); | |
| 405 | 390 | if (response.stop_reason !== "tool_use") return; | |
| 406 | 391 | const results: ToolResultBlock[] = []; | |
| 407 | 392 | for (const block of response.content) { | |
| 408 | 393 | if (block.type !== "tool_use") continue; | |
| 409 | 394 | 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 | + | } | |
| 411 | 401 | console.log(`> ${block.name}: ${output.slice(0, 200)}`); | |
| 412 | 402 | results.push({ type: "tool_result", tool_use_id: block.id, content: output }); | |
| 413 | 403 | } | |
| 414 | 404 | messages.push({ role: "user", content: results }); | |
| 415 | 405 | } | |
| 416 | 406 | } | |
| 417 | 407 | ||
| 418 | 408 | async 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."); | |
| 419 | 411 | const rl = createInterface({ input: process.stdin, output: process.stdout }); | |
| 420 | 412 | const history: Message[] = []; | |
| 421 | 413 | while (true) { | |
| 422 | 414 | let query = ""; | |
| 423 | 415 | try { | |
| 424 | - | query = await rl.question("\x1b[36ms11 >> \x1b[0m"); | |
| 416 | + | query = await rl.question("\x1b[36ms12 >> \x1b[0m"); | |
| 425 | 417 | } catch (error) { | |
| 426 | 418 | if ( | |
| 427 | 419 | error instanceof Error && | |
| 428 | 420 | (("code" in error && error.code === "ERR_USE_AFTER_CLOSE") || error.name === "AbortError") | |
| 429 | 421 | ) { | |
| 430 | 422 | break; | |
| 431 | 423 | } | |
| 432 | 424 | throw error; | |
| 433 | 425 | } | |
| 434 | 426 | 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 | - | } | |
| 442 | 427 | history.push({ role: "user", content: query }); | |
| 443 | 428 | await agentLoop(history); | |
| 444 | 429 | const last = history[history.length - 1]?.content; | |
| 445 | 430 | if (Array.isArray(last)) { | |
| 446 | 431 | const text = assistantText(last as Array<ToolUseBlock | TextBlock>); | |
| 447 | 432 | if (text) console.log(text); | |
| 448 | 433 | } | |
| 449 | 434 | console.log(); | |
| 450 | 435 | } | |
| 451 | 436 | rl.close(); | |
| 452 | 437 | } | |
| 453 | 438 | ||
| 454 | 439 | void main(); |