@hueyexe/opencode-ensemble 0.3.0 → 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
@@ -1,6 +1,6 @@
1
1
  # OpenCode Ensemble
2
2
 
3
- Agent teams for [OpenCode](https://opencode.ai). Run multiple agents in parallel with messaging, shared tasks, and coordinated execution.
3
+ Run a team of parallel AI agents in OpenCode. Each agent gets its own session, context window, and task. They coordinate through messaging and a shared task board.
4
4
 
5
5
  Built as a plugin on the public OpenCode SDK. Zero internal dependencies.
6
6
 
@@ -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
@@ -1,16 +1,12 @@
1
1
  // @bun
2
2
  var __defProp = Object.defineProperty;
3
- var __returnValue = (v) => v;
4
- function __exportSetter(name, newValue) {
5
- this[name] = __returnValue.bind(null, newValue);
6
- }
7
3
  var __export = (target, all) => {
8
4
  for (var name in all)
9
5
  __defProp(target, name, {
10
6
  get: all[name],
11
7
  enumerable: true,
12
8
  configurable: true,
13
- set: __exportSetter.bind(all, name)
9
+ set: (newValue) => all[name] = () => newValue
14
10
  });
15
11
  };
16
12
 
@@ -12409,7 +12405,9 @@ var MIGRATIONS = [
12409
12405
  WHERE delivered = 0;
12410
12406
  `,
12411
12407
  `ALTER TABLE team_message ADD COLUMN read INTEGER NOT NULL DEFAULT 0;
12412
- 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;`
12413
12411
  ];
12414
12412
  function applyMigrations(db) {
12415
12413
  const { user_version: current } = db.query("PRAGMA user_version").get();
@@ -12502,6 +12500,28 @@ async function recoverStaleMembers(db, client) {
12502
12500
  }
12503
12501
  return { interrupted: result.changes };
12504
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
+ }
12505
12525
  async function recoverUndeliveredMessages(db, client, registry2) {
12506
12526
  const teams = db.query("SELECT id, lead_session_id FROM team WHERE status = 'active'").all();
12507
12527
  let redelivered = 0;
@@ -12743,7 +12763,7 @@ async function executeTeamCreate(deps, args, sessionId) {
12743
12763
  const id = generateId("team");
12744
12764
  const now = Date.now();
12745
12765
  deps.db.run("INSERT INTO team (id, name, lead_session_id, status, delegate, time_created, time_updated) VALUES (?, ?, ?, 'active', 0, ?, ?)", [id, args.name, sessionId, now, now]);
12746
- return `Team "${args.name}" created (ID: ${id}). You are the lead. Use team_spawn to add teammates. Teammates will message you when done \u2014 do not poll team_status repeatedly. IMPORTANT: After spawning all teammates, do NOT call any tools. Tell the user what you've set up and STOP. You will be woken automatically.`;
12766
+ return `Team "${args.name}" created. You are the lead. Use team_spawn to add teammates.`;
12747
12767
  }
12748
12768
 
12749
12769
  // src/tools/team-spawn.ts
@@ -12759,40 +12779,66 @@ async function executeTeamSpawn(deps, args, sessionId) {
12759
12779
  const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.name);
12760
12780
  if (existing)
12761
12781
  throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
12762
- 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 = {
12763
12805
  body: { parentID: sessionId, title: `${args.name} (@${args.agent} teammate)` }
12764
- });
12765
- const childSessionId = createResult.data?.id;
12766
- 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
+ }
12767
12828
  throw new Error("Failed to create teammate session");
12829
+ }
12768
12830
  const now = Date.now();
12769
- deps.db.run(`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, time_created, time_updated)
12770
- 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]);
12771
12833
  deps.registry.register(teamInfo.teamId, args.name, childSessionId);
12772
12834
  const context = [
12773
12835
  `You are "${args.name}", a teammate in team "${teamInfo.teamName}".`,
12774
- `Your agent type is "${args.agent}".`,
12775
- "",
12776
- "Tools available to you:",
12777
- "- team_message: send a message to the lead or another teammate",
12778
- "- team_broadcast: send a message to all team members",
12779
- "- team_tasks_list: view the shared team task board",
12780
- "- team_tasks_add: add tasks to the shared board",
12781
- "- team_tasks_complete: mark a task complete on the shared board",
12782
- "- team_claim: claim a pending task from the shared board",
12783
- "",
12784
- "When you finish your task:",
12785
- "1. If you claimed a task, mark it complete using team_tasks_complete.",
12786
- "2. Send ONE message to the lead with your findings using team_message.",
12787
- "3. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.",
12788
- "",
12789
- "If you are blocked, send ONE message to the lead describing the specific blocker.",
12790
- "",
12791
- "Your plain text output is NOT visible to the team. You MUST use team_message to communicate.",
12792
- "",
12793
- "Your task:",
12794
- args.prompt
12836
+ `Your agent type is "${args.agent}".`
12795
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);
12796
12842
  if (args.claim_task) {
12797
12843
  context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`);
