@hueyexe/opencode-ensemble 0.6.4 → 0.7.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
@@ -79,7 +79,11 @@ Lead: "All validation and tests are complete. 5 endpoints validated,
79
79
 
80
80
  ## Install
81
81
 
82
- Add the plugin to your OpenCode config. Project-level or global.
82
+ Two steps: add the plugin, then allowlist worktree paths.
83
+
84
+ ### 1. Add the plugin
85
+
86
+ Add to your OpenCode config. Project-level or global.
83
87
 
84
88
  **Project-level** (`opencode.json` in your project root):
85
89
 
@@ -99,6 +103,24 @@ Add the plugin to your OpenCode config. Project-level or global.
99
103
 
100
104
  OpenCode auto-installs npm plugins at startup (cached in `~/.cache/opencode/node_modules/`). No `npm install` needed.
101
105
 
106
+ ### 2. Allow worktree directory access
107
+
108
+ Teammates work in git worktrees outside your project directory. Without this permission, OpenCode will prompt you to approve every file operation in a teammate's worktree.
109
+
110
+ Add to your OpenCode config (`~/.config/opencode/opencode.json`):
111
+
112
+ ```json
113
+ {
114
+ "permission": {
115
+ "external_directory": {
116
+ "~/.local/share/opencode/worktree/**": "allow"
117
+ }
118
+ }
119
+ }
120
+ ```
121
+
122
+ This is required. Without it, you'll see "Permission required — Access external directory" prompts constantly.
123
+
102
124
  ### Local development
103
125
 
104
126
  For working on the plugin itself, create `.opencode/plugins/ensemble.ts` in your project:
@@ -221,7 +243,7 @@ Same coordination model (shared tasks, peer messaging, lead coordination) with s
221
243
  ```bash
222
244
  bun install
223
245
  bun run typecheck
224
- bun test # 286 tests
246
+ bun test # 370 tests
225
247
  bun run build
226
248
  ```
227
249
 
