@hueyexe/opencode-ensemble 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -163,9 +163,10 @@ Teammate messages arrive in the lead's session as `[Team message from alice]: ..
163
163
 
164
164
  - **SQLite** (`bun:sqlite`, WAL mode) for teams, members, tasks, and messages
165
165
  - **promptAsync** for message delivery: injects a message and starts the prompt loop in one call
166
+ - **Git worktree isolation**: each teammate gets their own worktree by default, so multiple agents can edit files without conflicts. The lead merges branches after cleanup. Opt out with `worktree: false` for read-only agents.
166
167
  - **Sub-agent isolation**: teammates' sub-agents can't use team tools (parent chain tracking, max depth 10)
167
- - **Crash recovery**: stale busy members marked as errored on restart, orphaned sessions aborted, undelivered messages redelivered
168
- - **Spawn rollback**: if the initial prompt fails, the member is cleaned up and the session aborted
168
+ - **Crash recovery**: stale busy members marked as errored on restart, orphaned sessions aborted, orphaned worktrees cleaned up, undelivered messages redelivered
169
+ - **Spawn rollback**: if the initial prompt fails, the member, session, and worktree are all cleaned up
169
170
  - **Timeout watchdog**: teammates stuck busy beyond the TTL are automatically timed out and aborted
170
171
  - **Shutdown resilience**: abort failures handled gracefully, busy-after-shutdown triggers re-abort
171
172
  - **Rate limiting**: token bucket (configurable via `OPENCODE_ENSEMBLE_RATE_LIMIT`, default 10 tokens/sec)
package/dist/index.js CHANGED
@@ -12405,7 +12405,9 @@ var MIGRATIONS = [
12405
12405
  WHERE delivered = 0;
12406
12406
  `,
12407
12407
  `ALTER TABLE team_message ADD COLUMN read INTEGER NOT NULL DEFAULT 0;
12408
- CREATE INDEX IF NOT EXISTS team_message_unread_idx ON team_message(team_id, read) WHERE read = 0;`
12408
+ CREATE INDEX IF NOT EXISTS team_message_unread_idx ON team_message(team_id, read) WHERE read = 0;`,
12409
+ `ALTER TABLE team_member ADD COLUMN worktree_dir TEXT;
12410
+ ALTER TABLE team_member ADD COLUMN worktree_branch TEXT;`
12409
12411
  ];
12410
12412
  function applyMigrations(db) {
12411
12413
  const { user_version: current } = db.query("PRAGMA user_version").get();
@@ -12498,6 +12500,28 @@ async function recoverStaleMembers(db, client) {
12498
12500
  }
12499
12501
  return { interrupted: result.changes };
12500
12502
  }