12798
12844
  }
@@ -12809,9 +12855,15 @@ async function executeTeamSpawn(deps, args, sessionId) {
12809
12855
  try {
12810
12856
  await deps.client.session.abort({ path: { id: childSessionId } });
12811
12857
  } catch {}
12858
+ if (worktreeDir) {
12859
+ try {
12860
+ await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
12861
+ } catch {}
12862
+ }
12812
12863
  throw new Error(`Failed to send initial prompt to teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`);
12813
12864
  }
12814
- return `Teammate "${args.name}" spawned (agent: ${args.agent}). IMPORTANT: Do NOT call any tools after this. Tell the user what you've set up and STOP. You will be woken automatically when teammates message you.`;
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 ? "..." : ""}`;
12815
12867
  }
12816
12868
 
12817
12869
  // src/tools/team-message.ts
@@ -12892,7 +12944,7 @@ async function executeTeamShutdown(deps, args, sessionId) {
12892
12944
  throw new Error("This session is not in a team.");
12893
12945
  if (teamInfo.role !== "lead")
12894
12946
  throw new Error("Only the team lead can shut down teammates.");
12895
- 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);
12896
12948
  if (!member)
12897
12949
  throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
12898
12950
  if (member.status === "shutdown")
@@ -12915,7 +12967,8 @@ async function executeTeamShutdown(deps, args, sessionId) {
12915
12967
  const sessionStatus = statuses.data?.[member.session_id];
12916
12968
  if (!sessionStatus || sessionStatus.type === "idle") {
12917
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]);
12918
- 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}`;
12919
12972
  }
12920
12973
  } catch {}
12921
12974
  return `Shutdown requested for ${args.member}. Will complete when current work finishes.`;
@@ -12928,7 +12981,7 @@ async function executeTeamCleanup(deps, args, sessionId) {
12928
12981
  throw new Error("This session is not in a team.");
12929
12982
  if (teamInfo.role !== "lead")
12930
12983
  throw new Error("Only the team lead can clean up the team.");
12931
- 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);
12932
12985
  const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
12933
12986
  if (active.length > 0 && !args.force) {
12934
12987
  const names = active.map((m) => m.name).join(", ");
@@ -12941,8 +12994,27 @@ async function executeTeamCleanup(deps, args, sessionId) {
12941
12994
  } catch {}
12942
12995
  }
12943
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
+ }
12944
13008
  deps.db.run("UPDATE team SET status = 'archived', time_updated = ? WHERE id = ?", [Date.now(), teamInfo.teamId]);
12945
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
+ }
12946
13018
  return `Team "${teamInfo.teamName}" cleaned up.`;
12947
13019
  }
12948
13020
 
@@ -13065,10 +13137,10 @@ async function executeTeamStatus(deps, sessionId) {
13065
13137
  const now = Date.now();
13066
13138
  const last = lastCallTime.get(teamInfo.teamId);
13067
13139
  if (last !== undefined && now - last < RATE_LIMIT_MS) {
13068
- return "Status unchanged. STOP \u2014 do not call any more tools. Tell the user you are waiting for teammates to finish. You will be woken automatically when they message you.";
13140
+ return "No changes since last check.";
13069
13141
  }
13070
13142
  lastCallTime.set(teamInfo.teamId, now);
13071
- 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);
13072
13144
  const tasks = deps.db.query("SELECT status FROM team_task WHERE team_id = ?").all(teamInfo.teamId);
13073
13145
  const lines = [];
13074
13146
  lines.push(`Team: ${teamInfo.teamName} (you are the ${teamInfo.role})`);
@@ -13079,7 +13151,8 @@ async function executeTeamStatus(deps, sessionId) {
13079
13151
  lines.push("Members:");
13080
13152
  for (const m of members) {
13081
13153
  const statusIcon = m.status === "busy" ? "working" : m.status === "ready" ? "idle" : m.status;
13082
- 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}`);
13083
13156
  }
13084
13157
  }
13085
13158
  if (tasks.length > 0) {
@@ -13110,9 +13183,7 @@ async function executeTeamStatus(deps, sessionId) {
13110
13183
  });
13111
13184
  } catch {}
13112
13185
  }
13113
- return `[Relay this status to the user in your next message:]
13114
-
13115
- ` + lines.join(`
13186
+ return lines.join(`
13116
13187
  `);
13117
13188
  }
13118
13189
 