package/dist/index.js CHANGED
@@ -15366,7 +15366,8 @@ var MIGRATIONS = [
15366
15366
  ALTER TABLE team_member ADD COLUMN worktree_branch TEXT;`,
15367
15367
  `ALTER TABLE team_member ADD COLUMN plan_approval TEXT NOT NULL DEFAULT 'none'
15368
15368
  CHECK(plan_approval IN ('none', 'pending', 'approved', 'rejected'));`,
15369
- `ALTER TABLE team ADD COLUMN lead_agent TEXT;`
15369
+ `ALTER TABLE team ADD COLUMN lead_agent TEXT;`,
15370
+ `ALTER TABLE team_member ADD COLUMN workspace_id TEXT;`
15370
15371
  ];
15371
15372
  function applyMigrations(db) {
15372
15373
  const { user_version: current } = db.query("PRAGMA user_version").get();
@@ -15426,6 +15427,11 @@ function wrapThrowingClient(raw) {
15426
15427
  remove: throwing(r.worktree.remove.bind(r.worktree)),
15427
15428
  list: throwing(r.worktree.list.bind(r.worktree)),
15428
15429
  reset: throwing(r.worktree.reset.bind(r.worktree))
15430
+ },
15431
+ workspace: {
15432
+ create: throwing(r.experimental.workspace.create.bind(r.experimental.workspace)),
15433
+ remove: throwing(r.experimental.workspace.remove.bind(r.experimental.workspace)),
15434
+ list: throwing(r.experimental.workspace.list.bind(r.experimental.workspace))
15429
15435
  }
15430
15436
  };
15431
15437
  }
@@ -15770,7 +15776,7 @@ function buildLeadSystemPrompt(db, teamId) {
15770
15776
  }
15771
15777
  lines.push("--- End Messages ---");
15772
15778
  }
15773
- lines.push("", "CRITICAL: Spawn teammates ONE AT A TIME. Send only ONE team_spawn call per response.", "Wait for the tool result before spawning the next teammate.", "Multiple team_spawn calls in a single response will cause timeouts.", "", "Teammates work asynchronously and message you when done.", "Do NOT poll team_status or team_tasks_list repeatedly \u2014 wait for messages.", "After spawning all teammates, tell the user what you've set up and wait.", "When all teammates finish, summarize results and suggest next steps.");
15779
+ lines.push("", "CRITICAL: Spawn teammates ONE AT A TIME. Send only ONE team_spawn call per response.", "Wait for the tool result before spawning the next teammate.", "Multiple team_spawn calls in a single response will cause timeouts.", "", "Teammates work asynchronously and message you when done.", "Do NOT poll team_status or team_tasks_list repeatedly \u2014 wait for messages.", "After spawning all teammates, tell the user what you've set up and wait.", "When all teammates finish, summarize results and suggest next steps.", "", "Before calling team_cleanup, verify teammates have committed their work.", "team_shutdown will warn you if a teammate has uncommitted changes.", "team_cleanup will block if any worktree has uncommitted changes \u2014 merge or commit first.");
15774
15780
  return lines.join(`
15775
15781
  `);
15776
15782
  }
@@ -15854,10 +15860,41 @@ async function executeTeamCreate(deps, args, sessionId) {
15854
15860
  return `Team "${args.name}" created. You are the lead. Use team_spawn to add teammates.`;
15855
15861
  }
15856
15862
 
15863
+ // src/tools/shared.ts
15864
+ async function checkWorktreeDirty(dir) {
15865
+ try {
15866
+ const proc = Bun.spawn(["git", "-C", dir, "status", "--porcelain"], { stdout: "pipe", stderr: "pipe" });
15867
+ const output = await new Response(proc.stdout).text();
15868
+ const exitCode = await proc.exited;
15869
+ if (exitCode !== 0)
15870
+ return false;
15871
+ return output.trim().length > 0;
15872
+ } catch {
15873
+ return false;
15874
+ }
15875
+ }
15876
+ function requireLead(deps, sessionId) {
15877
+ const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
15878
+ if (!teamInfo)
15879
+ throw new Error("This session is not in a team. Use team_create first.");
15880
+ if (teamInfo.role !== "lead")
15881
+ throw new Error("Only the team lead can use this tool.");
15882
+ return { teamId: teamInfo.teamId, teamName: teamInfo.teamName };
15883
+ }
15884
+ function requireTeamMember(deps, sessionId) {
15885
+ const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
15886
+ if (!teamInfo)
15887
+ throw new Error("This session is not in a team.");
15888
+ return teamInfo;
15889
+ }
15890
+
15857
15891
  // src/tools/team-spawn.ts
15858
15892
  function getSpawnTimeout() {
15859
15893
  return Number(process.env.SPAWN_TIMEOUT_MS) || 120000;
15860
15894
  }
15895
+ function isWorktreeDirectory(dir) {
15896
+ return dir.includes("/opencode/worktree/");
15897
+ }
15861
15898
  function withTimeout(promise2, ms, label) {
15862
15899
  let timer;
15863
15900
  return Promise.race([
@@ -15871,15 +15908,11 @@ async function executeTeamSpawn(deps, args, sessionId) {
15871
15908
  const nameError = validateMemberName(args.name);
15872
15909
  if (nameError)
15873
15910
  throw new Error(nameError);
15874
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
15875
- if (!teamInfo)
15876
- throw new Error("This session is not in a team. Use team_create first.");
15877
- if (teamInfo.role !== "lead")
15878
- throw new Error("Only the team lead can spawn teammates.");
15911
+ const teamInfo = requireLead(deps, sessionId);
15879
15912
  const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.name);
15880
15913
  if (existing)
15881
15914
  throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
15882
- const useWorktree = args.worktree !== false;
15915
+ const useWorktree = args.worktree !== false && !isWorktreeDirectory(deps.directory);
15883
15916
  const usePlanApproval = args.plan_approval === true;
15884
15917
  log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`);
15885
15918
  let worktreeDir = null;
@@ -15906,26 +15939,50 @@ async function executeTeamSpawn(deps, args, sessionId) {
15906
15939
  } catch {}
15907
15940
  }
15908
15941
  }
15942
+ let workspaceId = null;
15943
+ if (worktreeDir && worktreeBranch) {
15944
+ try {
15945
+ log(`spawn:workspace:start name=${args.name}`);
15946
+ const wsResult = await withTimeout(deps.client.workspace.create({ branch: worktreeBranch }), getSpawnTimeout(), `workspace.create for "${args.name}"`);
15947
+ if (wsResult.data) {
15948
+ workspaceId = wsResult.data.id;
15949
+ }
15950
+ log(`spawn:workspace:done name=${args.name} id=${workspaceId}`);
15951
+ } catch (err) {
15952
+ log(`spawn:workspace:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`);
15953
+ }
15954
+ }
15909
15955
  const isReadOnly = args.agent === "plan" || args.agent === "explore";
15910
15956
  const TEAM_TOOLS = ["team_message", "team_broadcast", "team_tasks_list", "team_tasks_add", "team_tasks_complete", "team_claim"];
