@hueyexe/opencode-ensemble 0.3.1 → 0.5.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)
@@ -186,6 +187,28 @@ OPENCODE_ENSEMBLE_TIMEOUT=3600000
186
187
  OPENCODE_ENSEMBLE_TIMEOUT=0
187
188
  ```
188
189
 
190
+ ### Best practices
191
+
192
+ - Start with 2-4 teammates for most tasks. More agents means more coordination overhead.
193
+ - Give each teammate 3-5 specific, self-contained tasks. Vague prompts produce vague results.
194
+ - Start with research before implementation — spawn an explore agent first to understand the codebase, then spawn build agents with that context.
195
+ - Use `worktree: false` for read-only agents (research, review, code analysis). They don't need file isolation.
196
+ - Use `plan_approval: true` for risky changes — the teammate sends a plan first, you review and approve before they write any code.
197
+ - Let the lead coordinate. Don't micromanage teammates — they message you when done or when they're blocked.
198
+ - After spawning, tell the user what you've set up and wait for results. Don't poll `team_status` in a loop.
199
+
200
+ ### How this differs from Claude Code agent teams
201
+
202
+ Claude Code has built-in agent teams. This plugin takes the same coordination model (shared tasks, peer messaging, lead coordination) and adds:
203
+
204
+ - **Git worktree isolation by default** — each teammate gets their own branch, no merge conflicts between parallel agents
205
+ - **System prompt injection** — the lead's system prompt is updated with team state (member statuses, task progress) so it stays aware across turns
206
+ - **Compaction safety** — team context is preserved when sessions get long and OpenCode compacts the conversation
207
+ - **Team-aware shell environment** — teammate shells get `ENSEMBLE_TEAM`, `ENSEMBLE_MEMBER`, `ENSEMBLE_ROLE`, and `ENSEMBLE_BRANCH` variables
208
+ - **Graceful shutdown** — teammates finish their current work before stopping, with a force flag for emergencies
209
+ - **Plan approval mode** — review teammate plans before they write code, useful for risky or unfamiliar changes
210
+ - **Works today as a plugin** — no waiting for upstream features, install and go
211
+
189
212
  ## Development
190
213
 
191
214
  ```bash
package/dist/index.js CHANGED
@@ -1,12 +1,16 @@
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
+ }
3
7
  var __export = (target, all) => {
4
8
  for (var name in all)
5
9
  __defProp(target, name, {
6
10
  get: all[name],
7
11
  enumerable: true,
8
12
  configurable: true,
9
- set: (newValue) => all[name] = () => newValue
13
+ set: __exportSetter.bind(all, name)
10
14
  });
11
15
  };
12
16
 
@@ -12331,7 +12335,7 @@ function tool(input) {
12331
12335
  }
12332
12336
  tool.schema = exports_external;
12333
12337
  // src/index.ts
12334
- import path2 from "path";
12338
+ import path3 from "path";
12335
12339
  import { mkdirSync } from "fs";
12336
12340
 
12337
12341
  // src/db.ts
@@ -12405,7 +12409,11 @@ var MIGRATIONS = [
12405
12409
  WHERE delivered = 0;
12406
12410
  `,
12407
12411
  `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;`
12412
+ CREATE INDEX IF NOT EXISTS team_message_unread_idx ON team_message(team_id, read) WHERE read = 0;`,
12413
+ `ALTER TABLE team_member ADD COLUMN worktree_dir TEXT;
12414
+ ALTER TABLE team_member ADD COLUMN worktree_branch TEXT;`,
12415
+ `ALTER TABLE team_member ADD COLUMN plan_approval TEXT NOT NULL DEFAULT 'none'
12416
+ CHECK(plan_approval IN ('none', 'pending', 'approved', 'rejected'));`
12409
12417
  ];
12410
12418
  function applyMigrations(db) {
12411
12419
  const { user_version: current } = db.query("PRAGMA user_version").get();
@@ -12498,6 +12506,28 @@ async function recoverStaleMembers(db, client) {
12498
12506
  }
12499
12507
  return { interrupted: result.changes };
12500
12508
  }