@@ -13125,11 +13196,11 @@ async function executeTeamView(deps, args, sessionId) {
13125
13196
  if (!member)
13126
13197
  throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
13127
13198
  try {
13128
- if (typeof deps.client.tui.selectSession === "function") {
13129
- await deps.client.tui.selectSession({ sessionID: member.session_id });
13130
- }
13131
- } catch {}
13132
- return `View ${args.member}'s session: ${member.session_id}. Use the session picker (ctrl+p) to navigate to their session.`;
13199
+ await deps.client.tui.selectSession({ sessionID: member.session_id });
13200
+ return `Switched to ${args.member}'s session.`;
13201
+ } catch {
13202
+ return `Could not switch to ${args.member}'s session (${member.session_id}). The session may not be accessible from the TUI.`;
13203
+ }
13133
13204
  }
13134
13205
 
13135
13206
  // src/rate-limit.ts
@@ -13275,6 +13346,7 @@ var plugin = async (input) => {
13275
13346
  }
13276
13347
  }
13277
13348
  recoverUndeliveredMessages(db, client, registry2).catch(() => {});
13349
+ recoverOrphanedWorktrees(db, client).catch(() => {});
13278
13350
  const rateLimitEnv = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT;
13279
13351
  const rateLimitCapacity = rateLimitEnv === "0" ? 0 : parseInt(rateLimitEnv ?? "", 10) || DEFAULT_RATE_LIMIT_CAPACITY;
13280
13352
  const rateLimiter = new TokenBucket({
@@ -13353,13 +13425,14 @@ var plugin = async (input) => {
13353
13425
  }
13354
13426
  }),
13355
13427
  team_spawn: tool({
13356
- 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.",
13357
13429
  args: {
13358
13430
  name: tool.schema.string().describe("Teammate name (lowercase alphanumeric with hyphens)"),
13359
13431
  agent: tool.schema.string().default("build").describe("Agent type (e.g. 'build', 'plan', 'explore')"),
13360
13432
  prompt: tool.schema.string().describe("Task instructions for the teammate"),
13361
13433
  model: tool.schema.string().optional().describe("Model in provider/model format (optional, uses default)"),
13362
- 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)")
13363
13436
  },
13364
13437
  async execute(args, ctx) {
13365
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.0",
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
  }
@@ -28,5 +28,5 @@ export async function executeTeamCreate(
28
28
  [id, args.name, sessionId, now, now]
29
29
  )
30
30
 
31
- return `Team "${args.name}" created (ID: ${id}). You are the lead. Use team_spawn to add teammates. Teammates will message you when done — do not poll team_status repeatedly. IMPORTANT: After spawning all teammates, do NOT call any tools. Tell the user what you've set up and STOP. You will be woken automatically.`
31
+ return `Team "${args.name}" created. You are the lead. Use team_spawn to add teammates.`
32
32
  }
@@ -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}). IMPORTANT: Do NOT call any tools after this. Tell the user what you've set up and STOP. You will be woken automatically when teammates message you.`
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
  }
@@ -19,14 +19,14 @@ export async function executeTeamStatus(
19
19
  const now = Date.now()
20
20
  const last = lastCallTime.get(teamInfo.teamId)
21
21
  if (last !== undefined && (now - last) < RATE_LIMIT_MS) {
22
- return "Status unchanged. STOP — do not call any more tools. Tell the user you are waiting for teammates to finish. You will be woken automatically when they message you."
22
+ return "No changes since last check."
23
23
  }
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
 
@@ -80,5 +81,5 @@ export async function executeTeamStatus(
80
81
  }
81
82
  }
82
83
 
83
- return "[Relay this status to the user in your next message:]\n\n" + lines.join("\n")
84
+ return lines.join("\n")
84
85
  }
@@ -17,14 +17,10 @@ export async function executeTeamView(
17
17
  .get(teamInfo.teamId, args.member) as { session_id: string; status: string; agent: string } | null
18
18
  if (!member) throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`)
19
19
 
20
- // TUI session navigation — try selectSession if available, otherwise guide the user
21
20
  try {
22
- if (typeof deps.client.tui.selectSession === "function") {
23
- await deps.client.tui.selectSession({ sessionID: member.session_id })
24
- }
21
+ await deps.client.tui.selectSession({ sessionID: member.session_id })
22
+ return `Switched to ${args.member}'s session.`
25
23
  } catch {
26
- // selectSession may not be available in all SDK versions — fall through to manual guidance
24
+ return `Could not switch to ${args.member}'s session (${member.session_id}). The session may not be accessible from the TUI.`
27
25
  }
28
-
29
- return `View ${args.member}'s session: ${member.session_id}. Use the session picker (ctrl+p) to navigate to their session.`
30
26
  }
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
  /**