15911
- const permission = isReadOnly ? [
15912
- { permission: "edit", pattern: "*", action: "deny" },
15913
- { permission: "bash", pattern: "*", action: "deny" },
15914
- ...TEAM_TOOLS.map((t) => ({ permission: t, pattern: "*", action: "allow" }))
15915
- ] : undefined;
15957
+ const permission = [];
15958
+ if (worktreeDir) {
15959
+ permission.push({ permission: "edit", pattern: `${worktreeDir}/**`, action: "allow" });
15960
+ if (!isReadOnly) {
15961
+ permission.push({ permission: "bash", pattern: "*", action: "allow" });
15962
+ }
15963
+ }
15964
+ if (isReadOnly) {
15965
+ permission.push({ permission: "edit", pattern: "*", action: "deny" }, { permission: "bash", pattern: "*", action: "deny" });
15966
+ }
15967
+ permission.push(...TEAM_TOOLS.map((t) => ({ permission: t, pattern: "*", action: "allow" })));
15916
15968
  let childSessionId;
15917
15969
  try {
15918
15970
  log(`spawn:session:start name=${args.name}`);
15919
15971
  const createResult = await withTimeout(deps.client.session.create({
15920
15972
  parentID: sessionId,
15921
15973
  title: `${args.name} (@${args.agent} teammate)`,
15922
- ...permission ? { permission } : {},
15923
- ...worktreeDir ? { directory: worktreeDir } : {}
15974
+ permission,
15975
+ ...workspaceId ? { workspaceID: workspaceId } : {}
15924
15976
  }), getSpawnTimeout(), `session.create for "${args.name}"`);
15925
15977
  childSessionId = createResult.data?.id;
15926
15978
  log(`spawn:session:done name=${args.name} sessionId=${childSessionId}`);
15927
15979
  } catch (err) {
15928
15980
  log(`spawn:session:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`);
15981
+ if (workspaceId) {
15982
+ try {
15983
+ await deps.client.workspace.remove({ id: workspaceId });
15984
+ } catch {}
15985
+ }
15929
15986
  if (worktreeDir) {
15930
15987
  try {
15931
15988
  await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
@@ -15934,6 +15991,11 @@ async function executeTeamSpawn(deps, args, sessionId) {
15934
15991
  throw new Error(`Failed to create session for teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`);
15935
15992
  }
15936
15993
  if (!childSessionId) {
15994
+ if (workspaceId) {
15995
+ try {
15996
+ await deps.client.workspace.remove({ id: workspaceId });
15997
+ } catch {}
15998
+ }
15937
15999
  if (worktreeDir) {
15938
16000
  try {
15939
16001
  await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
@@ -15943,20 +16005,46 @@ async function executeTeamSpawn(deps, args, sessionId) {
15943
16005
  }
15944
16006
  const planApproval = usePlanApproval ? "pending" : "none";
15945
16007
  const now = Date.now();
15946
- 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)
15947
- VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, planApproval, now, now]);
16008
+ deps.db.run(`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, workspace_id, plan_approval, time_created, time_updated)
16009
+ VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]);
15948
16010
  deps.registry.register(teamInfo.teamId, args.name, childSessionId);
15949
16011
  const context = [
15950
16012
  `You are "${args.name}", a teammate in team "${teamInfo.teamName}".`,
15951
16013
  `Your agent type is "${args.agent}".`
15952
16014
  ];
15953
- if (worktreeBranch) {
15954
- context.push(`You are working on branch "${worktreeBranch}" in your own worktree. Your changes are isolated from other teammates.`);
16015
+ if (worktreeBranch && worktreeDir && !workspaceId) {
16016
+ context.push(`You are working on branch "${worktreeBranch}" in your own worktree at: ${worktreeDir}`, `Your changes are isolated from other teammates.`, `IMPORTANT: All file operations and shell commands MUST target your worktree directory.`, `Before running shell commands, cd to: ${worktreeDir}`);
16017
+ } else if (worktreeBranch && worktreeDir) {
16018
+ context.push(`You are working on branch "${worktreeBranch}" in your own isolated worktree.`, `Your changes are isolated from other teammates.`);
16019
+ } else if (worktreeBranch) {
16020
+ context.push(`You are working on branch "${worktreeBranch}". Your changes are isolated from other teammates.`);
15955
16021
  }
15956
16022
  if (usePlanApproval) {
15957
16023
  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.");
15958
16024
  }
15959
- 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.");
16025
+ if (isReadOnly) {
16026
+ 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");
16027
+ } else {
16028
+ 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");
16029
+ }
16030
+ context.push("", "When you finish your task:");
16031
+ if (!isReadOnly && worktreeBranch) {
16032
+ context.push(`1. Commit your changes: git add -A && git commit -m "your summary"`);
16033
+ context.push("2. If you claimed a task, mark it complete using team_tasks_complete.");
16034
+ context.push("3. Send ONE message to the lead using team_message with this format:");
16035
+ } else if (!isReadOnly) {
16036
+ context.push("1. If you claimed a task, mark it complete using team_tasks_complete.");
16037
+ context.push("2. Send ONE message to the lead using team_message with this format:");
16038
+ } else {
16039
+ context.push("1. Send ONE message to the lead using team_message with this format:");
16040
+ }
16041
+ context.push("<task-result>", "<status>completed or failed</status>", "<summary>One-line summary of what you did</summary>", "<details>Full findings or changes made</details>");
16042
+ if (worktreeBranch) {
16043
+ context.push(`<branch>${worktreeBranch}</branch>`);
16044
+ }
16045
+ context.push("</task-result>");
16046
+ const lastStep = !isReadOnly && worktreeBranch ? "4" : !isReadOnly ? "3" : "2";
16047
+ context.push(`${lastStep}. 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.");
15960
16048
  context.push("", "Your task:", args.prompt);
15961
16049
  if (args.claim_task) {
15962
16050
  context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`);
@@ -15974,6 +16062,9 @@ async function executeTeamSpawn(deps, args, sessionId) {
15974
16062
  deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId]);
15975
16063
  deps.registry.unregister(childSessionId);
15976
16064
  deps.client.session.abort({ sessionID: childSessionId }).catch(() => {});
16065
+ if (workspaceId) {
16066
+ deps.client.workspace.remove({ id: workspaceId }).catch(() => {});
16067
+ }
15977
16068
  if (worktreeDir) {
15978
16069
  deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => {});
15979
16070
  }
@@ -15999,9 +16090,7 @@ async function executeTeamSpawn(deps, args, sessionId) {
15999
16090
 
16000
16091
  // src/tools/team-message.ts
16001
16092
  async function executeTeamMessage(deps, args, sessionId) {
16002
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
16003
- if (!teamInfo)
16004
- throw new Error("This session is not in a team. Use team_create first.");
16093
+ const teamInfo = requireTeamMember(deps, sessionId);
16005
16094
  const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
16006
16095
  const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to);
16007
16096
  if (!recipientSessionId)
@@ -16057,9 +16146,7 @@ async function executeTeamMessage(deps, args, sessionId) {
16057
16146
 
16058
16147
  // src/tools/team-broadcast.ts
16059
16148
  async function executeTeamBroadcast(deps, args, sessionId) {
16060
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
16061
- if (!teamInfo)
16062
- throw new Error("This session is not in a team. Use team_create first.");
16149
+ const teamInfo = requireTeamMember(deps, sessionId);
16063
16150
  const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
16064
16151
  const msgId = broadcastMessage(deps.db, {
16065
16152
  teamId: teamInfo.teamId,
@@ -16095,13 +16182,9 @@ async function executeTeamBroadcast(deps, args, sessionId) {
16095
16182
  }
16096
16183
 
16097
16184
  // src/tools/team-shutdown.ts
16098
- async function executeTeamShutdown(deps, args, sessionId) {
16099
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
16100
- if (!teamInfo)
16101
- throw new Error("This session is not in a team.");
16102
- if (teamInfo.role !== "lead")
16103
- throw new Error("Only the team lead can shut down teammates.");
16104
- const member = deps.db.query("SELECT session_id, status, worktree_branch FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
16185
+ async function executeTeamShutdown(deps, args, sessionId, isDirty = checkWorktreeDirty) {
16186
+ const teamInfo = requireLead(deps, sessionId);
16187
+ const member = deps.db.query("SELECT session_id, status, worktree_branch, worktree_dir FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
16105
16188
  if (!member)
16106
16189
  throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
16107
16190
  if (member.status === "shutdown")
@@ -16110,7 +16193,8 @@ async function executeTeamShutdown(deps, args, sessionId) {
16110
16193
  if (member.status === "shutdown_requested") {
16111
16194
  await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id);
16112
16195
  const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : "";
16113
- return `Force shut down "${args.member}".${branchInfo}`;
16196
+ const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty);
16197
+ return `Force shut down "${args.member}".${branchInfo}${dirtyWarning}`;
16114
16198
  }
16115
16199
  let isIdle = false;
16116
16200
  try {
@@ -16121,7 +16205,8 @@ async function executeTeamShutdown(deps, args, sessionId) {
16121
16205
  if (isIdle || force) {
16122
16206
  await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id);
16123
16207
  const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : "";
16124
- return `Teammate "${args.member}" has been shut down.${branchInfo}`;
16208
+ const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty);
16209
+ return `Teammate "${args.member}" has been shut down.${branchInfo}${dirtyWarning}`;
16125
16210
  }
16126
16211
  try {
16127
16212
  await deps.client.session.promptAsync({
@@ -16141,20 +16226,50 @@ async function abortAndShutdown(deps, teamId, memberName, sessionId) {
16141
16226
  } catch {}
16142
16227
  deps.db.run("UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamId, memberName]);
16143
16228
  }
16229
+ async function getDirtyWarning(worktreeDir, memberName, isDirty) {
16230
+ if (!worktreeDir)
16231
+ return "";
16232
+ try {
16233
+ if (await isDirty(worktreeDir)) {
16234
+ return `
16235
+
16236
+ Warning: ${memberName} has uncommitted changes in their worktree. Commit or merge before calling team_cleanup.`;
16237
+ }
16238
+ } catch {}
16239
+ return "";
16240
+ }
16144
16241
 
16145
16242
  // src/tools/team-cleanup.ts
16146
- async function executeTeamCleanup(deps, args, sessionId) {
16147
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
16148
- if (!teamInfo)
16149
- throw new Error("This session is not in a team.");
16150
- if (teamInfo.role !== "lead")
16151
- throw new Error("Only the team lead can clean up the team.");
16152
- const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch FROM team_member WHERE team_id = ?").all(teamInfo.teamId);
16243
+ async function executeTeamCleanup(deps, args, sessionId, isDirty = checkWorktreeDirty) {
16244
+ const teamInfo = requireLead(deps, sessionId);
16245
+ const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch, workspace_id FROM team_member WHERE team_id = ?").all(teamInfo.teamId);
16153
16246
  const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
16154
16247
  if (active.length > 0 && !args.force) {
16155
16248
  const names = active.map((m) => m.name).join(", ");
16156
16249
  throw new Error(`Cannot clean up team "${teamInfo.teamName}": ${active.length} member(s) still active: ${names}. Use team_shutdown on each member first, or call team_cleanup with force: true to abort them immediately.`);
16157
16250
  }
16251
+ if (!args.acknowledge_uncommitted) {
16252
+ const dirty = [];
16253
+ for (const member of members) {
16254
+ if (member.worktree_dir) {
16255
+ try {
16256
+ if (await isDirty(member.worktree_dir)) {
16257
+ dirty.push({ name: member.name, branch: member.worktree_branch ?? "unknown" });
16258
+ }
16259
+ } catch {
16260
+ log(`cleanup:dirty-check:failed name=${member.name}`);
16261
+ }
16262
+ }
16263
+ }
16264
+ if (dirty.length > 0) {
16265
+ const warnings = dirty.map((d) => ` - ${d.name} (branch: ${d.branch})`).join(`
16266
+ `);
16267
+ return `Warning: ${dirty.length} teammate(s) have uncommitted changes in their worktrees:
16268
+ ${warnings}
16269
+
16270
+ Commit or merge their work first, then call team_cleanup with acknowledge_uncommitted: true to proceed.`;
16271
+ }
16272
+ }
16158
16273
  if (args.force) {
16159
16274
  for (const member of active) {
16160
16275
  try {
@@ -16164,9 +16279,16 @@ async function executeTeamCleanup(deps, args, sessionId) {
16164
16279
  }
16165
16280
  const branches = [];
16166
16281
  for (const member of members) {
16282
+ if (member.workspace_id) {
16283
+ try {
16284
+ await deps.client.workspace.remove({ id: member.workspace_id });
16285
+ deps.db.run("UPDATE team_member SET workspace_id = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name]);
16286
+ } catch {}
16287
+ }
16167
16288
  if (member.worktree_dir) {
16168
16289
  try {
16169
16290
  await deps.client.worktree.remove({ worktreeRemoveInput: { directory: member.worktree_dir } });
16291
+ deps.db.run("UPDATE team_member SET worktree_dir = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name]);
16170
16292
  } catch {}
16171
16293
  }
16172
16294
  if (member.worktree_branch) {
@@ -16188,9 +16310,7 @@ ${mergeCommands}`;
16188
16310
 
16189
16311
  // src/tools/team-tasks-list.ts
16190
16312
  async function executeTeamTasksList(deps, sessionId) {
16191
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
16192
- if (!teamInfo)
16193
- throw new Error("This session is not in a team.");
16313
+ const teamInfo = requireTeamMember(deps, sessionId);
16194
16314
  const tasks = deps.db.query("SELECT * FROM team_task WHERE team_id = ? ORDER BY time_created ASC").all(teamInfo.teamId);
16195
16315
  if (tasks.length === 0)
16196
16316
  return "No tasks on the board.";
@@ -16200,9 +16320,7 @@ async function executeTeamTasksList(deps, sessionId) {
16200
16320
 
16201
16321
  // src/tools/team-tasks-add.ts
16202
16322
  async function executeTeamTasksAdd(deps, args, sessionId) {
16203
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
16204
- if (!teamInfo)
16205
- throw new Error("This session is not in a team.");
16323
+ const teamInfo = requireTeamMember(deps, sessionId);
16206
16324
  const ids = [];
16207
16325
  const now = Date.now();
16208
16326
  for (const task of args.tasks) {
@@ -16225,9 +16343,7 @@ async function executeTeamTasksAdd(deps, args, sessionId) {
16225
16343
 
16226
16344
  // src/tools/team-tasks-complete.ts
16227
16345
  async function executeTeamTasksComplete(deps, args, sessionId) {
16228
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
16229
- if (!teamInfo)
16230
- throw new Error("This session is not in a team.");
16346
+ const teamInfo = requireTeamMember(deps, sessionId);
16231
16347
  const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?").get(args.task_id, teamInfo.teamId);
16232
16348
  if (!task)
16233
16349
  throw new Error(`Task "${args.task_id}" not found`);
@@ -16258,9 +16374,7 @@ async function executeTeamTasksComplete(deps, args, sessionId) {
16258
16374
 
16259
16375
  // src/tools/team-claim.ts
16260
16376
  async function executeTeamClaim(deps, args, sessionId) {
16261
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
16262
- if (!teamInfo)
16263
- throw new Error("This session is not in a team.");
16377
+ const teamInfo = requireTeamMember(deps, sessionId);
16264
16378
  const claimerName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
16265
16379
  const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?").get(args.task_id, teamInfo.teamId);
16266
16380
  if (!task)
@@ -16280,9 +16394,7 @@ async function executeTeamClaim(deps, args, sessionId) {
16280
16394
 
16281
16395
  // src/tools/team-results.ts
16282
16396
  async function executeTeamResults(deps, args, sessionId) {
16283
- const team = findTeamBySession(deps.db, deps.registry, sessionId);
16284
- if (!team)
16285
- throw new Error("not in a team");
16397
+ const team = requireTeamMember(deps, sessionId);
16286
16398
  const rows = args.from ? deps.db.query("SELECT id, from_name, content, time_created FROM team_message WHERE team_id = ? AND read = 0 AND from_name = ? ORDER BY time_created ASC").all(team.teamId, args.from) : deps.db.query("SELECT id, from_name, content, time_created FROM team_message WHERE team_id = ? AND read = 0 ORDER BY time_created ASC").all(team.teamId);
16287
16399
  if (rows.length === 0)
16288
16400
  return "No unread messages.";
@@ -16299,16 +16411,14 @@ ${r.content}
16299
16411
  var lastCallTime = new Map;
16300
16412
  var RATE_LIMIT_MS = 30000;
16301
16413
  async function executeTeamStatus(deps, sessionId) {
16302
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
16303
- if (!teamInfo)
16304
- throw new Error("This session is not in a team.");
16414
+ const teamInfo = requireTeamMember(deps, sessionId);
16305
16415
  const now = Date.now();
16306
16416
  const last = lastCallTime.get(teamInfo.teamId);
16307
16417
  if (last !== undefined && now - last < RATE_LIMIT_MS) {
16308
16418
  return "No changes since last check.";
16309
16419
  }
16310
16420
  lastCallTime.set(teamInfo.teamId, now);
16311
- 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);
16421
+ const members = deps.db.query("SELECT name, session_id, agent, status, execution_status, worktree_branch, worktree_dir, plan_approval FROM team_member WHERE team_id = ? ORDER BY time_created ASC").all(teamInfo.teamId);
16312
16422
  const tasks = deps.db.query("SELECT status FROM team_task WHERE team_id = ?").all(teamInfo.teamId);
16313
16423
  const lines = [];
16314
16424
  lines.push(`Team: ${teamInfo.teamName} (you are the ${teamInfo.role})`);
@@ -16322,6 +16432,9 @@ async function executeTeamStatus(deps, sessionId) {
16322
16432
  const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : "";
16323
16433
  const plan = m.plan_approval !== "none" ? `, plan: ${m.plan_approval}` : "";
16324
16434
  lines.push(` ${m.name} [${statusIcon}${plan}] agent: ${m.agent}${branch} session: ${m.session_id}`);
16435
+ if (m.worktree_dir) {
16436
+ lines.push(` worktree: ${m.worktree_dir}`);
16437
+ }
16325
16438
  }
16326
16439
  }
16327
16440
  if (tasks.length > 0) {
@@ -16358,9 +16471,7 @@ async function executeTeamStatus(deps, sessionId) {
16358
16471
 
16359
16472
  // src/tools/team-view.ts
16360
16473
  async function executeTeamView(deps, args, sessionId) {
16361
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
16362
- if (!teamInfo)
16363
- throw new Error("This session is not in a team.");
16474
+ const teamInfo = requireTeamMember(deps, sessionId);
16364
16475
  const member = deps.db.query("SELECT session_id, status, agent FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
16365
16476
  if (!member)
16366
16477
  throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
@@ -16450,7 +16561,28 @@ class Watchdog {
16450
16561
  this.ttlMs = opts.ttlMs;
16451
16562
  this.checkIntervalMs = opts.checkIntervalMs ?? 60000;
16452
16563
  }
16564
+ static STALE_THRESHOLD_MS = Number(process.env.STALE_WORKTREE_THRESHOLD_MS) || 300000;
16565
+ async cleanupStaleWorktrees() {
16566
+ const cutoff = Date.now() - Watchdog.STALE_THRESHOLD_MS;
16567
+ const stale = this.db.query(`SELECT tm.team_id, tm.name, tm.worktree_dir, tm.workspace_id
16568
+ FROM team_member tm
16569
+ JOIN team t ON tm.team_id = t.id
16570
+ WHERE t.status = 'active'
16571
+ AND tm.status IN ('shutdown', 'error')
16572
+ AND tm.worktree_dir IS NOT NULL
16573
+ AND tm.time_updated < ?`).all(cutoff);
16574
+ for (const m of stale) {
16575
+ try {
16576
+ if (m.workspace_id) {
16577
+ await this.client.workspace.remove({ id: m.workspace_id });
16578
+ }
16579
+ await this.client.worktree.remove({ worktreeRemoveInput: { directory: m.worktree_dir } });
16580
+ this.db.run("UPDATE team_member SET worktree_dir = NULL, worktree_branch = NULL, workspace_id = NULL WHERE team_id = ? AND name = ?", [m.team_id, m.name]);
16581
+ } catch {}
16582
+ }
16583
+ }
16453
16584
  async check() {
16585
+ await this.cleanupStaleWorktrees();
16454
16586
  if (this.ttlMs === 0)
16455
16587
  return;
16456
16588
  const cutoff = Date.now() - this.ttlMs;
@@ -16478,8 +16610,6 @@ class Watchdog {
16478
16610
  start() {
16479
16611
  if (this.timer)
16480
16612
  return;
16481
- if (this.ttlMs === 0)
16482
- return;
16483
16613
  this.timer = setInterval(() => this.check(), this.checkIntervalMs);
16484
16614
  }
16485
16615
  stop() {
@@ -16638,10 +16768,13 @@ var plugin = async (input) => {
16638
16768
  output.env.ENSEMBLE_ROLE = teamInfo.role;
16639
16769
  if (teamInfo.memberName) {
16640
16770
  output.env.ENSEMBLE_MEMBER = teamInfo.memberName;
16641
- const member = db.query("SELECT worktree_branch FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, teamInfo.memberName);
16771
+ const member = db.query("SELECT worktree_branch, worktree_dir FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, teamInfo.memberName);
16642
16772
  if (member?.worktree_branch) {
16643
16773
  output.env.ENSEMBLE_BRANCH = member.worktree_branch;
16644
16774
  }
16775
+ if (member?.worktree_dir) {
16776
+ output.env.ENSEMBLE_WORKTREE_DIR = member.worktree_dir;
16777
+ }
16645
16778
  }
16646
16779
  },
16647
16780
  tool: {
@@ -16765,18 +16898,21 @@ var plugin = async (input) => {
16765
16898
  },
16766
16899
  async execute(args, ctx) {
16767
16900
  const result = await executeTeamShutdown(deps, args, ctx.sessionID);
16768
- ctx.metadata({ title: `Shutdown \u2192 ${args.member}` });
16901
+ const hasWarning = result.includes("uncommitted");
16902
+ ctx.metadata({ title: hasWarning ? `${args.member} shut down \u2014 uncommitted changes` : `${args.member} shut down` });
16769
16903
  return result;
16770
16904
  }
16771
16905
  }),
16772
16906
  team_cleanup: tool({
16773
16907
  description: "Clean up the team. All teammates must be shut down first. Removes team data and frees resources.",
16774
16908
  args: {
16775
- force: tool.schema.boolean().default(false).describe("Force cleanup even if members are active (will abort them)")
16909
+ force: tool.schema.boolean().default(false).describe("Force cleanup even if members are active (will abort them)"),
16910
+ acknowledge_uncommitted: tool.schema.boolean().default(false)
16776
16911
  },
16777
16912
  async execute(args, ctx) {
16778
16913
  const result = await executeTeamCleanup(deps, args, ctx.sessionID);
16779
- ctx.metadata({ title: "Team cleaned up" });
16914
+ const blocked = result.includes("uncommitted");
16915
+ ctx.metadata({ title: blocked ? "Cleanup blocked \u2014 uncommitted changes" : "Team cleaned up" });
16780
16916
  return result;
16781
16917
  }
16782
16918
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.6.4",
3
+ "version": "0.7.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/client.ts CHANGED
@@ -25,6 +25,7 @@ interface RawClient {
25
25
  session: { create: SdkMethod; promptAsync: SdkMethod; abort: SdkMethod; status: SdkMethod }
26
26
  tui: { showToast: SdkMethod; selectSession: SdkMethod }
27
27
  worktree: { create: SdkMethod; remove: SdkMethod; list: SdkMethod; reset: SdkMethod }
28
+ experimental: { workspace: { create: SdkMethod; remove: SdkMethod; list: SdkMethod } }
28
29
  }
29
30
 
30
31
  /**
@@ -50,5 +51,10 @@ export function wrapThrowingClient(raw: unknown): PluginClient {
50
51
  list: throwing(r.worktree.list.bind(r.worktree)),
51
52
  reset: throwing(r.worktree.reset.bind(r.worktree)),
52
53
  },
54
+ workspace: {
55
+ create: throwing(r.experimental.workspace.create.bind(r.experimental.workspace)),
56
+ remove: throwing(r.experimental.workspace.remove.bind(r.experimental.workspace)),
57
+ list: throwing(r.experimental.workspace.list.bind(r.experimental.workspace)),
58
+ },
53
59
  } as PluginClient
54
60
  }
package/src/index.ts CHANGED
@@ -217,11 +217,14 @@ const plugin: Plugin = async (input) => {
217
217
  output.env.ENSEMBLE_ROLE = teamInfo.role
218
218
  if (teamInfo.memberName) {
219
219
  output.env.ENSEMBLE_MEMBER = teamInfo.memberName
220
- const member = db.query("SELECT worktree_branch FROM team_member WHERE team_id = ? AND name = ?")
221
- .get(teamInfo.teamId, teamInfo.memberName) as { worktree_branch: string | null } | null
220
+ const member = db.query("SELECT worktree_branch, worktree_dir FROM team_member WHERE team_id = ? AND name = ?")
221
+ .get(teamInfo.teamId, teamInfo.memberName) as { worktree_branch: string | null; worktree_dir: string | null } | null
222
222
  if (member?.worktree_branch) {
223
223
  output.env.ENSEMBLE_BRANCH = member.worktree_branch
224
224
  }
225
+ if (member?.worktree_dir) {
226
+ output.env.ENSEMBLE_WORKTREE_DIR = member.worktree_dir
227
+ }
225
228
  }
226
229
  },
227
230
 
@@ -358,7 +361,8 @@ const plugin: Plugin = async (input) => {
358
361
  },
359
362
  async execute(args, ctx) {
360
363
  const result = await executeTeamShutdown(deps, args, ctx.sessionID)
361
- ctx.metadata({ title: `Shutdown → ${args.member}` })
364
+ const hasWarning = result.includes("uncommitted")
365
+ ctx.metadata({ title: hasWarning ? `${args.member} shut down — uncommitted changes` : `${args.member} shut down` })
362
366
  return result
363
367
  },
364
368
  }),
@@ -367,10 +371,12 @@ const plugin: Plugin = async (input) => {
367
371
  description: "Clean up the team. All teammates must be shut down first. Removes team data and frees resources.",
368
372
  args: {
369
373
  force: tool.schema.boolean().default(false).describe("Force cleanup even if members are active (will abort them)"),
374
+ acknowledge_uncommitted: tool.schema.boolean().default(false),
370
375
  },
371
376
  async execute(args, ctx) {
372
377
  const result = await executeTeamCleanup(deps, args, ctx.sessionID)
373
- ctx.metadata({ title: "Team cleaned up" })
378
+ const blocked = result.includes("uncommitted")
379
+ ctx.metadata({ title: blocked ? "Cleanup blocked — uncommitted changes" : "Team cleaned up" })
374
380
  return result
375
381
  },
376
382
  }),
package/src/schema.ts CHANGED
@@ -78,6 +78,8 @@ export const MIGRATIONS: string[] = [
78
78
  CHECK(plan_approval IN ('none', 'pending', 'approved', 'rejected'));`,
79
79
  // Migration 5: Track lead's agent mode so message delivery preserves it
80
80
  `ALTER TABLE team ADD COLUMN lead_agent TEXT;`,
81
+ // Migration 6: Track workspace ID for worktree-session binding
82
+ `ALTER TABLE team_member ADD COLUMN workspace_id TEXT;`,
81
83
  ]
82
84
 
83
85
  /**