@hueyexe/opencode-ensemble 0.6.5 → 0.7.1

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 # 377 tests
225
247
  bun run build
226
248
  ```
227
249
 
package/dist/index.js CHANGED
@@ -15675,6 +15675,13 @@ function checkToolIsolation(registry2, tracker, toolName, sessionId) {
15675
15675
  throw new Error("Team tools are not available to sub-agents. Report findings to your parent teammate via your normal output.");
15676
15676
  }
15677
15677
  }
15678
+ function shouldNudgeIdleMember(db, teamId, memberName) {
15679
+ const member = db.query("SELECT status FROM team_member WHERE team_id = ? AND name = ?").get(teamId, memberName);
15680
+ if (!member || member.status !== "ready")
15681
+ return false;
15682
+ const msg = db.query("SELECT id FROM team_message WHERE team_id = ? AND from_name = ? AND (to_name = 'lead' OR to_name IS NULL) LIMIT 1").get(teamId, memberName);
15683
+ return !msg;
15684
+ }
15678
15685
 
15679
15686
  // src/notify.ts
15680
15687
  var TOAST_CONFIG = {
@@ -15776,7 +15783,7 @@ function buildLeadSystemPrompt(db, teamId) {
15776
15783
  }
15777
15784
  lines.push("--- End Messages ---");
15778
15785
  }
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.");
15786
+ 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.");
15780
15787
  return lines.join(`
15781
15788
  `);
15782
15789
  }
@@ -15784,7 +15791,7 @@ function buildTeammateSystemPrompt(db, teamId, memberName) {
15784
15791
  const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId);
15785
15792
  if (!team)
15786
15793
  return "";
15787
- return `You are "${memberName}", a teammate in team "${team.name}". Use team_message to communicate.`;
15794
+ return `You are "${memberName}", a teammate in team "${team.name}". Use team_message to communicate. You MUST send your results to the lead via team_message before stopping.`;
15788
15795
  }
15789
15796
  function buildTeamCompactionContext(db, teamId, role, memberName) {
15790
15797
  const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId);
@@ -15802,11 +15809,15 @@ function buildTeamCompactionContext(db, teamId, role, memberName) {
15802
15809
  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}".`;
15803
15810
  const memberList = members.map((m) => `${m.name} (${STATUS_DISPLAY[m.status] ?? m.status})`).join(", ");
15804
15811
  const membersLine = members.length > 0 ? `Members: ${memberList}` : "Members: none";