12503
+ async function recoverOrphanedWorktrees(db, client) {
12504
+ let removed = 0;
12505
+ try {
12506
+ const worktrees = await client.worktree.list();
12507
+ if (!worktrees.data)
12508
+ return { removed: 0 };
12509
+ const activeWorktrees = new Set(db.query(`SELECT tm.worktree_dir FROM team_member tm
12510
+ JOIN team t ON tm.team_id = t.id
12511
+ WHERE tm.worktree_dir IS NOT NULL AND t.status = 'active'`).all().map((r) => r.worktree_dir));
12512
+ for (const wt of worktrees.data) {
12513
+ if (!wt.name.startsWith("ensemble-"))
12514
+ continue;
12515
+ if (activeWorktrees.has(wt.directory))
12516
+ continue;
12517
+ try {
12518
+ await client.worktree.remove({ worktreeRemoveInput: { directory: wt.directory } });
12519
+ removed++;
12520
+ } catch {}
12521
+ }
12522
+ } catch {}
12523
+ return { removed };
12524
+ }
12501
12525
  async function recoverUndeliveredMessages(db, client, registry2) {
12502
12526
  const teams = db.query("SELECT id, lead_session_id FROM team WHERE status = 'active'").all();
12503
12527
  let redelivered = 0;
@@ -12755,40 +12779,66 @@ async function executeTeamSpawn(deps, args, sessionId) {
12755
12779
  const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.name);
12756
12780
  if (existing)
12757
12781
  throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
12758
- const createResult = await deps.client.session.create({
12782
+ const useWorktree = args.worktree !== false;
12783
+ let worktreeDir = null;
12784
+ let worktreeBranch = null;
12785
+ if (useWorktree) {
12786
+ const worktreeName = `ensemble-${teamInfo.teamName}-${args.name}`;
12787
+ try {
12788
+ const result = await deps.client.worktree.create({ name: worktreeName });
12789
+ if (result.data) {
12790
+ worktreeDir = result.data.directory;
12791
+ worktreeBranch = result.data.branch;
12792
+ }
12793
+ } catch {
12794
+ try {
12795
+ await deps.client.tui.showToast({
12796
+ title: "Team",
12797
+ message: `Worktree creation failed for ${args.name}, using shared directory`,
12798
+ variant: "warning",
12799
+ duration: 4000
12800
+ });
12801
+ } catch {}
12802
+ }
12803
+ }
12804
+ const createOpts = {
12759
12805
  body: { parentID: sessionId, title: `${args.name} (@${args.agent} teammate)` }
12760
- });
12761
- const childSessionId = createResult.data?.id;
12762
- if (!childSessionId)
12806
+ };
12807
+ if (worktreeDir) {
12808
+ createOpts.directory = worktreeDir;
12809
+ }
12810
+ let childSessionId;
12811
+ try {
12812
+ const createResult = await deps.client.session.create(createOpts);
12813
+ childSessionId = createResult.data?.id;
12814
+ } catch (err) {
12815
+ if (worktreeDir) {
12816
+ try {
12817
+ await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
12818
+ } catch {}
12819
+ }
12820
+ throw new Error(`Failed to create session for teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`);
12821
+ }
12822
+ if (!childSessionId) {
12823
+ if (worktreeDir) {
12824
+ try {
12825
+ await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
12826
+ } catch {}
12827
+ }
12763
12828
  throw new Error("Failed to create teammate session");
12829
+ }
12764
12830
  const now = Date.now();
12765
- deps.db.run(`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, time_created, time_updated)
12766
- VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, now, now]);
12831
+ deps.db.run(`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, time_created, time_updated)
12832
+ VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, now, now]);
12767
12833
  deps.registry.register(teamInfo.teamId, args.name, childSessionId);
12768
12834
  const context = [
12769
12835
  `You are "${args.name}", a teammate in team "${teamInfo.teamName}".`,
12770
- `Your agent type is "${args.agent}".`,
12771
- "",
12772
- "Tools available to you:",
12773
- "- team_message: send a message to the lead or another teammate",
12774
- "- team_broadcast: send a message to all team members",
12775
- "- team_tasks_list: view the shared team task board",
12776
- "- team_tasks_add: add tasks to the shared board",
12777
- "- team_tasks_complete: mark a task complete on the shared board",
12778
- "- team_claim: claim a pending task from the shared board",
12779
- "",
12780
- "When you finish your task:",
12781
- "1. If you claimed a task, mark it complete using team_tasks_complete.",
12782
- "2. Send ONE message to the lead with your findings using team_message.",
12783
- "3. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.",
12784
- "",
12785
- "If you are blocked, send ONE message to the lead describing the specific blocker.",
12786
- "",
12787
- "Your plain text output is NOT visible to the team. You MUST use team_message to communicate.",
12788
- "",
12789
- "Your task:",
12790
- args.prompt
12836
+ `Your agent type is "${args.agent}".`
12791
12837
  ];