12509
+ async function recoverOrphanedWorktrees(db, client) {
12510
+ let removed = 0;
12511
+ try {
12512
+ const worktrees = await client.worktree.list();
12513
+ if (!worktrees.data)
12514
+ return { removed: 0 };
12515
+ const activeWorktrees = new Set(db.query(`SELECT tm.worktree_dir FROM team_member tm
12516
+ JOIN team t ON tm.team_id = t.id
12517
+ WHERE tm.worktree_dir IS NOT NULL AND t.status = 'active'`).all().map((r) => r.worktree_dir));
12518
+ for (const wt of worktrees.data) {
12519
+ if (!wt.name.startsWith("ensemble-"))
12520
+ continue;
12521
+ if (activeWorktrees.has(wt.directory))
12522
+ continue;
12523
+ try {
12524
+ await client.worktree.remove({ worktreeRemoveInput: { directory: wt.directory } });
12525
+ removed++;
12526
+ } catch {}
12527
+ }
12528
+ } catch {}
12529
+ return { removed };
12530
+ }
12501
12531
  async function recoverUndeliveredMessages(db, client, registry2) {
12502
12532
  const teams = db.query("SELECT id, lead_session_id FROM team WHERE status = 'active'").all();
12503
12533
  let redelivered = 0;
@@ -12703,6 +12733,71 @@ async function notifyWorkingProgress(client, db, teamId) {
12703
12733
  } catch {}
12704
12734
  }
12705
12735
 
12736
+ // src/system-prompt.ts
12737
+ var STATUS_DISPLAY = {
12738
+ busy: "working",
12739
+ ready: "idle",
12740
+ shutdown_requested: "shutting down",
12741
+ shutdown: "shut down",
12742
+ error: "error"
12743
+ };
12744
+ function buildLeadSystemPrompt(db, teamId) {
12745
+ const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId);
12746
+ if (!team)
12747
+ return "";
12748
+ const members = db.query("SELECT name, status FROM team_member WHERE team_id = ?").all(teamId);
12749
+ const taskCounts = db.query("SELECT status, COUNT(*) as count FROM team_task WHERE team_id = ? GROUP BY status").all(teamId);
12750
+ const countMap = {};
12751
+ for (const row of taskCounts) {
12752
+ countMap[row.status] = row.count;
12753
+ }
12754
+ const completed = countMap["completed"] ?? 0;
12755
+ const inProgress = countMap["in_progress"] ?? 0;
12756
+ const pending = countMap["pending"] ?? 0;
12757
+ const memberList = members.map((m) => `${m.name} [${STATUS_DISPLAY[m.status] ?? m.status}]`).join(", ");
12758
+ const teammateLine = members.length > 0 ? `Teammates: ${memberList}` : "Teammates: none";
12759
+ return [
12760
+ `You are leading team "${team.name}" with ${members.length} active teammates.`,
12761
+ teammateLine,
12762
+ `Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`,
12763
+ "",
12764
+ "Teammates work asynchronously and message you when done.",
12765
+ "Do NOT poll team_status or team_tasks_list repeatedly \u2014 wait for messages.",
12766
+ "After spawning teammates, tell the user what you've set up and wait.",
12767
+ "When all teammates finish, summarize results and suggest next steps."
12768
+ ].join(`
12769
+ `);
12770
+ }
12771
+ function buildTeammateSystemPrompt(db, teamId, memberName) {
12772
+ const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId);
12773
+ if (!team)
12774
+ return "";
12775
+ return `You are "${memberName}", a teammate in team "${team.name}". Use team_message to communicate.`;
12776
+ }
12777
+ function buildTeamCompactionContext(db, teamId, role, memberName) {
12778
+ const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId);
12779
+ if (!team)
12780
+ return "";
12781
+ const members = db.query("SELECT name, status FROM team_member WHERE team_id = ?").all(teamId);
12782
+ const taskCounts = db.query("SELECT status, COUNT(*) as count FROM team_task WHERE team_id = ? GROUP BY status").all(teamId);
12783
+ const countMap = {};
12784
+ for (const row of taskCounts) {
12785
+ countMap[row.status] = row.count;
12786
+ }
12787
+ const completed = countMap["completed"] ?? 0;
12788
+ const inProgress = countMap["in_progress"] ?? 0;
12789
+ const pending = countMap["pending"] ?? 0;
12790
+ const roleLine = role === "lead" ? `[Team Context] You are the lead of team "${team.name}".` : `[Team Context] You are a teammate named "${memberName}" in team "${team.name}".`;
12791
+ const memberList = members.map((m) => `${m.name} (${STATUS_DISPLAY[m.status] ?? m.status})`).join(", ");
12792
+ const membersLine = members.length > 0 ? `Members: ${memberList}` : "Members: none";
12793
+ return [
12794
+ roleLine,
12795
+ membersLine,
12796
+ `Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`
12797
+ ].join(`
12798
+ `);
12799
+ }
12800
+
12706
12801
  // src/types.ts