15805
- return [
15812
+ const lines = [
15806
15813
  roleLine,
15807
15814
  membersLine,
15808
15815
  `Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`
15809
- ].join(`
15816
+ ];
15817
+ if (role === "member") {
15818
+ lines.push("IMPORTANT: You MUST send your results to the lead via team_message before stopping.");
15819
+ }
15820
+ return lines.join(`
15810
15821
  `);
15811
15822
  }
15812
15823
 
@@ -15860,10 +15871,41 @@ async function executeTeamCreate(deps, args, sessionId) {
15860
15871
  return `Team "${args.name}" created. You are the lead. Use team_spawn to add teammates.`;
15861
15872
  }
15862
15873
 
15874
+ // src/tools/shared.ts
15875
+ async function checkWorktreeDirty(dir) {
15876
+ try {
15877
+ const proc = Bun.spawn(["git", "-C", dir, "status", "--porcelain"], { stdout: "pipe", stderr: "pipe" });
15878
+ const output = await new Response(proc.stdout).text();
15879
+ const exitCode = await proc.exited;
15880
+ if (exitCode !== 0)
15881
+ return false;
15882
+ return output.trim().length > 0;
15883
+ } catch {
15884
+ return false;
15885
+ }
15886
+ }
15887
+ function requireLead(deps, sessionId) {
15888
+ const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
15889
+ if (!teamInfo)
15890
+ throw new Error("This session is not in a team. Use team_create first.");
15891
+ if (teamInfo.role !== "lead")
15892
+ throw new Error("Only the team lead can use this tool.");
15893
+ return { teamId: teamInfo.teamId, teamName: teamInfo.teamName };
15894
+ }
15895
+ function requireTeamMember(deps, sessionId) {
15896
+ const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
15897
+ if (!teamInfo)
15898
+ throw new Error("This session is not in a team.");
15899
+ return teamInfo;
15900
+ }
15901
+
15863
15902
  // src/tools/team-spawn.ts
15864
15903
  function getSpawnTimeout() {
15865
15904
  return Number(process.env.SPAWN_TIMEOUT_MS) || 120000;
15866
15905
  }
15906
+ function isWorktreeDirectory(dir) {
15907
+ return dir.includes("/opencode/worktree/");
15908
+ }
15867
15909
  function withTimeout(promise2, ms, label) {
15868
15910
  let timer;
15869
15911
  return Promise.race([
@@ -15877,15 +15919,11 @@ async function executeTeamSpawn(deps, args, sessionId) {
15877
15919
  const nameError = validateMemberName(args.name);
15878
15920
  if (nameError)
15879
15921
  throw new Error(nameError);
15880
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
15881
- if (!teamInfo)
15882
- throw new Error("This session is not in a team. Use team_create first.");
15883
- if (teamInfo.role !== "lead")
15884
- throw new Error("Only the team lead can spawn teammates.");
15922
+ const teamInfo = requireLead(deps, sessionId);
15885
15923
  const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.name);
15886
15924
  if (existing)
15887
15925
  throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
15888
- const useWorktree = args.worktree !== false;
15926
+ const useWorktree = args.worktree !== false && !isWorktreeDirectory(deps.directory);
15889
15927
  const usePlanApproval = args.plan_approval === true;
15890
15928
  log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`);
15891
15929
  let worktreeDir = null;
@@ -15927,18 +15965,24 @@ async function executeTeamSpawn(deps, args, sessionId) {
15927
15965
  }
15928
15966
  const isReadOnly = args.agent === "plan" || args.agent === "explore";
15929
15967
  const TEAM_TOOLS = ["team_message", "team_broadcast", "team_tasks_list", "team_tasks_add", "team_tasks_complete", "team_claim"];
15930
- const permission = isReadOnly ? [
15931
- { permission: "edit", pattern: "*", action: "deny" },
15932
- { permission: "bash", pattern: "*", action: "deny" },
15933
- ...TEAM_TOOLS.map((t) => ({ permission: t, pattern: "*", action: "allow" }))
15934
- ] : undefined;
15968
+ const permission = [];
15969
+ if (worktreeDir) {
15970
+ permission.push({ permission: "edit", pattern: `${worktreeDir}/**`, action: "allow" });
15971
+ if (!isReadOnly) {
15972
+ permission.push({ permission: "bash", pattern: "*", action: "allow" });
15973
+ }
15974
+ }
15975
+ if (isReadOnly) {
15976
+ permission.push({ permission: "edit", pattern: "*", action: "deny" }, { permission: "bash", pattern: "*", action: "deny" });
15977
+ }
15978
+ permission.push(...TEAM_TOOLS.map((t) => ({ permission: t, pattern: "*", action: "allow" })));
15935
15979
  let childSessionId;
15936
15980
  try {
15937
15981
  log(`spawn:session:start name=${args.name}`);
15938
15982
  const createResult = await withTimeout(deps.client.session.create({
15939
15983
  parentID: sessionId,
15940
15984
  title: `${args.name} (@${args.agent} teammate)`,
15941
- ...permission ? { permission } : {},
15985
+ permission,
15942
15986
  ...workspaceId ? { workspaceID: workspaceId } : {}
15943
15987
  }), getSpawnTimeout(), `session.create for "${args.name}"`);
15944
15988
  childSessionId = createResult.data?.id;
@@ -15989,7 +16033,29 @@ async function executeTeamSpawn(deps, args, sessionId) {
15989
16033
  if (usePlanApproval) {
15990
16034
  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.");
15991
16035
  }
15992
- 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.");
16036
+ if (isReadOnly) {
16037
+ 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");
16038
+ } else {
16039
+ 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");
16040
+ }
16041
+ context.push("", "When you finish your task:");
16042
+ if (!isReadOnly && worktreeBranch) {
16043
+ context.push(`1. Commit your changes: git add -A && git commit -m "your summary"`);
16044
+ context.push("2. If you claimed a task, mark it complete using team_tasks_complete.");
16045
+ context.push("3. Send ONE message to the lead using team_message with this format:");
16046
+ } else if (!isReadOnly) {
16047
+ context.push("1. If you claimed a task, mark it complete using team_tasks_complete.");
16048
+ context.push("2. Send ONE message to the lead using team_message with this format:");
16049
+ } else {
16050
+ context.push("1. Send ONE message to the lead using team_message with this format:");
16051
+ }
16052
+ 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>");
16053
+ if (worktreeBranch) {
16054
+ context.push(`<branch>${worktreeBranch}</branch>`);
16055
+ }
16056
+ context.push("</task-result>");
16057
+ const lastStep = !isReadOnly && worktreeBranch ? "4" : !isReadOnly ? "3" : "2";
16058
+ 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.");
15993
16059
  context.push("", "Your task:", args.prompt);
15994
16060
  if (args.claim_task) {
15995
16061
  context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`);
@@ -16035,9 +16101,7 @@ async function executeTeamSpawn(deps, args, sessionId) {
16035
16101
 
16036
16102
  // src/tools/team-message.ts
16037
16103
  async function executeTeamMessage(deps, args, sessionId) {
16038
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
16039
- if (!teamInfo)
16040
- throw new Error("This session is not in a team. Use team_create first.");
16104
+ const teamInfo = requireTeamMember(deps, sessionId);
16041
16105
  const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
16042
16106
  const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to);
16043
16107
  if (!recipientSessionId)
@@ -16093,9 +16157,7 @@ async function executeTeamMessage(deps, args, sessionId) {
16093
16157
 
16094
16158
  // src/tools/team-broadcast.ts
16095
16159
  async function executeTeamBroadcast(deps, args, sessionId) {
16096
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
16097
- if (!teamInfo)
16098
- throw new Error("This session is not in a team. Use team_create first.");
16160
+ const teamInfo = requireTeamMember(deps, sessionId);
16099
16161
  const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
16100
16162
  const msgId = broadcastMessage(deps.db, {
16101
16163
  teamId: teamInfo.teamId,
@@ -16131,13 +16193,9 @@ async function executeTeamBroadcast(deps, args, sessionId) {
16131
16193
  }
16132
16194
 
16133
16195
  // src/tools/team-shutdown.ts
16134
- async function executeTeamShutdown(deps, args, sessionId) {
16135
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
16136
- if (!teamInfo)
16137
- throw new Error("This session is not in a team.");
16138
- if (teamInfo.role !== "lead")
16139
- throw new Error("Only the team lead can shut down teammates.");
16140
- const member = deps.db.query("SELECT session_id, status, worktree_branch FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
16196
+ async function executeTeamShutdown(deps, args, sessionId, isDirty = checkWorktreeDirty) {
16197
+ const teamInfo = requireLead(deps, sessionId);
16198
+ 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);
16141
16199
  if (!member)
16142
16200
  throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
16143
16201
  if (member.status === "shutdown")
@@ -16146,7 +16204,8 @@ async function executeTeamShutdown(deps, args, sessionId) {
16146
16204
  if (member.status === "shutdown_requested") {
16147
16205
  await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id);
16148
16206
  const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : "";
16149
- return `Force shut down "${args.member}".${branchInfo}`;
16207
+ const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty);
16208
+ return `Force shut down "${args.member}".${branchInfo}${dirtyWarning}`;
16150
16209
  }
16151
16210
  let isIdle = false;
16152
16211
  try {
@@ -16157,7 +16216,8 @@ async function executeTeamShutdown(deps, args, sessionId) {
16157
16216
  if (isIdle || force) {
16158
16217
  await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id);
16159
16218
  const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : "";
16160
- return `Teammate "${args.member}" has been shut down.${branchInfo}`;
16219
+ const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty);
16220
+ return `Teammate "${args.member}" has been shut down.${branchInfo}${dirtyWarning}`;
16161
16221
  }
16162
16222
  try {
16163
16223
  await deps.client.session.promptAsync({
@@ -16177,20 +16237,50 @@ async function abortAndShutdown(deps, teamId, memberName, sessionId) {
16177
16237
  } catch {}
16178
16238
  deps.db.run("UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamId, memberName]);
16179
16239
  }
16240
+ async function getDirtyWarning(worktreeDir, memberName, isDirty) {
16241
+ if (!worktreeDir)
16242
+ return "";
16243
+ try {
16244
+ if (await isDirty(worktreeDir)) {
16245
+ return `
16246
+
16247
+ Warning: ${memberName} has uncommitted changes in their worktree. Commit or merge before calling team_cleanup.`;
16248
+ }
16249
+ } catch {}
16250
+ return "";
16251
+ }
16180
16252
 
16181
16253
  // src/tools/team-cleanup.ts
16182
- async function executeTeamCleanup(deps, args, sessionId) {
16183
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
16184
- if (!teamInfo)
16185
- throw new Error("This session is not in a team.");
16186
- if (teamInfo.role !== "lead")
16187
- throw new Error("Only the team lead can clean up the team.");
16254
+ async function executeTeamCleanup(deps, args, sessionId, isDirty = checkWorktreeDirty) {
16255
+ const teamInfo = requireLead(deps, sessionId);
16188
16256
  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);
16189
16257
  const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
16190
16258
  if (active.length > 0 && !args.force) {
16191
16259
  const names = active.map((m) => m.name).join(", ");
16192
16260
  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.`);
16193
16261
  }
16262
+ if (!args.acknowledge_uncommitted) {
16263
+ const dirty = [];
16264
+ for (const member of members) {
16265
+ if (member.worktree_dir) {
16266
+ try {
16267
+ if (await isDirty(member.worktree_dir)) {
16268
+ dirty.push({ name: member.name, branch: member.worktree_branch ?? "unknown" });
16269
+ }
16270
+ } catch {
16271
+ log(`cleanup:dirty-check:failed name=${member.name}`);
16272
+ }
16273
+ }
16274
+ }
16275
+ if (dirty.length > 0) {
16276
+ const warnings = dirty.map((d) => ` - ${d.name} (branch: ${d.branch})`).join(`
16277
+ `);
16278
+ return `Warning: ${dirty.length} teammate(s) have uncommitted changes in their worktrees:
16279
+ ${warnings}
16280
+
16281
+ Commit or merge their work first, then call team_cleanup with acknowledge_uncommitted: true to proceed.`;
16282
+ }
16283
+ }
16194
16284
  if (args.force) {
16195
16285
  for (const member of active) {
16196
16286
  try {
@@ -16231,9 +16321,7 @@ ${mergeCommands}`;
16231
16321
 
16232
16322
  // src/tools/team-tasks-list.ts
16233
16323
  async function executeTeamTasksList(deps, sessionId) {
16234
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
16235
- if (!teamInfo)
16236
- throw new Error("This session is not in a team.");
16324
+ const teamInfo = requireTeamMember(deps, sessionId);
16237
16325
  const tasks = deps.db.query("SELECT * FROM team_task WHERE team_id = ? ORDER BY time_created ASC").all(teamInfo.teamId);
16238
16326
  if (tasks.length === 0)
16239
16327
  return "No tasks on the board.";
@@ -16243,9 +16331,7 @@ async function executeTeamTasksList(deps, sessionId) {
16243
16331
 
16244
16332
  // src/tools/team-tasks-add.ts
16245
16333
  async function executeTeamTasksAdd(deps, args, sessionId) {
16246
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
16247
- if (!teamInfo)
16248
- throw new Error("This session is not in a team.");
16334
+ const teamInfo = requireTeamMember(deps, sessionId);
16249
16335
  const ids = [];
16250
16336
  const now = Date.now();
16251
16337
  for (const task of args.tasks) {
@@ -16268,9 +16354,7 @@ async function executeTeamTasksAdd(deps, args, sessionId) {
16268
16354
 
16269
16355
  // src/tools/team-tasks-complete.ts
16270
16356
  async function executeTeamTasksComplete(deps, args, sessionId) {
16271
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
16272
- if (!teamInfo)
16273
- throw new Error("This session is not in a team.");
16357
+ const teamInfo = requireTeamMember(deps, sessionId);
16274
16358
  const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?").get(args.task_id, teamInfo.teamId);
16275
16359
  if (!task)
16276
16360
  throw new Error(`Task "${args.task_id}" not found`);
@@ -16301,9 +16385,7 @@ async function executeTeamTasksComplete(deps, args, sessionId) {
16301
16385
 
16302
16386
  // src/tools/team-claim.ts
16303
16387
  async function executeTeamClaim(deps, args, sessionId) {
16304
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
16305
- if (!teamInfo)
16306
- throw new Error("This session is not in a team.");
16388
+ const teamInfo = requireTeamMember(deps, sessionId);
16307
16389
  const claimerName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
16308
16390
  const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?").get(args.task_id, teamInfo.teamId);
16309
16391
  if (!task)
@@ -16323,9 +16405,7 @@ async function executeTeamClaim(deps, args, sessionId) {
16323
16405
 
16324
16406
  // src/tools/team-results.ts
16325
16407
  async function executeTeamResults(deps, args, sessionId) {
16326
- const team = findTeamBySession(deps.db, deps.registry, sessionId);
16327
- if (!team)
16328
- throw new Error("not in a team");
16408
+ const team = requireTeamMember(deps, sessionId);
16329
16409
  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);
16330
16410
  if (rows.length === 0)
16331
16411
  return "No unread messages.";
@@ -16342,16 +16422,14 @@ ${r.content}
16342
16422
  var lastCallTime = new Map;
16343
16423
  var RATE_LIMIT_MS = 30000;
16344
16424
  async function executeTeamStatus(deps, sessionId) {
16345
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
16346
- if (!teamInfo)
16347
- throw new Error("This session is not in a team.");
16425
+ const teamInfo = requireTeamMember(deps, sessionId);
16348
16426
  const now = Date.now();
16349
16427
  const last = lastCallTime.get(teamInfo.teamId);
16350
16428
  if (last !== undefined && now - last < RATE_LIMIT_MS) {
16351
16429
  return "No changes since last check.";
16352
16430
  }
16353
16431
  lastCallTime.set(teamInfo.teamId, now);
16354
- 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);
16432
+ 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);
16355
16433
  const tasks = deps.db.query("SELECT status FROM team_task WHERE team_id = ?").all(teamInfo.teamId);
16356
16434
  const lines = [];
16357
16435
  lines.push(`Team: ${teamInfo.teamName} (you are the ${teamInfo.role})`);
@@ -16365,6 +16443,9 @@ async function executeTeamStatus(deps, sessionId) {
16365
16443
  const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : "";
16366
16444
  const plan = m.plan_approval !== "none" ? `, plan: ${m.plan_approval}` : "";
16367
16445
  lines.push(` ${m.name} [${statusIcon}${plan}] agent: ${m.agent}${branch} session: ${m.session_id}`);
16446
+ if (m.worktree_dir) {
16447
+ lines.push(` worktree: ${m.worktree_dir}`);
16448
+ }
16368
16449
  }
16369
16450
  }
16370
16451
  if (tasks.length > 0) {
@@ -16401,9 +16482,7 @@ async function executeTeamStatus(deps, sessionId) {
16401
16482
 
16402
16483
  // src/tools/team-view.ts
16403
16484
  async function executeTeamView(deps, args, sessionId) {
16404
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
16405
- if (!teamInfo)
16406
- throw new Error("This session is not in a team.");
16485
+ const teamInfo = requireTeamMember(deps, sessionId);
16407
16486
  const member = deps.db.query("SELECT session_id, status, agent FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
16408
16487
  if (!member)
16409
16488
  throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
@@ -16493,7 +16572,28 @@ class Watchdog {
16493
16572
  this.ttlMs = opts.ttlMs;
16494
16573
  this.checkIntervalMs = opts.checkIntervalMs ?? 60000;
16495
16574
  }
16575
+ static STALE_THRESHOLD_MS = Number(process.env.STALE_WORKTREE_THRESHOLD_MS) || 300000;
16576
+ async cleanupStaleWorktrees() {
16577
+ const cutoff = Date.now() - Watchdog.STALE_THRESHOLD_MS;
16578
+ const stale = this.db.query(`SELECT tm.team_id, tm.name, tm.worktree_dir, tm.workspace_id
16579
+ FROM team_member tm
16580
+ JOIN team t ON tm.team_id = t.id
16581
+ WHERE t.status = 'active'
16582
+ AND tm.status IN ('shutdown', 'error')
16583
+ AND tm.worktree_dir IS NOT NULL
16584
+ AND tm.time_updated < ?`).all(cutoff);
16585
+ for (const m of stale) {
16586
+ try {
16587
+ if (m.workspace_id) {
16588
+ await this.client.workspace.remove({ id: m.workspace_id });
16589
+ }
16590
+ await this.client.worktree.remove({ worktreeRemoveInput: { directory: m.worktree_dir } });
16591
+ 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]);
16592
+ } catch {}
16593
+ }
16594
+ }
16496
16595
  async check() {
16596
+ await this.cleanupStaleWorktrees();
16497
16597
  if (this.ttlMs === 0)
16498
16598
  return;
16499
16599
  const cutoff = Date.now() - this.ttlMs;
@@ -16521,8 +16621,6 @@ class Watchdog {
16521
16621
  start() {
16522
16622
  if (this.timer)
16523
16623
  return;
16524
- if (this.ttlMs === 0)
16525
- return;
16526
16624
  this.timer = setInterval(() => this.check(), this.checkIntervalMs);
16527
16625
  }
16528
16626
  stop() {
@@ -16548,6 +16646,7 @@ var plugin = async (input) => {
16548
16646
  const db = createDb(dbPath);
16549
16647
  const registry2 = new MemberRegistry;
16550
16648
  const tracker = new DescendantTracker;
16649
+ const nudgedMembers = new Set;
16551
16650
  const pluginTransport = input.client._client;
16552
16651
  const rawClient = new OpencodeClient({ client: pluginTransport });
16553
16652
  initLog(rawClient);
@@ -16601,6 +16700,15 @@ var plugin = async (input) => {
16601
16700
  notifyTeamEvent(client3, "shutdown", { memberName: transition.memberName });
16602
16701
  } else if (transition.to === "ready" && transition.from === "busy") {
16603
16702
  notifyTeamEvent(client3, "completed", { memberName: transition.memberName });
16703
+ const nudgeKey = `${transition.teamId}:${transition.memberName}`;
16704
+ if (!nudgedMembers.has(nudgeKey) && shouldNudgeIdleMember(db, transition.teamId, transition.memberName)) {
16705
+ nudgedMembers.add(nudgeKey);
16706
+ log(`nudge:idle-without-report name=${transition.memberName}`);
16707
+ client3.session.promptAsync({
16708
+ sessionID,
16709
+ parts: [{ type: "text", text: "[System]: You completed your work but did not report results. Send your findings to the lead via team_message now." }]
16710
+ }).catch(() => {});
16711
+ }
16604
16712
  } else if (transition.to === "error") {
16605
16713
  notifyTeamEvent(client3, "error", { memberName: transition.memberName });
16606
16714
  } else if (transition.to === "retry") {
@@ -16811,18 +16919,21 @@ var plugin = async (input) => {
16811
16919
  },
16812
16920
  async execute(args, ctx) {
16813
16921
  const result = await executeTeamShutdown(deps, args, ctx.sessionID);
16814
- ctx.metadata({ title: `Shutdown \u2192 ${args.member}` });
16922
+ const hasWarning = result.includes("uncommitted");
16923
+ ctx.metadata({ title: hasWarning ? `${args.member} shut down \u2014 uncommitted changes` : `${args.member} shut down` });
16815
16924
  return result;
16816
16925
  }
16817
16926
  }),
16818
16927
  team_cleanup: tool({
16819
16928
  description: "Clean up the team. All teammates must be shut down first. Removes team data and frees resources.",
16820
16929
  args: {
16821
- force: tool.schema.boolean().default(false).describe("Force cleanup even if members are active (will abort them)")
16930
+ force: tool.schema.boolean().default(false).describe("Force cleanup even if members are active (will abort them)"),
16931
+ acknowledge_uncommitted: tool.schema.boolean().default(false)
16822
16932
  },
16823
16933
  async execute(args, ctx) {
16824
16934
  const result = await executeTeamCleanup(deps, args, ctx.sessionID);
16825
- ctx.metadata({ title: "Team cleaned up" });
16935
+ const blocked = result.includes("uncommitted");
16936
+ ctx.metadata({ title: blocked ? "Cleanup blocked \u2014 uncommitted changes" : "Team cleaned up" });
16826
16937
  return result;
16827
16938
  }
16828
16939
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.6.5",
3
+ "version": "0.7.1",
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/hooks.ts CHANGED
@@ -98,3 +98,17 @@ export function checkToolIsolation(
98
98
  throw new Error("Team tools are not available to sub-agents. Report findings to your parent teammate via your normal output.")
99
99
  }
100
100
  }
101
+
102
+ /**
103
+ * Check if a member went idle without ever sending a message to the lead.
104
+ * Returns true if the member is idle/ready and has no outbound messages.
105
+ */
106
+ export function shouldNudgeIdleMember(db: Database, teamId: string, memberName: string): boolean {
107
+ const member = db.query("SELECT status FROM team_member WHERE team_id = ? AND name = ?")
108
+ .get(teamId, memberName) as { status: string } | null
109
+ if (!member || member.status !== "ready") return false
110
+
111
+ const msg = db.query("SELECT id FROM team_message WHERE team_id = ? AND from_name = ? AND (to_name = 'lead' OR to_name IS NULL) LIMIT 1")
112
+ .get(teamId, memberName) as { id: string } | null
113
+ return !msg
114
+ }
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@ import { wrapThrowingClient } from "./client"
8
8
  import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktrees } from "./recovery"
9
9
  import { MemberRegistry, DescendantTracker } from "./state"
10
10
  import { isWorktreeInstance } from "./util"
11
- import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation } from "./hooks"
11
+ import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation, shouldNudgeIdleMember } from "./hooks"
12
12
  import { notifyTeamEvent, notifyWorkingProgress } from "./notify"
13
13
  import { buildLeadSystemPrompt, buildTeammateSystemPrompt, buildTeamCompactionContext } from "./system-prompt"
14
14
  import { log, initLog } from "./log"
@@ -50,6 +50,7 @@ const plugin: Plugin = async (input) => {
50
50
  // Initialize in-memory state
51
51
  const registry = new MemberRegistry()
52
52
  const tracker = new DescendantTracker()
53
+ const nudgedMembers = new Set<string>()
53
54
 
54
55
  // Extract the working HeyAPI transport from the plugin-provided v1 client and pass it
55
56
  // to the v2 OpencodeClient. The plugin framework provides a v1 client which stores its
@@ -123,6 +124,16 @@ const plugin: Plugin = async (input) => {
123
124
  notifyTeamEvent(client, "shutdown", { memberName: transition.memberName })
124
125
  } else if (transition.to === "ready" && transition.from === "busy") {
125
126
  notifyTeamEvent(client, "completed", { memberName: transition.memberName })
127
+ // Nudge teammate if they went idle without reporting to the lead (once only)
128
+ const nudgeKey = `${transition.teamId}:${transition.memberName}`
129
+ if (!nudgedMembers.has(nudgeKey) && shouldNudgeIdleMember(db, transition.teamId, transition.memberName)) {
130
+ nudgedMembers.add(nudgeKey)
131
+ log(`nudge:idle-without-report name=${transition.memberName}`)
132
+ client.session.promptAsync({
133
+ sessionID,
134
+ parts: [{ type: "text", text: "[System]: You completed your work but did not report results. Send your findings to the lead via team_message now." }],
135
+ }).catch(() => { /* best effort */ })
136
+ }
126
137
  } else if (transition.to === "error") {
127
138
  notifyTeamEvent(client, "error", { memberName: transition.memberName })
128
139
  } else if (transition.to === "retry") {
@@ -361,7 +372,8 @@ const plugin: Plugin = async (input) => {
361
372
  },
362
373
  async execute(args, ctx) {
363
374
  const result = await executeTeamShutdown(deps, args, ctx.sessionID)
364
- ctx.metadata({ title: `Shutdown → ${args.member}` })
375
+ const hasWarning = result.includes("uncommitted")
376
+ ctx.metadata({ title: hasWarning ? `${args.member} shut down — uncommitted changes` : `${args.member} shut down` })
365
377
  return result
366
378
  },
367
379
  }),
@@ -370,10 +382,12 @@ const plugin: Plugin = async (input) => {
370
382
  description: "Clean up the team. All teammates must be shut down first. Removes team data and frees resources.",
371
383
  args: {
372
384
  force: tool.schema.boolean().default(false).describe("Force cleanup even if members are active (will abort them)"),
385
+ acknowledge_uncommitted: tool.schema.boolean().default(false),
373
386
  },
374
387
  async execute(args, ctx) {
375
388
  const result = await executeTeamCleanup(deps, args, ctx.sessionID)
376
- ctx.metadata({ title: "Team cleaned up" })
389
+ const blocked = result.includes("uncommitted")
390
+ ctx.metadata({ title: blocked ? "Cleanup blocked — uncommitted changes" : "Team cleaned up" })
377
391
  return result
378
392
  },
379
393
  }),
@@ -75,6 +75,10 @@ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
75
75
  "Do NOT poll team_status or team_tasks_list repeatedly — wait for messages.",
76
76
  "After spawning all teammates, tell the user what you've set up and wait.",
77
77
  "When all teammates finish, summarize results and suggest next steps.",
78
+ "",
79
+ "Before calling team_cleanup, verify teammates have committed their work.",
80
+ "team_shutdown will warn you if a teammate has uncommitted changes.",
81
+ "team_cleanup will block if any worktree has uncommitted changes — merge or commit first.",
78
82
  )
79
83
 
80
84
  return lines.join("\n")
@@ -88,7 +92,7 @@ export function buildTeammateSystemPrompt(db: Database, teamId: string, memberNa
88
92
  const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId) as { name: string } | null
89
93
  if (!team) return ""
90
94
 
91
- return `You are "${memberName}", a teammate in team "${team.name}". Use team_message to communicate.`
95
+ return `You are "${memberName}", a teammate in team "${team.name}". Use team_message to communicate. You MUST send your results to the lead via team_message before stopping.`
92
96
  }
93
97
 
94
98
  /**
@@ -131,9 +135,15 @@ export function buildTeamCompactionContext(
131
135
  ? `Members: ${memberList}`
132
136
  : "Members: none"
133
137
 
134
- return [
138
+ const lines = [
135
139
  roleLine,
136
140
  membersLine,
137
141
  `Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`,
138
- ].join("\n")
142
+ ]
143
+
144
+ if (role === "member") {
145
+ lines.push("IMPORTANT: You MUST send your results to the lead via team_message before stopping.")
146
+ }
147
+
148
+ return lines.join("\n")
139
149
  }
@@ -0,0 +1,39 @@
1
+ import type { ToolDeps } from "../types"
2
+ import { findTeamBySession } from "../types"
3
+
4
+ /** Function type for dirty worktree check — injectable for testing. */
5
+ export type IsDirtyFn = (dir: string) => Promise<boolean>
6
+
7
+ /** Check if a worktree directory has uncommitted changes via git status. */
8
+ export async function checkWorktreeDirty(dir: string): Promise<boolean> {
9
+ try {
10
+ const proc = Bun.spawn(["git", "-C", dir, "status", "--porcelain"], { stdout: "pipe", stderr: "pipe" })
11
+ const output = await new Response(proc.stdout).text()
12
+ const exitCode = await proc.exited
13
+ if (exitCode !== 0) return false // git failed — assume clean
14
+ return output.trim().length > 0
15
+ } catch {
16
+ return false // can't check — assume clean
17
+ }
18
+ }
19
+
20
+ /** Validate that the session belongs to the team lead. Throws if not. */
21
+ export function requireLead(
22
+ deps: Pick<ToolDeps, "db" | "registry">,
23
+ sessionId: string,
24
+ ): { teamId: string; teamName: string } {
25
+ const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
26
+ if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
27
+ if (teamInfo.role !== "lead") throw new Error("Only the team lead can use this tool.")
28
+ return { teamId: teamInfo.teamId, teamName: teamInfo.teamName }
29
+ }
30
+
31
+ /** Validate that the session belongs to any team member (lead or teammate). Throws if not. */
32
+ export function requireTeamMember(
33
+ deps: Pick<ToolDeps, "db" | "registry">,
34
+ sessionId: string,
35
+ ): { teamId: string; teamName: string; role: "lead" | "member"; memberName?: string } {
36
+ const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
37
+ if (!teamInfo) throw new Error("This session is not in a team.")
38
+ return teamInfo
39
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { findTeamBySession } from "../types"
2
+ import { requireTeamMember } from "./shared"
3
3
  import { broadcastMessage, markDelivered } from "../messaging"
4
4
  import { log } from "../log"
5
5
 
@@ -11,8 +11,7 @@ export async function executeTeamBroadcast(
11
11
  args: { text: string },
12
12
  sessionId: string,
13
13
  ): Promise<string> {
14
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
15
- if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
14
+ const teamInfo = requireTeamMember(deps, sessionId)
16
15
 
17
16
  const senderName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
18
17
 
@@ -1,5 +1,5 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { findTeamBySession } from "../types"
2
+ import { requireTeamMember } from "./shared"
3
3
 
4
4
  /**
5
5
  * Execute the team_claim tool. Atomically claims a pending task.
@@ -10,8 +10,7 @@ export async function executeTeamClaim(
10
10
  args: { task_id: string },
11
11
  sessionId: string,
12
12
  ): Promise<string> {
13
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
14
- if (!teamInfo) throw new Error("This session is not in a team.")
13
+ const teamInfo = requireTeamMember(deps, sessionId)
15
14
 
16
15
  const claimerName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
17
16
 
@@ -1,19 +1,21 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { findTeamBySession } from "../types"
2
+ import { requireLead, checkWorktreeDirty } from "./shared"
3
+ import type { IsDirtyFn } from "./shared"
4
+ import { log } from "../log"
3
5
 
4
6
  /**
5
7
  * Execute the team_cleanup tool. Archives the team and cleans up resources.
6
- * Removes worktrees and lists branches for the lead to merge.
7
- * If force=true, aborts all active member sessions first.
8
+ * Checks for uncommitted changes BEFORE aborting sessions or removing worktrees.
9
+ * If force=true, aborts active sessions but still blocks on dirty worktrees.
10
+ * Pass acknowledge_uncommitted=true to remove dirty worktrees.
8
11
  */
9
12
  export async function executeTeamCleanup(
10
13
  deps: ToolDeps,
11
- args: { force: boolean },
14
+ args: { force: boolean; acknowledge_uncommitted?: boolean },
12
15
  sessionId: string,
16
+ isDirty: IsDirtyFn = checkWorktreeDirty,
13
17
  ): Promise<string> {
14
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
15
- if (!teamInfo) throw new Error("This session is not in a team.")
16
- if (teamInfo.role !== "lead") throw new Error("Only the team lead can clean up the team.")
18
+ const teamInfo = requireLead(deps, sessionId)
17
19
 
18
20
  const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch, workspace_id FROM team_member WHERE team_id = ?")
19
21
  .all(teamInfo.teamId) as Array<{ name: string; session_id: string; status: string; worktree_dir: string | null; worktree_branch: string | null; workspace_id: string | null }>
@@ -25,7 +27,28 @@ export async function executeTeamCleanup(
25
27
  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.`)
26
28
  }
27
29
 
28
- // Force-abort active members
30
+ // Check for uncommitted changes BEFORE aborting sessions — agents can still commit if warned
31
+ if (!args.acknowledge_uncommitted) {
32
+ const dirty: Array<{ name: string; branch: string }> = []
33
+ for (const member of members) {
34
+ if (member.worktree_dir) {
35
+ try {
36
+ if (await isDirty(member.worktree_dir)) {
37
+ dirty.push({ name: member.name, branch: member.worktree_branch ?? "unknown" })
38
+ }
39
+ } catch {
40
+ log(`cleanup:dirty-check:failed name=${member.name}`)
41
+ }
42
+ }
43
+ }
44
+
45
+ if (dirty.length > 0) {
46
+ const warnings = dirty.map(d => ` - ${d.name} (branch: ${d.branch})`).join("\n")
47
+ return `Warning: ${dirty.length} teammate(s) have uncommitted changes in their worktrees:\n${warnings}\n\nCommit or merge their work first, then call team_cleanup with acknowledge_uncommitted: true to proceed.`
48
+ }
49
+ }
50
+
51
+ // Force-abort active members (only reached if worktrees are clean or acknowledged)
29
52
  if (args.force) {
30
53
  for (const member of active) {
31
54
  try {
@@ -1,5 +1,6 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { findTeamBySession, resolveRecipientSession } from "../types"
2
+ import { resolveRecipientSession } from "../types"
3
+ import { requireTeamMember } from "./shared"
3
4
  import { sendMessage, markDelivered } from "../messaging"
4
5
  import { log } from "../log"
5
6
 
@@ -12,8 +13,7 @@ export async function executeTeamMessage(
12
13
  args: { to: string; text: string; approve?: boolean; reject?: string },
13
14
  sessionId: string,
14
15
  ): Promise<string> {
15
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
16
- if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
16
+ const teamInfo = requireTeamMember(deps, sessionId)
17
17
 
18
18
  const senderName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
19
19
 
@@ -1,5 +1,5 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { findTeamBySession } from "../types"
2
+ import { requireTeamMember } from "./shared"
3
3
 
4
4
  /** Row shape for unread messages query. */
5
5
  interface UnreadMessageRow {
@@ -18,8 +18,7 @@ export async function executeTeamResults(
18
18
  args: { from?: string },
19
19
  sessionId: string,
20
20
  ): Promise<string> {
21
- const team = findTeamBySession(deps.db, deps.registry, sessionId)
22
- if (!team) throw new Error("not in a team")
21
+ const team = requireTeamMember(deps, sessionId)
23
22
 
24
23
  const rows = args.from
25
24
  ? (deps.db
@@ -1,5 +1,6 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { findTeamBySession } from "../types"
2
+ import { requireLead, checkWorktreeDirty } from "./shared"
3
+ import type { IsDirtyFn } from "./shared"
3
4
 
4
5
  /**
5
6
  * Execute the team_shutdown tool. Requests a teammate to shut down.
@@ -9,18 +10,19 @@ import { findTeamBySession } from "../types"
9
10
  * - If member is idle or force=true, abort immediately and set status='shutdown'.
10
11
  * - If member is busy and force=false, send a shutdown message via promptAsync
11
12
  * and set status='shutdown_requested'. The member finishes work and reports back.
13
+ *
14
+ * Warns if the member's worktree has uncommitted changes.
12
15
  */
13
16
  export async function executeTeamShutdown(
14
17
  deps: ToolDeps,
15
18
  args: { member: string; force?: boolean },
16
19
  sessionId: string,
20
+ isDirty: IsDirtyFn = checkWorktreeDirty,
17
21
  ): Promise<string> {
18
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
19
- if (!teamInfo) throw new Error("This session is not in a team.")
20
- if (teamInfo.role !== "lead") throw new Error("Only the team lead can shut down teammates.")
22
+ const teamInfo = requireLead(deps, sessionId)
21
23
 
22
- const member = deps.db.query("SELECT session_id, status, worktree_branch FROM team_member WHERE team_id = ? AND name = ?")
23
- .get(teamInfo.teamId, args.member) as { session_id: string; status: string; worktree_branch: string | null } | null
24
+ const member = deps.db.query("SELECT session_id, status, worktree_branch, worktree_dir FROM team_member WHERE team_id = ? AND name = ?")
25
+ .get(teamInfo.teamId, args.member) as { session_id: string; status: string; worktree_branch: string | null; worktree_dir: string | null } | null
24
26
  if (!member) throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`)
25
27
  if (member.status === "shutdown") throw new Error(`Teammate "${args.member}" is already shut down`)
26
28
 
@@ -30,7 +32,8 @@ export async function executeTeamShutdown(
30
32
  if (member.status === "shutdown_requested") {
31
33
  await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id)
32
34
  const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : ""
33
- return `Force shut down "${args.member}".${branchInfo}`
35
+ const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty)
36
+ return `Force shut down "${args.member}".${branchInfo}${dirtyWarning}`
34
37
  }
35
38
 
36
39
  // Determine if member is idle or busy
@@ -46,7 +49,8 @@ export async function executeTeamShutdown(
46
49
  if (isIdle || force) {
47
50
  await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id)
48
51
  const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : ""
49
- return `Teammate "${args.member}" has been shut down.${branchInfo}`
52
+ const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty)
53
+ return `Teammate "${args.member}" has been shut down.${branchInfo}${dirtyWarning}`
50
54
  }
51
55
 
52
56
  // Busy + not force → graceful: send shutdown message, set shutdown_requested
@@ -91,3 +95,14 @@ async function abortAndShutdown(
91
95
  [Date.now(), teamId, memberName],
92
96
  )
93
97
  }
98
+
99
+ /** Build a warning suffix if the member's worktree has uncommitted changes. */
100
+ async function getDirtyWarning(worktreeDir: string | null, memberName: string, isDirty: IsDirtyFn): Promise<string> {
101
+ if (!worktreeDir) return ""
102
+ try {
103
+ if (await isDirty(worktreeDir)) {
104
+ return `\n\nWarning: ${memberName} has uncommitted changes in their worktree. Commit or merge before calling team_cleanup.`
105
+ }
106
+ } catch { /* best effort */ }
107
+ return ""
108
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ToolDeps, PermissionRule } from "../types"
2
2
  import { validateMemberName } from "../util"
3
- import { findTeamBySession } from "../types"
3
+ import { requireLead } from "./shared"
4
4
  import { sendMessage } from "../messaging"
5
5
  import { log } from "../log"
6
6
 
@@ -9,6 +9,11 @@ function getSpawnTimeout(): number {
9
9
  return Number(process.env.SPAWN_TIMEOUT_MS) || 120_000
10
10
  }
11
11
 
12
+ /** Returns true if the directory is already inside an OpenCode worktree. */
13
+ function isWorktreeDirectory(dir: string): boolean {
14
+ return dir.includes("/opencode/worktree/")
15
+ }
16
+
12
17
  /** Race a promise against a timeout. Throws if the timeout fires first. Cleans up timer on resolution. */
13
18
  function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
14
19
  let timer: ReturnType<typeof setTimeout>
@@ -34,16 +39,14 @@ export async function executeTeamSpawn(
34
39
  const nameError = validateMemberName(args.name)
35
40
  if (nameError) throw new Error(nameError)
36
41
 
37
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
38
- if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
39
- if (teamInfo.role !== "lead") throw new Error("Only the team lead can spawn teammates.")
42
+ const teamInfo = requireLead(deps, sessionId)
40
43
 
41
44
  // Check duplicate name
42
45
  const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?")
43
46
  .get(teamInfo.teamId, args.name)
44
47
  if (existing) throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`)
45
48
 
46
- const useWorktree = args.worktree !== false
49
+ const useWorktree = args.worktree !== false && !isWorktreeDirectory(deps.directory)
47
50
  const usePlanApproval = args.plan_approval === true
48
51
 
49
52
  log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`)
@@ -99,17 +102,31 @@ export async function executeTeamSpawn(
99
102
  }
100
103
 
101
104
  // Permission rules on session.create are the hard gate (server-enforced).
102
- // For read-only agents, deny write tools and explicitly allow team tools
103
- // so the agent's built-in restrictive permissions don't block them.
105
+ // For read-only agents, deny write tools and explicitly allow team tools.
106
+ // For all agents with worktrees, allowlist the worktree path for edit/bash.
104
107
  const isReadOnly = args.agent === "plan" || args.agent === "explore"
105
108
  const TEAM_TOOLS = ["team_message", "team_broadcast", "team_tasks_list", "team_tasks_add", "team_tasks_complete", "team_claim"] as const
106
- const permission: PermissionRule[] | undefined = isReadOnly
107
- ? [
108
- { permission: "edit", pattern: "*", action: "deny" },
109
- { permission: "bash", pattern: "*", action: "deny" },
110
- ...TEAM_TOOLS.map(t => ({ permission: t, pattern: "*", action: "allow" as const })),
111
- ]
112
- : undefined
109
+ const permission: PermissionRule[] = []
110
+
111
+ if (worktreeDir) {
112
+ permission.push(
113
+ { permission: "edit", pattern: `${worktreeDir}/**`, action: "allow" },
114
+ )
115
+ if (!isReadOnly) {
116
+ permission.push({ permission: "bash", pattern: "*", action: "allow" })
117
+ }
118
+ }
119
+
120
+ if (isReadOnly) {
121
+ permission.push(
122
+ { permission: "edit", pattern: "*", action: "deny" },
123
+ { permission: "bash", pattern: "*", action: "deny" },
124
+ )
125
+ }
126
+
127
+ permission.push(
128
+ ...TEAM_TOOLS.map(t => ({ permission: t, pattern: "*", action: "allow" as const })),
129
+ )
113
130
 
114
131
  // Create child session — bind to workspace if available (server-enforced CWD isolation).
115
132
  // Falls back to no workspace binding if workspace.create failed.
@@ -120,7 +137,7 @@ export async function executeTeamSpawn(
120
137
  deps.client.session.create({
121
138
  parentID: sessionId,
122
139
  title: `${args.name} (@${args.agent} teammate)`,
123
- ...(permission ? { permission } : {}),
140
+ permission,
124
141
  ...(workspaceId ? { workspaceID: workspaceId } : {}),
125
142
  }),
126
143
  getSpawnTimeout(), `session.create for "${args.name}"`
@@ -196,20 +213,55 @@ export async function executeTeamSpawn(
196
213
  )
197
214
  }
198
215
 
216
+ if (isReadOnly) {
217
+ context.push(
218
+ "", "Tools available to you:",
219
+ "- team_message: send a message to the lead or another teammate",
220
+ "- team_broadcast: send a message to all team members",
221
+ "- team_tasks_list: view the shared team task board",
222
+ )
223
+ } else {
224
+ context.push(
225
+ "", "Tools available to you:",
226
+ "- team_message: send a message to the lead or another teammate",
227
+ "- team_broadcast: send a message to all team members",
228
+ "- team_tasks_list: view the shared team task board",
229
+ "- team_tasks_add: add tasks to the shared board",
230
+ "- team_tasks_complete: mark a task complete on the shared board",
231
+ "- team_claim: claim a pending task from the shared board",
232
+ )
233
+ }
234
+
235
+ context.push("", "When you finish your task:")
236
+ if (!isReadOnly && worktreeBranch) {
237
+ context.push(`1. Commit your changes: git add -A && git commit -m "your summary"`)
238
+ context.push("2. If you claimed a task, mark it complete using team_tasks_complete.")
239
+ context.push(
240
+ "3. Send ONE message to the lead using team_message with this format:",
241
+ )
242
+ } else if (!isReadOnly) {
243
+ context.push("1. If you claimed a task, mark it complete using team_tasks_complete.")
244
+ context.push(
245
+ "2. Send ONE message to the lead using team_message with this format:",
246
+ )
247
+ } else {
248
+ context.push(
249
+ "1. Send ONE message to the lead using team_message with this format:",
250
+ )
251
+ }
199
252
  context.push(
200
- "",
201
- "Tools available to you:",
202
- "- team_message: send a message to the lead or another teammate",
203
- "- team_broadcast: send a message to all team members",
204
- "- team_tasks_list: view the shared team task board",
205
- "- team_tasks_add: add tasks to the shared board",
206
- "- team_tasks_complete: mark a task complete on the shared board",
207
- "- team_claim: claim a pending task from the shared board",
208
- "",
209
- "When you finish your task:",
210
- "1. If you claimed a task, mark it complete using team_tasks_complete.",
211
- "2. Send ONE message to the lead with your findings using team_message.",
212
- "3. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.",
253
+ "<task-result>",
254
+ "<status>completed or failed</status>",
255
+ "<summary>One-line summary of what you did</summary>",
256
+ "<details>Full findings or changes made</details>",
257
+ )
258
+ if (worktreeBranch) {
259
+ context.push(`<branch>${worktreeBranch}</branch>`)
260
+ }
261
+ context.push("</task-result>")
262
+ const lastStep = !isReadOnly && worktreeBranch ? "4" : !isReadOnly ? "3" : "2"
263
+ context.push(
264
+ `${lastStep}. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.`,
213
265
  "",
214
266
  "If you are blocked, send ONE message to the lead describing the specific blocker.",
215
267
  "",
@@ -1,5 +1,5 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { findTeamBySession } from "../types"
2
+ import { requireTeamMember } from "./shared"
3
3
 
4
4
  /** Tracks the last time team_status was called per team ID (epoch ms). */
5
5
  export const lastCallTime = new Map<string, number>()
@@ -13,8 +13,7 @@ export async function executeTeamStatus(
13
13
  deps: ToolDeps,
14
14
  sessionId: string,
15
15
  ): Promise<string> {
16
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
17
- if (!teamInfo) throw new Error("This session is not in a team.")
16
+ const teamInfo = requireTeamMember(deps, sessionId)
18
17
 
19
18
  const now = Date.now()
20
19
  const last = lastCallTime.get(teamInfo.teamId)
@@ -24,9 +23,9 @@ export async function executeTeamStatus(
24
23
  lastCallTime.set(teamInfo.teamId, now)
25
24
 
26
25
  const members = deps.db.query(
27
- "SELECT name, session_id, agent, status, execution_status, worktree_branch, plan_approval FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
26
+ "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"
28
27
  ).all(teamInfo.teamId) as Array<{
29
- name: string; session_id: string; agent: string; status: string; execution_status: string; worktree_branch: string | null; plan_approval: string
28
+ name: string; session_id: string; agent: string; status: string; execution_status: string; worktree_branch: string | null; worktree_dir: string | null; plan_approval: string
30
29
  }>
31
30
 
32
31
  const tasks = deps.db.query(
@@ -46,6 +45,9 @@ export async function executeTeamStatus(
46
45
  const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : ""
47
46
  const plan = m.plan_approval !== "none" ? `, plan: ${m.plan_approval}` : ""
48
47
  lines.push(` ${m.name} [${statusIcon}${plan}] agent: ${m.agent}${branch} session: ${m.session_id}`)
48
+ if (m.worktree_dir) {
49
+ lines.push(` worktree: ${m.worktree_dir}`)
50
+ }
49
51
  }
50
52
  }
51
53
 
@@ -1,5 +1,5 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { findTeamBySession } from "../types"
2
+ import { requireTeamMember } from "./shared"
3
3
  import { generateId } from "../util"
4
4
 
5
5
  interface TaskInput {
@@ -17,8 +17,7 @@ export async function executeTeamTasksAdd(
17
17
  args: { tasks: TaskInput[] },
18
18
  sessionId: string,
19
19
  ): Promise<string> {
20
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
21
- if (!teamInfo) throw new Error("This session is not in a team.")
20
+ const teamInfo = requireTeamMember(deps, sessionId)
22
21
 
23
22
  const ids: string[] = []
24
23
  const now = Date.now()
@@ -1,5 +1,5 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { findTeamBySession } from "../types"
2
+ import { requireTeamMember } from "./shared"
3
3
 
4
4
  /**
5
5
  * Execute the team_tasks_complete tool. Marks a task as completed
@@ -10,8 +10,7 @@ export async function executeTeamTasksComplete(
10
10
  args: { task_id: string },
11
11
  sessionId: string,
12
12
  ): Promise<string> {
13
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
14
- if (!teamInfo) throw new Error("This session is not in a team.")
13
+ const teamInfo = requireTeamMember(deps, sessionId)
15
14
 
16
15
  const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?")
17
16
  .get(args.task_id, teamInfo.teamId) as Record<string, unknown> | null
@@ -1,5 +1,5 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { findTeamBySession } from "../types"
2
+ import { requireTeamMember } from "./shared"
3
3
 
4
4
  /**
5
5
  * Execute the team_tasks_list tool. Shows all tasks on the shared board.
@@ -8,8 +8,7 @@ export async function executeTeamTasksList(
8
8
  deps: ToolDeps,
9
9
  sessionId: string,
10
10
  ): Promise<string> {
11
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
12
- if (!teamInfo) throw new Error("This session is not in a team.")
11
+ const teamInfo = requireTeamMember(deps, sessionId)
13
12
 
14
13
  const tasks = deps.db.query(
15
14
  "SELECT * FROM team_task WHERE team_id = ? ORDER BY time_created ASC"
@@ -1,5 +1,5 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { findTeamBySession } from "../types"
2
+ import { requireTeamMember } from "./shared"
3
3
 
4
4
  /**
5
5
  * Execute the team_view tool. Navigates the TUI to a teammate's session
@@ -10,8 +10,7 @@ export async function executeTeamView(
10
10
  args: { member: string },
11
11
  sessionId: string,
12
12
  ): Promise<string> {
13
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
14
- if (!teamInfo) throw new Error("This session is not in a team.")
13
+ const teamInfo = requireTeamMember(deps, sessionId)
15
14
 
16
15
  const member = deps.db.query("SELECT session_id, status, agent FROM team_member WHERE team_id = ? AND name = ?")
17
16
  .get(teamInfo.teamId, args.member) as { session_id: string; status: string; agent: string } | null
package/src/watchdog.ts CHANGED
@@ -32,8 +32,38 @@ export class Watchdog {
32
32
  this.checkIntervalMs = opts.checkIntervalMs ?? 60_000
33
33
  }
34
34
 
35
+ private static STALE_THRESHOLD_MS = Number(process.env.STALE_WORKTREE_THRESHOLD_MS) || 300_000
36
+
37
+ /** Clean up worktrees and workspaces for shutdown/error members past the stale threshold. */
38
+ async cleanupStaleWorktrees(): Promise<void> {
39
+ const cutoff = Date.now() - Watchdog.STALE_THRESHOLD_MS
40
+ const stale = this.db.query(
41
+ `SELECT tm.team_id, tm.name, tm.worktree_dir, tm.workspace_id
42
+ FROM team_member tm
43
+ JOIN team t ON tm.team_id = t.id
44
+ WHERE t.status = 'active'
45
+ AND tm.status IN ('shutdown', 'error')
46
+ AND tm.worktree_dir IS NOT NULL
47
+ AND tm.time_updated < ?`
48
+ ).all(cutoff) as Array<{ team_id: string; name: string; worktree_dir: string; workspace_id: string | null }>
49
+
50
+ for (const m of stale) {
51
+ try {
52
+ if (m.workspace_id) {
53
+ await this.client.workspace.remove({ id: m.workspace_id })
54
+ }
55
+ await this.client.worktree.remove({ worktreeRemoveInput: { directory: m.worktree_dir } })
56
+ this.db.run(
57
+ "UPDATE team_member SET worktree_dir = NULL, worktree_branch = NULL, workspace_id = NULL WHERE team_id = ? AND name = ?",
58
+ [m.team_id, m.name]
59
+ )
60
+ } catch { /* best effort */ }
61
+ }
62
+ }
63
+
35
64
  /** Run a single check for stale busy members. */
36
65
  async check(): Promise<void> {
66
+ await this.cleanupStaleWorktrees()
37
67
  if (this.ttlMs === 0) return
38
68
 
39
69
  const cutoff = Date.now() - this.ttlMs
@@ -70,10 +100,9 @@ export class Watchdog {
70
100
  }
71
101
  }
72
102
 
73
- /** Start the periodic check. */
103
+ /** Start the periodic check. Runs stale worktree GC regardless of TTL setting. */
74
104
  start(): void {
75
105
  if (this.timer) return
76
- if (this.ttlMs === 0) return
77
106
  this.timer = setInterval(() => this.check(), this.checkIntervalMs)
78
107
  }
79
108