12838
+ if (worktreeBranch) {
12839
+ context.push(`You are working on branch "${worktreeBranch}" in your own worktree. Your changes are isolated from other teammates.`);
12840
+ }
12841
+ context.push("", "Tools available to you:", "- team_message: send a message to the lead or another teammate", "- team_broadcast: send a message to all team members", "- team_tasks_list: view the shared team task board", "- team_tasks_add: add tasks to the shared board", "- team_tasks_complete: mark a task complete on the shared board", "- team_claim: claim a pending task from the shared board", "", "When you finish your task:", "1. If you claimed a task, mark it complete using team_tasks_complete.", "2. Send ONE message to the lead with your findings using team_message.", "3. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.", "", "If you are blocked, send ONE message to the lead describing the specific blocker.", "", "Your plain text output is NOT visible to the team. You MUST use team_message to communicate.", "", "Your task:", args.prompt);
12792
12842
  if (args.claim_task) {
12793
12843
  context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`);
12794
12844
  }
@@ -12805,9 +12855,15 @@ async function executeTeamSpawn(deps, args, sessionId) {
12805
12855
  try {
12806
12856
  await deps.client.session.abort({ path: { id: childSessionId } });
12807
12857
  } catch {}
12858
+ if (worktreeDir) {
12859
+ try {
12860
+ await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
12861
+ } catch {}
12862
+ }
12808
12863
  throw new Error(`Failed to send initial prompt to teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`);
12809
12864
  }
12810
- return `Teammate "${args.name}" spawned (agent: ${args.agent}). They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`;
12865
+ const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : "";
12866
+ return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`;
12811
12867
  }
12812
12868
 
12813
12869
  // src/tools/team-message.ts