12707
12802
  function findTeamBySession(db, registry2, sessionId) {
12708
12803
  const entry = registry2.getBySession(sessionId);
@@ -12743,6 +12838,7 @@ async function executeTeamCreate(deps, args, sessionId) {
12743
12838
  }
12744
12839
 
12745
12840
  // src/tools/team-spawn.ts
12841
+ import path2 from "path";
12746
12842
  async function executeTeamSpawn(deps, args, sessionId) {
12747
12843
  const nameError = validateMemberName(args.name);
12748
12844
  if (nameError)
@@ -12755,40 +12851,82 @@ async function executeTeamSpawn(deps, args, sessionId) {
12755
12851
  const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.name);
12756
12852
  if (existing)
12757
12853
  throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
12758
- const createResult = await deps.client.session.create({
12854
+ const useWorktree = args.worktree !== false;
12855
+ const usePlanApproval = args.plan_approval === true;
12856
+ let worktreeDir = null;
12857
+ let worktreeBranch = null;
12858
+ if (useWorktree) {
12859
+ const worktreeName = `ensemble-${teamInfo.teamName}-${args.name}`;
12860
+ try {
12861
+ const result = await deps.client.worktree.create({ name: worktreeName });
12862
+ if (result.data) {
12863
+ worktreeDir = result.data.directory;
12864
+ worktreeBranch = result.data.branch;
12865
+ }
12866
+ } catch {
12867
+ try {
12868
+ await deps.client.tui.showToast({
12869
+ title: "Team",
12870
+ message: `Worktree creation failed for ${args.name}, using shared directory`,
12871
+ variant: "warning",
12872
+ duration: 4000
12873
+ });
12874
+ } catch {}
12875
+ }
12876
+ }
12877
+ const createOpts = {
12759
12878
  body: { parentID: sessionId, title: `${args.name} (@${args.agent} teammate)` }
12760
- });
12761
- const childSessionId = createResult.data?.id;
12762
- if (!childSessionId)
12879
+ };
12880
+ if (worktreeDir) {
12881
+ createOpts.directory = worktreeDir;
12882
+ }
12883
+ let childSessionId;
12884
+ try {
12885
+ const createResult = await deps.client.session.create(createOpts);
12886
+ childSessionId = createResult.data?.id;
12887
+ } catch (err) {
12888
+ if (worktreeDir) {
12889
+ try {
12890
+ await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
12891
+ } catch {}
12892
+ }
12893
+ throw new Error(`Failed to create session for teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`);
12894
+ }
12895
+ if (!childSessionId) {
12896
+ if (worktreeDir) {
12897
+ try {
12898
+ await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
12899
+ } catch {}
12900
+ }
12763
12901
  throw new Error("Failed to create teammate session");
12902
+ }
12903
+ const planApproval = usePlanApproval ? "pending" : "none";
12764
12904
  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]);
12905
+ deps.db.run(`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, plan_approval, time_created, time_updated)
12906
+ VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, planApproval, now, now]);
12767
12907
  deps.registry.register(teamInfo.teamId, args.name, childSessionId);
12768
12908
  const context = [
12769
12909
  `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
12910
+ `Your agent type is "${args.agent}".`
12791
12911
  ];
12912
+ if (worktreeBranch) {
12913
+ context.push(`You are working on branch "${worktreeBranch}" in your own worktree. Your changes are isolated from other teammates.`);
12914
+ }
12915
+ if (usePlanApproval) {
12916
+ context.push("", "IMPORTANT: You are in PLAN MODE.", "Read and explore the codebase, then send your implementation plan to the lead via team_message.", "Do NOT write or modify any files until the lead approves your plan.", "Wait for the lead's approval message before proceeding with implementation.");
12917
+ }
12918
+ 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.");
12919
+ try {
12920
+ const agentsPath = path2.join(deps.directory, "AGENTS.md");
12921
+ const file2 = Bun.file(agentsPath);
12922
+ if (await file2.exists()) {
12923
+ const content = await file2.text();
12924
+ const truncated = content.length > 2000 ? content.slice(0, 2000) + `
12925
+ ...(truncated)` : content;
12926
+ context.push("", "Project guidelines (from AGENTS.md):", truncated);
12927
+ }
12928
+ } catch {}
12929
+ context.push("", "Your task:", args.prompt);
12792
12930
  if (args.claim_task) {
12793
12931
  context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`);
12794
12932
  }
@@ -12805,9 +12943,16 @@ async function executeTeamSpawn(deps, args, sessionId) {
12805
12943
  try {
12806
12944
  await deps.client.session.abort({ path: { id: childSessionId } });
12807
12945
  } catch {}
12946
+ if (worktreeDir) {
12947
+ try {
12948
+ await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
12949
+ } catch {}
12950
+ }
12808
12951
  throw new Error(`Failed to send initial prompt to teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`);
12809
12952
  }
12810
- return `Teammate "${args.name}" spawned (agent: ${args.agent}). They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`;
12953
+ const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : "";
12954
+ const planInfo = usePlanApproval ? " [plan mode \u2014 will send plan for approval]" : "";
12955
+ return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`;
12811
12956
  }
12812
12957
 
12813
12958
  // src/tools/team-message.ts
@@ -12819,20 +12964,40 @@ async function executeTeamMessage(deps, args, sessionId) {
12819
12964
  const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to);
12820
12965
  if (!recipientSessionId)
12821
12966
  throw new Error(`Recipient "${args.to}" not found in team "${teamInfo.teamName}"`);