@@ -12888,7 +12944,7 @@ async function executeTeamShutdown(deps, args, sessionId) {
12888
12944
  throw new Error("This session is not in a team.");
12889
12945
  if (teamInfo.role !== "lead")
12890
12946
  throw new Error("Only the team lead can shut down teammates.");
12891
- const member = deps.db.query("SELECT session_id, status FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
12947
+ const member = deps.db.query("SELECT session_id, status, worktree_branch FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
12892
12948
  if (!member)
12893
12949
  throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
12894
12950
  if (member.status === "shutdown")
@@ -12911,7 +12967,8 @@ async function executeTeamShutdown(deps, args, sessionId) {
12911
12967
  const sessionStatus = statuses.data?.[member.session_id];
12912
12968
  if (!sessionStatus || sessionStatus.type === "idle") {
12913
12969
  deps.db.run("UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamInfo.teamId, args.member]);
12914
- return `Teammate "${args.member}" has been shut down.`;
12970
+ const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : "";
12971
+ return `Teammate "${args.member}" has been shut down.${branchInfo}`;
12915
12972
  }
12916
12973
  } catch {}
12917
12974
  return `Shutdown requested for ${args.member}. Will complete when current work finishes.`;
@@ -12924,7 +12981,7 @@ async function executeTeamCleanup(deps, args, sessionId) {
12924
12981
  throw new Error("This session is not in a team.");
12925
12982
  if (teamInfo.role !== "lead")
12926
12983
  throw new Error("Only the team lead can clean up the team.");
12927
- const members = deps.db.query("SELECT name, session_id, status FROM team_member WHERE team_id = ?").all(teamInfo.teamId);
12984
+ const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch FROM team_member WHERE team_id = ?").all(teamInfo.teamId);
12928
12985
  const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
12929
12986
  if (active.length > 0 && !args.force) {
12930
12987
  const names = active.map((m) => m.name).join(", ");
@@ -12937,8 +12994,27 @@ async function executeTeamCleanup(deps, args, sessionId) {
12937
12994
  } catch {}
12938
12995
  }
12939
12996
  }
12997
+ const branches = [];
12998
+ for (const member of members) {
12999
+ if (member.worktree_dir) {
13000
+ try {
13001
+ await deps.client.worktree.remove({ worktreeRemoveInput: { directory: member.worktree_dir } });
13002
+ } catch {}
13003
+ }
13004
+ if (member.worktree_branch) {
13005
+ branches.push(member.worktree_branch);
13006
+ }
13007
+ }
12940
13008
  deps.db.run("UPDATE team SET status = 'archived', time_updated = ? WHERE id = ?", [Date.now(), teamInfo.teamId]);
12941
13009
  deps.registry.unregisterTeam(teamInfo.teamId);
13010
+ if (branches.length > 0) {
13011
+ const mergeCommands = branches.map((b) => ` git merge ${b}`).join(`
13012
+ `);
13013
+ return `Team "${teamInfo.teamName}" cleaned up. Worktrees removed.
13014
+
13015
+ Merge teammate branches:
13016
+ ${mergeCommands}`;
13017
+ }
12942
13018
  return `Team "${teamInfo.teamName}" cleaned up.`;
12943
13019
  }
12944
13020
 
@@ -13064,7 +13140,7 @@ async function executeTeamStatus(deps, sessionId) {
13064
13140
  return "No changes since last check.";
13065
13141
  }
13066
13142
  lastCallTime.set(teamInfo.teamId, now);
13067
- const members = deps.db.query("SELECT name, session_id, agent, status, execution_status FROM team_member WHERE team_id = ? ORDER BY time_created ASC").all(teamInfo.teamId);
13143
+ const members = deps.db.query("SELECT name, session_id, agent, status, execution_status, worktree_branch FROM team_member WHERE team_id = ? ORDER BY time_created ASC").all(teamInfo.teamId);
13068
13144
  const tasks = deps.db.query("SELECT status FROM team_task WHERE team_id = ?").all(teamInfo.teamId);
13069
13145
  const lines = [];
13070
13146
  lines.push(`Team: ${teamInfo.teamName} (you are the ${teamInfo.role})`);
@@ -13075,7 +13151,8 @@ async function executeTeamStatus(deps, sessionId) {
13075
13151
  lines.push("Members:");
13076
13152
  for (const m of members) {
13077
13153
  const statusIcon = m.status === "busy" ? "working" : m.status === "ready" ? "idle" : m.status;
13078
- lines.push(` ${m.name} [${statusIcon}] agent: ${m.agent} session: ${m.session_id}`);
13154
+ const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : "";
13155
+ lines.push(` ${m.name} [${statusIcon}] agent: ${m.agent}${branch} session: ${m.session_id}`);
13079
13156
  }
13080
13157
  }
13081
13158
  if (tasks.length > 0) {
@@ -13269,6 +13346,7 @@ var plugin = async (input) => {
13269
13346
  }
13270
13347
  }
13271
13348
  recoverUndeliveredMessages(db, client, registry2).catch(() => {});
13349
+ recoverOrphanedWorktrees(db, client).catch(() => {});
13272
13350
  const rateLimitEnv = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT;
13273
13351
  const rateLimitCapacity = rateLimitEnv === "0" ? 0 : parseInt(rateLimitEnv ?? "", 10) || DEFAULT_RATE_LIMIT_CAPACITY;
13274
13352
  const rateLimiter = new TokenBucket({
@@ -13347,13 +13425,14 @@ var plugin = async (input) => {
13347
13425
  }
13348
13426
  }),
13349
13427
  team_spawn: tool({
13350
- description: "Spawn a new teammate that works in parallel. The teammate starts immediately with the given prompt. " + "Teammates work asynchronously and will message you when done. Do not poll for their status.",
13428
+ description: "Spawn a new teammate that works in parallel. The teammate starts immediately with the given prompt. " + "Each teammate gets their own git worktree for file isolation. " + "Teammates work asynchronously and will message you when done. Do not poll for their status.",
13351
13429
  args: {
13352
13430
  name: tool.schema.string().describe("Teammate name (lowercase alphanumeric with hyphens)"),
13353
13431
  agent: tool.schema.string().default("build").describe("Agent type (e.g. 'build', 'plan', 'explore')"),
13354
13432
  prompt: tool.schema.string().describe("Task instructions for the teammate"),
13355
13433
  model: tool.schema.string().optional().describe("Model in provider/model format (optional, uses default)"),
13356
- claim_task: tool.schema.string().optional().describe("Task ID to auto-claim for this teammate (optional)")
13434
+ claim_task: tool.schema.string().optional().describe("Task ID to auto-claim for this teammate (optional)"),
13435
+ worktree: tool.schema.boolean().default(true).describe("Create a git worktree for file isolation (default: true, set false for read-only agents)")
13357
13436
  },
13358
13437
  async execute(args, ctx) {
13359
13438
  const result = await executeTeamSpawn(deps, args, ctx.sessionID);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Agent teams for OpenCode — parallel agents with peer-to-peer communication, shared tasks, and coordinated execution",
5
5
  "module": "src/index.ts",
6
6
  "main": "dist/index.js",
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@ import { tool } from "@opencode-ai/plugin"
3
3
  import path from "path"
4
4
  import { mkdirSync } from "node:fs"
5
5
  import { createDb, getDbPath } from "./db"
6
- import { recoverStaleMembers, recoverUndeliveredMessages } from "./recovery"
6
+ import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktrees } from "./recovery"
7
7
  import { MemberRegistry, DescendantTracker } from "./state"
8
8
  import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation } from "./hooks"
9
9
  import { notifyTeamEvent, notifyWorkingProgress } from "./notify"
@@ -66,6 +66,11 @@ const plugin: Plugin = async (input) => {
66
66
  // Best effort — don't block plugin init
67
67
  })
68
68
 
69
+ // Clean up orphaned worktrees from crashed teams
70
+ recoverOrphanedWorktrees(db, client).catch(() => {
71
+ // Best effort — don't block plugin init
72
+ })
73
+
69
74
  // Initialize rate limiter
70
75
  // OPENCODE_ENSEMBLE_RATE_LIMIT=0 disables it entirely
71
76
  const rateLimitEnv = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT
@@ -163,6 +168,7 @@ const plugin: Plugin = async (input) => {
163
168
 
164
169
  team_spawn: tool({
165
170
  description: "Spawn a new teammate that works in parallel. The teammate starts immediately with the given prompt. " +
171
+ "Each teammate gets their own git worktree for file isolation. " +
166
172
  "Teammates work asynchronously and will message you when done. Do not poll for their status.",
167
173
  args: {
168
174
  name: tool.schema.string().describe("Teammate name (lowercase alphanumeric with hyphens)"),
@@ -170,6 +176,7 @@ const plugin: Plugin = async (input) => {
170
176
  prompt: tool.schema.string().describe("Task instructions for the teammate"),
171
177
  model: tool.schema.string().optional().describe("Model in provider/model format (optional, uses default)"),
172
178
  claim_task: tool.schema.string().optional().describe("Task ID to auto-claim for this teammate (optional)"),
179
+ worktree: tool.schema.boolean().default(true).describe("Create a git worktree for file isolation (default: true, set false for read-only agents)"),
173
180
  },
174
181
  async execute(args, ctx) {
175
182
  const result = await executeTeamSpawn(deps, args, ctx.sessionID)
package/src/recovery.ts CHANGED
@@ -37,6 +37,43 @@ export async function recoverStaleMembers(db: Database, client?: PluginClient):
37
37
  return { interrupted: result.changes }
38
38
  }
39
39
 
40
+ /**
41
+ * Clean up orphaned worktrees from archived teams or members that no longer exist.
42
+ * Compares worktrees on disk (via client.worktree.list) against active team members.
43
+ */
44
+ export async function recoverOrphanedWorktrees(db: Database, client: PluginClient): Promise<{ removed: number }> {
45
+ let removed = 0
46
+
47
+ try {
48
+ const worktrees = await client.worktree.list()
49
+ if (!worktrees.data) return { removed: 0 }
50
+
51
+ // Get all active worktree directories from the DB
52
+ const activeWorktrees = new Set(
53
+ (db.query(
54
+ `SELECT tm.worktree_dir FROM team_member tm
55
+ JOIN team t ON tm.team_id = t.id
56
+ WHERE tm.worktree_dir IS NOT NULL AND t.status = 'active'`
57
+ ).all() as Array<{ worktree_dir: string }>).map(r => r.worktree_dir)
58
+ )
59
+
60
+ for (const wt of worktrees.data) {
61
+ // Only clean up worktrees created by ensemble (name starts with "ensemble-")
62
+ if (!wt.name.startsWith("ensemble-")) continue
63
+ if (activeWorktrees.has(wt.directory)) continue
64
+
65
+ try {
66
+ await client.worktree.remove({ worktreeRemoveInput: { directory: wt.directory } })
67
+ removed++
68
+ } catch { /* best effort */ }
69
+ }
70
+ } catch {
71
+ // worktree.list may not be available — silently ignore
72
+ }
73
+
74
+ return { removed }
75
+ }
76
+
40
77
  /**
41
78
  * Redeliver undelivered messages (delivered=0) via promptAsync.
42
79
  * Resolves recipient session IDs from the member registry or team lead.
package/src/schema.ts CHANGED
@@ -70,6 +70,9 @@ export const MIGRATIONS: string[] = [
70
70
  // Migration 2: Add read column to team_message for team_results tracking
71
71
  `ALTER TABLE team_message ADD COLUMN read INTEGER NOT NULL DEFAULT 0;
72
72
  CREATE INDEX IF NOT EXISTS team_message_unread_idx ON team_message(team_id, read) WHERE read = 0;`,
73
+ // Migration 3: Add worktree columns to team_member for git worktree isolation
74
+ `ALTER TABLE team_member ADD COLUMN worktree_dir TEXT;
75
+ ALTER TABLE team_member ADD COLUMN worktree_branch TEXT;`,
73
76
  ]
74
77
 
75
78
  /**
@@ -3,6 +3,7 @@ import { findTeamBySession } from "../types"
3
3
 
4
4
  /**
5
5
  * Execute the team_cleanup tool. Archives the team and cleans up resources.
6
+ * Removes worktrees and lists branches for the lead to merge.
6
7
  * If force=true, aborts all active member sessions first.
7
8
  */
8
9
  export async function executeTeamCleanup(
@@ -14,8 +15,8 @@ export async function executeTeamCleanup(
14
15
  if (!teamInfo) throw new Error("This session is not in a team.")
15
16
  if (teamInfo.role !== "lead") throw new Error("Only the team lead can clean up the team.")
16
17
 
17
- const members = deps.db.query("SELECT name, session_id, status FROM team_member WHERE team_id = ?")
18
- .all(teamInfo.teamId) as Array<{ name: string; session_id: string; status: string }>
18
+ const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch FROM team_member WHERE team_id = ?")
19
+ .all(teamInfo.teamId) as Array<{ name: string; session_id: string; status: string; worktree_dir: string | null; worktree_branch: string | null }>
19
20
 
20
21
  const active = members.filter(m => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error")
21
22
 
@@ -29,9 +30,20 @@ export async function executeTeamCleanup(
29
30
  for (const member of active) {
30
31
  try {
31
32
  await deps.client.session.abort({ path: { id: member.session_id } })
32
- } catch {
33
- // Best effort
34
- }
33
+ } catch { /* best effort */ }
34
+ }
35
+ }
36
+
37
+ // Remove worktrees and collect branches for merging
38
+ const branches: string[] = []
39
+ for (const member of members) {
40
+ if (member.worktree_dir) {
41
+ try {
42
+ await deps.client.worktree.remove({ worktreeRemoveInput: { directory: member.worktree_dir } })
43
+ } catch { /* best effort — worktree may already be gone */ }
44
+ }
45
+ if (member.worktree_branch) {
46
+ branches.push(member.worktree_branch)
35
47
  }
36
48
  }
37
49
 
@@ -41,5 +53,10 @@ export async function executeTeamCleanup(
41
53
  // Clean up registry
42
54
  deps.registry.unregisterTeam(teamInfo.teamId)
43
55
 
56
+ if (branches.length > 0) {
57
+ const mergeCommands = branches.map(b => ` git merge ${b}`).join("\n")
58
+ return `Team "${teamInfo.teamName}" cleaned up. Worktrees removed.\n\nMerge teammate branches:\n${mergeCommands}`
59
+ }
60
+
44
61
  return `Team "${teamInfo.teamName}" cleaned up.`
45
62
  }
@@ -16,8 +16,8 @@ export async function executeTeamShutdown(
16
16
  if (!teamInfo) throw new Error("This session is not in a team.")
17
17
  if (teamInfo.role !== "lead") throw new Error("Only the team lead can shut down teammates.")
18
18
 
19
- const member = deps.db.query("SELECT session_id, status FROM team_member WHERE team_id = ? AND name = ?")
20
- .get(teamInfo.teamId, args.member) as { session_id: string; status: string } | null
19
+ const member = deps.db.query("SELECT session_id, status, worktree_branch FROM team_member WHERE team_id = ? AND name = ?")
20
+ .get(teamInfo.teamId, args.member) as { session_id: string; status: string; worktree_branch: string | null } | null
21
21
  if (!member) throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`)
22
22
  if (member.status === "shutdown") throw new Error(`Teammate "${args.member}" is already shut down`)
23
23
 
@@ -53,7 +53,8 @@ export async function executeTeamShutdown(
53
53
  "UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?",
54
54
  [Date.now(), teamInfo.teamId, args.member]
55
55
  )
56
- return `Teammate "${args.member}" has been shut down.`
56
+ const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : ""
57
+ return `Teammate "${args.member}" has been shut down.${branchInfo}`
57
58
  }
58
59
  } catch {
59
60
  // Status poll failed — fall through to eventual consistency
@@ -4,10 +4,12 @@ import { findTeamBySession } from "../types"
4
4
 
5
5
  /**
6
6
  * Execute the team_spawn tool. Creates a child session and starts a teammate.
7
+ * By default, each teammate gets their own git worktree for file isolation.
8
+ * Pass worktree: false for read-only agents that don't need isolation.
7
9
  */
8
10
  export async function executeTeamSpawn(
9
11
  deps: ToolDeps,
10
- args: { name: string; agent: string; prompt: string; model?: string; claim_task?: string },
12
+ args: { name: string; agent: string; prompt: string; model?: string; claim_task?: string; worktree?: boolean },
11
13
  sessionId: string,
12
14
  ): Promise<string> {
13
15
  const nameError = validateMemberName(args.name)
@@ -22,28 +24,82 @@ export async function executeTeamSpawn(
22
24
  .get(teamInfo.teamId, args.name)
23
25
  if (existing) throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`)
24
26
 
25
- // Create child session via SDK
26
- const createResult = await deps.client.session.create({
27
+ const useWorktree = args.worktree !== false
28
+
29
+ // Create worktree if enabled
30
+ let worktreeDir: string | null = null
31
+ let worktreeBranch: string | null = null
32
+
33
+ if (useWorktree) {
34
+ const worktreeName = `ensemble-${teamInfo.teamName}-${args.name}`
35
+ try {
36
+ const result = await deps.client.worktree.create({ name: worktreeName })
37
+ if (result.data) {
38
+ worktreeDir = result.data.directory
39
+ worktreeBranch = result.data.branch
40
+ }
41
+ } catch {
42
+ // Worktree creation failed — fall back to shared directory with a warning
43
+ try {
44
+ await deps.client.tui.showToast({
45
+ title: "Team",
46
+ message: `Worktree creation failed for ${args.name}, using shared directory`,
47
+ variant: "warning",
48
+ duration: 4000,
49
+ })
50
+ } catch { /* TUI may not be available */ }
51
+ }
52
+ }
53
+
54
+ // Create child session — use worktree directory if available
55
+ const createOpts: Record<string, unknown> = {
27
56
  body: { parentID: sessionId, title: `${args.name} (@${args.agent} teammate)` },
28
- })
29
- const childSessionId = createResult.data?.id
30
- if (!childSessionId) throw new Error("Failed to create teammate session")
57
+ }
58
+ if (worktreeDir) {
59
+ createOpts.directory = worktreeDir
60
+ }
61
+
62
+ let childSessionId: string | undefined
63
+ try {
64
+ const createResult = await deps.client.session.create(createOpts as Parameters<typeof deps.client.session.create>[0])
65
+ childSessionId = createResult.data?.id
66
+ } catch (err) {
67
+ // Rollback worktree if session creation failed
68
+ if (worktreeDir) {
69
+ try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
70
+ }
71
+ throw new Error(`Failed to create session for teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`)
72
+ }
73
+
74
+ if (!childSessionId) {
75
+ if (worktreeDir) {
76
+ try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
77
+ }
78
+ throw new Error("Failed to create teammate session")
79
+ }
31
80
 
32
81
  // Register in DB
33
82
  const now = Date.now()
34
83
  deps.db.run(
35
- `INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, time_created, time_updated)
36
- VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?)`,
37
- [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, now, now]
84
+ `INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, time_created, time_updated)
85
+ VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?)`,
86
+ [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, now, now]
38
87
  )
39
88
 
40
89
  // Register in memory
41
90
  deps.registry.register(teamInfo.teamId, args.name, childSessionId)
42
91
 
43
- // Build teammate context message per AGENTS.md Teammate Context Message Design
92
+ // Build teammate context message
44
93
  const context = [
45
94
  `You are "${args.name}", a teammate in team "${teamInfo.teamName}".`,
46
95
  `Your agent type is "${args.agent}".`,
96
+ ]
97
+
98
+ if (worktreeBranch) {
99
+ context.push(`You are working on branch "${worktreeBranch}" in your own worktree. Your changes are isolated from other teammates.`)
100
+ }
101
+
102
+ context.push(
47
103
  "",
48
104
  "Tools available to you:",
49
105
  "- team_message: send a message to the lead or another teammate",
@@ -64,7 +120,7 @@ export async function executeTeamSpawn(
64
120
  "",
65
121
  "Your task:",
66
122
  args.prompt,
67
- ]
123
+ )
68
124
 
69
125
  if (args.claim_task) {
70
126
  context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`)
@@ -73,20 +129,22 @@ export async function executeTeamSpawn(
73
129
  const contextStr = context.join("\n")
74
130
 
75
131
  // Fire-and-forget: send prompt to teammate session
76
- // OQ-1: confirmed — promptAsync queues when session is busy (verified in live testing)
77
- // OQ-10: confirmed — fresh session accepts promptAsync without session.init() (verified in live testing)
78
132
  try {
79
133
  await deps.client.session.promptAsync({
80
134
  path: { id: childSessionId },
81
135
  body: { parts: [{ type: "text", text: contextStr }] },
82
136
  })
83
137
  } catch (err) {
84
- // Rollback: clean up DB, registry, and abort the orphaned session
138
+ // Rollback: clean up DB, registry, session, and worktree
85
139
  deps.db.run("DELETE FROM team_member WHERE team_id = ? AND name = ?", [teamInfo.teamId, args.name])
86
140
  deps.registry.unregister(childSessionId)
87
141
  try { await deps.client.session.abort({ path: { id: childSessionId } }) } catch { /* best effort */ }
142
+ if (worktreeDir) {
143
+ try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
144
+ }
88
145
  throw new Error(`Failed to send initial prompt to teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`)
89
146
  }
90
147
 
91
- return `Teammate "${args.name}" spawned (agent: ${args.agent}). They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
148
+ const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : ""
149
+ return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
92
150
  }
@@ -24,9 +24,9 @@ export async function executeTeamStatus(
24
24
  lastCallTime.set(teamInfo.teamId, now)
25
25
 
26
26
  const members = deps.db.query(
27
- "SELECT name, session_id, agent, status, execution_status FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
27
+ "SELECT name, session_id, agent, status, execution_status, worktree_branch FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
28
28
  ).all(teamInfo.teamId) as Array<{
29
- name: string; session_id: string; agent: string; status: string; execution_status: string
29
+ name: string; session_id: string; agent: string; status: string; execution_status: string; worktree_branch: string | null
30
30
  }>
31
31
 
32
32
  const tasks = deps.db.query(
@@ -43,7 +43,8 @@ export async function executeTeamStatus(
43
43
  lines.push("Members:")
44
44
  for (const m of members) {
45
45
  const statusIcon = m.status === "busy" ? "working" : m.status === "ready" ? "idle" : m.status
46
- lines.push(` ${m.name} [${statusIcon}] agent: ${m.agent} session: ${m.session_id}`)
46
+ const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : ""
47
+ lines.push(` ${m.name} [${statusIcon}] agent: ${m.agent}${branch} session: ${m.session_id}`)
47
48
  }
48
49
  }
49
50
 
package/src/types.ts CHANGED
@@ -37,6 +37,12 @@ export interface PluginClient {
37
37
  }): Promise<unknown>
38
38
  selectSession(options: { sessionID?: string }): Promise<unknown>
39
39
  }
40
+ worktree: {
41
+ create(options: { name?: string; startCommand?: string }): Promise<{ data?: { name: string; branch: string; directory: string } }>
42
+ remove(options: { worktreeRemoveInput?: { directory: string } }): Promise<unknown>
43
+ list(): Promise<{ data?: Array<{ name: string; branch: string; directory: string }> }>
44
+ reset(options: { worktreeResetInput?: { directory: string } }): Promise<unknown>
45
+ }
40
46
  }
41
47
 
42
48
  /**