12967
+ let messageText = args.text;
12968
+ if (args.approve || args.reject) {
12969
+ if (args.approve && args.reject) {
12970
+ throw new Error("Cannot both approve and reject a plan.");
12971
+ }
12972
+ if (teamInfo.role !== "lead") {
12973
+ throw new Error("Only the lead can approve or reject plans.");
12974
+ }
12975
+ const recipient = deps.db.query("SELECT plan_approval FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.to);
12976
+ if (!recipient || recipient.plan_approval !== "pending") {
12977
+ throw new Error(`Recipient "${args.to}" is not in plan approval mode (plan_approval is not pending).`);
12978
+ }
12979
+ if (args.approve) {
12980
+ deps.db.run("UPDATE team_member SET plan_approval = 'approved', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamInfo.teamId, args.to]);
12981
+ messageText = `[Plan Approved] ${args.text}`;
12982
+ } else {
12983
+ deps.db.run("UPDATE team_member SET plan_approval = 'rejected', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamInfo.teamId, args.to]);
12984
+ messageText = `[Plan Rejected: ${args.reject}] ${args.text}`;
12985
+ }
12986
+ }
12822
12987
  const msgId = sendMessage(deps.db, {
12823
12988
  teamId: teamInfo.teamId,
12824
12989
  from: senderName,
12825
12990
  to: args.to,
12826
- content: args.text
12991
+ content: messageText
12827
12992
  });
12828
12993
  const isToLead = args.to === "lead";
12829
12994
  const MAX_LEAD_MSG = 500;
12830
12995
  let deliveryText;
12831
- if (isToLead && args.text.length > MAX_LEAD_MSG) {
12832
- const truncated = args.text.slice(0, MAX_LEAD_MSG);
12996
+ if (isToLead && messageText.length > MAX_LEAD_MSG) {
12997
+ const truncated = messageText.slice(0, MAX_LEAD_MSG);
12833
12998
  deliveryText = `[Team message from ${senderName}]: ${truncated}... (use team_results to read full message)`;
12834
12999
  } else {
12835
- deliveryText = `[Team message from ${senderName}]: ${args.text}`;
13000
+ deliveryText = `[Team message from ${senderName}]: ${messageText}`;
12836
13001
  }
12837
13002
  await deps.client.session.promptAsync({
12838
13003
  path: { id: recipientSessionId },
@@ -12888,33 +13053,47 @@ async function executeTeamShutdown(deps, args, sessionId) {
12888
13053
  throw new Error("This session is not in a team.");
12889
13054
  if (teamInfo.role !== "lead")
12890
13055
  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);
13056
+ 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
13057
  if (!member)
12893
13058
  throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
12894
13059
  if (member.status === "shutdown")
12895
13060
  throw new Error(`Teammate "${args.member}" is already shut down`);
12896
- deps.db.run("UPDATE team_member SET status = 'shutdown_requested', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamInfo.teamId, args.member]);
12897
- try {
12898
- await deps.client.session.abort({ path: { id: member.session_id } });
12899
- } catch {
12900
- try {
12901
- await deps.client.tui.showToast({
12902
- title: "Team",
12903
- message: `Failed to abort ${args.member} session \u2014 will rely on event hook`,
12904
- variant: "warning",
12905
- duration: 4000
12906
- });
12907
- } catch {}
13061
+ const force = args.force ?? false;
13062
+ if (member.status === "shutdown_requested") {
13063
+ await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id);
13064
+ const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : "";
13065
+ return `Force shut down "${args.member}".${branchInfo}`;
12908
13066
  }
13067
+ let isIdle = false;
12909
13068
  try {
12910
13069
  const statuses = await deps.client.session.status();
12911
13070
  const sessionStatus = statuses.data?.[member.session_id];
12912
- if (!sessionStatus || sessionStatus.type === "idle") {
12913
- 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.`;
12915
- }
13071
+ isIdle = !sessionStatus || sessionStatus.type === "idle";
13072
+ } catch {}
13073
+ if (isIdle || force) {
13074
+ await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id);
13075
+ const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : "";
13076
+ return `Teammate "${args.member}" has been shut down.${branchInfo}`;
13077
+ }
13078
+ try {
13079
+ await deps.client.session.promptAsync({
13080
+ path: { id: member.session_id },
13081
+ body: {
13082
+ parts: [{
13083
+ type: "text",
13084
+ text: `[Shutdown requested]: The lead has requested you shut down. Finish your current task, send your final findings to the lead via team_message, then stop.`
13085
+ }]
13086
+ }
13087
+ });
12916
13088
  } catch {}
12917
- return `Shutdown requested for ${args.member}. Will complete when current work finishes.`;
13089
+ deps.db.run("UPDATE team_member SET status = 'shutdown_requested', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamInfo.teamId, args.member]);
13090
+ return `Shutdown requested for ${args.member}. They will finish current work and shut down. Call team_shutdown with force: true to abort immediately.`;
13091
+ }
13092
+ async function abortAndShutdown(deps, teamId, memberName, sessionId) {
13093
+ try {
13094
+ await deps.client.session.abort({ path: { id: sessionId } });
13095
+ } catch {}
13096
+ deps.db.run("UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamId, memberName]);
12918
13097
  }
12919
13098
 
12920
13099
  // src/tools/team-cleanup.ts
@@ -12924,7 +13103,7 @@ async function executeTeamCleanup(deps, args, sessionId) {
12924
13103
  throw new Error("This session is not in a team.");
12925
13104
  if (teamInfo.role !== "lead")
12926
13105
  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);
13106
+ const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch FROM team_member WHERE team_id = ?").all(teamInfo.teamId);
12928
13107
  const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
12929
13108
  if (active.length > 0 && !args.force) {
12930
13109
  const names = active.map((m) => m.name).join(", ");
@@ -12937,8 +13116,27 @@ async function executeTeamCleanup(deps, args, sessionId) {
12937
13116
  } catch {}
12938
13117
  }
12939
13118
  }
13119
+ const branches = [];
13120
+ for (const member of members) {
13121
+ if (member.worktree_dir) {
13122
+ try {
13123
+ await deps.client.worktree.remove({ worktreeRemoveInput: { directory: member.worktree_dir } });
13124
+ } catch {}
13125
+ }
13126
+ if (member.worktree_branch) {
13127
+ branches.push(member.worktree_branch);
13128
+ }
13129
+ }
12940
13130
  deps.db.run("UPDATE team SET status = 'archived', time_updated = ? WHERE id = ?", [Date.now(), teamInfo.teamId]);
12941
13131
  deps.registry.unregisterTeam(teamInfo.teamId);
13132
+ if (branches.length > 0) {
13133
+ const mergeCommands = branches.map((b) => ` git merge ${b}`).join(`
13134
+ `);
13135
+ return `Team "${teamInfo.teamName}" cleaned up. Worktrees removed.
13136
+
13137
+ Merge teammate branches:
13138
+ ${mergeCommands}`;
13139
+ }
12942
13140
  return `Team "${teamInfo.teamName}" cleaned up.`;
12943
13141
  }
12944
13142
 
@@ -13064,7 +13262,7 @@ async function executeTeamStatus(deps, sessionId) {
13064
13262
  return "No changes since last check.";
13065
13263
  }
13066
13264
  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);
13265
+ const members = deps.db.query("SELECT name, session_id, agent, status, execution_status, worktree_branch, plan_approval FROM team_member WHERE team_id = ? ORDER BY time_created ASC").all(teamInfo.teamId);
13068
13266
  const tasks = deps.db.query("SELECT status FROM team_task WHERE team_id = ?").all(teamInfo.teamId);
13069
13267
  const lines = [];
13070
13268
  lines.push(`Team: ${teamInfo.teamName} (you are the ${teamInfo.role})`);
@@ -13075,7 +13273,9 @@ async function executeTeamStatus(deps, sessionId) {
13075
13273
  lines.push("Members:");
13076
13274
  for (const m of members) {
13077
13275
  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}`);
13276
+ const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : "";
13277
+ const plan = m.plan_approval !== "none" ? `, plan: ${m.plan_approval}` : "";
13278
+ lines.push(` ${m.name} [${statusIcon}${plan}] agent: ${m.agent}${branch} session: ${m.session_id}`);
13079
13279
  }
13080
13280
  }
13081
13281
  if (tasks.length > 0) {
@@ -13255,12 +13455,12 @@ var DEFAULT_WATCHDOG_TTL_MS = 30 * 60 * 1000;
13255
13455
  var DEFAULT_WATCHDOG_CHECK_MS = 60 * 1000;
13256
13456
  var plugin = async (input) => {
13257
13457
  const dbPath = getDbPath();
13258
- mkdirSync(path2.dirname(dbPath), { recursive: true });
13458
+ mkdirSync(path3.dirname(dbPath), { recursive: true });
13259
13459
  const db = createDb(dbPath);
13260
13460
  const registry2 = new MemberRegistry;
13261
13461
  const tracker = new DescendantTracker;
13262
13462
  const client = input.client;
13263
- const deps = { db, registry: registry2, tracker, client };
13463
+ const deps = { db, registry: registry2, tracker, client, directory: input.directory };
13264
13464
  const recovery = await recoverStaleMembers(db, client);
13265
13465
  if (recovery.interrupted > 0) {
13266
13466
  const members = db.query("SELECT tm.team_id, tm.name, tm.session_id FROM team_member tm JOIN team t ON tm.team_id = t.id WHERE t.status = 'active'").all();
@@ -13269,6 +13469,7 @@ var plugin = async (input) => {
13269
13469
  }
13270
13470
  }
13271
13471
  recoverUndeliveredMessages(db, client, registry2).catch(() => {});
13472
+ recoverOrphanedWorktrees(db, client).catch(() => {});
13272
13473
  const rateLimitEnv = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT;
13273
13474
  const rateLimitCapacity = rateLimitEnv === "0" ? 0 : parseInt(rateLimitEnv ?? "", 10) || DEFAULT_RATE_LIMIT_CAPACITY;
13274
13475
  const rateLimiter = new TokenBucket({
@@ -13334,6 +13535,38 @@ var plugin = async (input) => {
13334
13535
  }
13335
13536
  }
13336
13537
  },
13538
+ "experimental.chat.system.transform": async (input2, output) => {
13539
+ if (!input2.sessionID)
13540
+ return;
13541
+ const teamInfo = findTeamBySession(db, registry2, input2.sessionID);
13542
+ if (!teamInfo)
13543
+ return;
13544
+ const prompt = teamInfo.role === "lead" ? buildLeadSystemPrompt(db, teamInfo.teamId) : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName);
13545
+ output.system.push(prompt);
13546
+ },
13547
+ "experimental.session.compacting": async (input2, output) => {
13548
+ const teamInfo = findTeamBySession(db, registry2, input2.sessionID);
13549
+ if (!teamInfo)
13550
+ return;
13551
+ const context = buildTeamCompactionContext(db, teamInfo.teamId, teamInfo.role, teamInfo.memberName);
13552
+ output.context.push(context);
13553
+ },
13554
+ "shell.env": async (input2, output) => {
13555
+ if (!input2.sessionID)
13556
+ return;
13557
+ const teamInfo = findTeamBySession(db, registry2, input2.sessionID);
13558
+ if (!teamInfo)
13559
+ return;
13560
+ output.env.ENSEMBLE_TEAM = teamInfo.teamName;
13561
+ output.env.ENSEMBLE_ROLE = teamInfo.role;
13562
+ if (teamInfo.memberName) {
13563
+ output.env.ENSEMBLE_MEMBER = teamInfo.memberName;
13564
+ const member = db.query("SELECT worktree_branch FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, teamInfo.memberName);
13565
+ if (member?.worktree_branch) {
13566
+ output.env.ENSEMBLE_BRANCH = member.worktree_branch;
13567
+ }
13568
+ }
13569
+ },
13337
13570
  tool: {
13338
13571
  team_create: tool({
13339
13572
  description: "Create a new agent team. You become the team lead. Use this before spawning teammates.",
@@ -13347,13 +13580,15 @@ var plugin = async (input) => {
13347
13580
  }
13348
13581
  }),
13349
13582
  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.",
13583
+ 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
13584
  args: {
13352
13585
  name: tool.schema.string().describe("Teammate name (lowercase alphanumeric with hyphens)"),
13353
13586
  agent: tool.schema.string().default("build").describe("Agent type (e.g. 'build', 'plan', 'explore')"),
13354
13587
  prompt: tool.schema.string().describe("Task instructions for the teammate"),
13355
13588
  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)")
13589
+ claim_task: tool.schema.string().optional().describe("Task ID to auto-claim for this teammate (optional)"),
13590
+ worktree: tool.schema.boolean().default(true).describe("Create a git worktree for file isolation (default: true, set false for read-only agents)"),
13591
+ plan_approval: tool.schema.boolean().default(false).describe("Require teammate to send a plan for approval before writing files (default: false)")
13357
13592
  },
13358
13593
  async execute(args, ctx) {
13359
13594
  const result = await executeTeamSpawn(deps, args, ctx.sessionID);
@@ -13365,7 +13600,9 @@ var plugin = async (input) => {
13365
13600
  description: "Send a message to a specific teammate or to the lead. Use 'lead' to message the team lead.",
13366
13601
  args: {
13367
13602
  to: tool.schema.string().describe("Recipient name ('lead' or teammate name)"),
13368
- text: tool.schema.string().describe("Message content (max 10KB)")
13603
+ text: tool.schema.string().describe("Message content (max 10KB)"),
13604
+ approve: tool.schema.boolean().optional().describe("Approve a teammate's plan (only when recipient has plan_approval='pending')"),
13605
+ reject: tool.schema.string().optional().describe("Reject a teammate's plan with reason (only when recipient has plan_approval='pending')")
13369
13606
  },
13370
13607
  async execute(args, ctx) {
13371
13608
  const result = await executeTeamMessage(deps, args, ctx.sessionID);
@@ -13444,9 +13681,10 @@ var plugin = async (input) => {
13444
13681
  }
13445
13682
  }),
13446
13683
  team_shutdown: tool({
13447
- description: "Request a teammate to shut down. The teammate finishes current work then stops.",
13684
+ description: "Request a teammate to shut down. The teammate finishes current work then stops. " + "Pass force: true to abort immediately without waiting.",
13448
13685
  args: {
13449
- member: tool.schema.string().describe("Teammate name to shut down")
13686
+ member: tool.schema.string().describe("Teammate name to shut down"),
13687
+ force: tool.schema.boolean().default(false).describe("Force immediate abort without waiting for current work to finish")
13450
13688
  },
13451
13689
  async execute(args, ctx) {
13452
13690
  const result = await executeTeamShutdown(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.5.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",
@@ -38,7 +38,7 @@
38
38
  "typescript": "^5"
39
39
  },
40
40
  "dependencies": {
41
- "@opencode-ai/plugin": "^1.2.26",
42
- "@opencode-ai/sdk": "^1.2.26"
41
+ "@opencode-ai/plugin": "^1.2.27",
42
+ "@opencode-ai/sdk": "^1.2.27"
43
43
  }
44
44
  }