@hueyexe/opencode-ensemble 0.6.5 → 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
@@ -15776,7 +15776,7 @@ function buildLeadSystemPrompt(db, teamId) {
15776
15776
  }
15777
15777
  lines.push("--- End Messages ---");
15778
15778
  }
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.");
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.");
15780
15780
  return lines.join(`
15781
15781
  `);
15782
15782
  }
@@ -15860,10 +15860,41 @@ async function executeTeamCreate(deps, args, sessionId) {
15860
15860
  return `Team "${args.name}" created. You are the lead. Use team_spawn to add teammates.`;
15861
15861
  }
15862
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
+
15863
15891
  // src/tools/team-spawn.ts
15864
15892
  function getSpawnTimeout() {
15865
15893
  return Number(process.env.SPAWN_TIMEOUT_MS) || 120000;
15866
15894
  }
15895
+ function isWorktreeDirectory(dir) {
15896
+ return dir.includes("/opencode/worktree/");
15897
+ }
15867
15898
  function withTimeout(promise2, ms, label) {
15868
15899
  let timer;
15869
15900
  return Promise.race([
@@ -15877,15 +15908,11 @@ async function executeTeamSpawn(deps, args, sessionId) {
15877
15908
  const nameError = validateMemberName(args.name);
15878
15909
  if (nameError)
15879
15910
  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.");
15911
+ const teamInfo = requireLead(deps, sessionId);
15885
15912
  const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.name);
15886
15913
  if (existing)
15887
15914
  throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
15888
- const useWorktree = args.worktree !== false;
15915
+ const useWorktree = args.worktree !== false && !isWorktreeDirectory(deps.directory);
15889
15916
  const usePlanApproval = args.plan_approval === true;
15890
15917
  log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`);
15891
15918
  let worktreeDir = null;
@@ -15927,18 +15954,24 @@ async function executeTeamSpawn(deps, args, sessionId) {
15927
15954
  }
15928
15955
  const isReadOnly = args.agent === "plan" || args.agent === "explore";
15929
15956
  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;
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" })));
15935
15968
  let childSessionId;
15936
15969
  try {
15937
15970
  log(`spawn:session:start name=${args.name}`);
15938
15971
  const createResult = await withTimeout(deps.client.session.create({
15939
15972
  parentID: sessionId,
15940
15973
  title: `${args.name} (@${args.agent} teammate)`,
15941
- ...permission ? { permission } : {},
15974
+ permission,
15942
15975
  ...workspaceId ? { workspaceID: workspaceId } : {}
15943
15976
  }), getSpawnTimeout(), `session.create for "${args.name}"`);
15944
15977
  childSessionId = createResult.data?.id;
@@ -15989,7 +16022,29 @@ async function executeTeamSpawn(deps, args, sessionId) {
15989
16022
  if (usePlanApproval) {
15990
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.");
15991
16024
  }
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.");
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.");
15993
16048
  context.push("", "Your task:", args.prompt);
15994
16049
  if (args.claim_task) {
15995
16050
  context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`);
@@ -16035,9 +16090,7 @@ async function executeTeamSpawn(deps, args, sessionId) {
16035
16090
 
16036
16091
  // src/tools/team-message.ts
16037
16092
  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.");
16093
+ const teamInfo = requireTeamMember(deps, sessionId);
16041
16094
  const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
16042
16095
  const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to);
16043
16096
  if (!recipientSessionId)
@@ -16093,9 +16146,7 @@ async function executeTeamMessage(deps, args, sessionId) {
16093
16146
 
16094
16147
  // src/tools/team-broadcast.ts
16095
16148
  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.");
16149
+ const teamInfo = requireTeamMember(deps, sessionId);
16099
16150
  const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
16100
16151
  const msgId = broadcastMessage(deps.db, {
16101
16152
  teamId: teamInfo.teamId,
@@ -16131,13 +16182,9 @@ async function executeTeamBroadcast(deps, args, sessionId) {
16131
16182
  }
16132
16183
 
16133
16184
  // 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);
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);
16141
16188
  if (!member)
16142
16189
  throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
16143
16190
  if (member.status === "shutdown")
@@ -16146,7 +16193,8 @@ async function executeTeamShutdown(deps, args, sessionId) {
16146
16193
  if (member.status === "shutdown_requested") {
16147
16194
  await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id);
16148
16195
  const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : "";
16149
- 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}`;
16150
16198
  }
16151
16199
  let isIdle = false;
16152
16200
  try {
@@ -16157,7 +16205,8 @@ async function executeTeamShutdown(deps, args, sessionId) {
16157
16205
  if (isIdle || force) {
16158
16206
  await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id);
16159
16207
  const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : "";
16160
- 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}`;
16161
16210
  }
16162
16211
  try {
16163
16212
  await deps.client.session.promptAsync({
@@ -16177,20 +16226,50 @@ async function abortAndShutdown(deps, teamId, memberName, sessionId) {
16177
16226
  } catch {}
16178
16227
  deps.db.run("UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamId, memberName]);
16179
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
+ }
16180
16241
 
16181
16242
  // 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.");
16243
+ async function executeTeamCleanup(deps, args, sessionId, isDirty = checkWorktreeDirty) {
16244
+ const teamInfo = requireLead(deps, sessionId);
16188
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);
16189
16246
  const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
16190
16247
  if (active.length > 0 && !args.force) {
16191
16248
  const names = active.map((m) => m.name).join(", ");
16192
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.`);
16193
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
+ }
16194
16273
  if (args.force) {
16195
16274
  for (const member of active) {
16196
16275
  try {
@@ -16231,9 +16310,7 @@ ${mergeCommands}`;
16231
16310
 
16232
16311
  // src/tools/team-tasks-list.ts
16233
16312
  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.");
16313
+ const teamInfo = requireTeamMember(deps, sessionId);
16237
16314
  const tasks = deps.db.query("SELECT * FROM team_task WHERE team_id = ? ORDER BY time_created ASC").all(teamInfo.teamId);
16238
16315
  if (tasks.length === 0)
16239
16316
  return "No tasks on the board.";
@@ -16243,9 +16320,7 @@ async function executeTeamTasksList(deps, sessionId) {
16243
16320
 
16244
16321
  // src/tools/team-tasks-add.ts
16245
16322
  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.");
16323
+ const teamInfo = requireTeamMember(deps, sessionId);
16249
16324
  const ids = [];
16250
16325
  const now = Date.now();
16251
16326
  for (const task of args.tasks) {
@@ -16268,9 +16343,7 @@ async function executeTeamTasksAdd(deps, args, sessionId) {
16268
16343
 
16269
16344
  // src/tools/team-tasks-complete.ts
16270
16345
  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.");
16346
+ const teamInfo = requireTeamMember(deps, sessionId);
16274
16347
  const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?").get(args.task_id, teamInfo.teamId);
16275
16348
  if (!task)
16276
16349
  throw new Error(`Task "${args.task_id}" not found`);
@@ -16301,9 +16374,7 @@ async function executeTeamTasksComplete(deps, args, sessionId) {
16301
16374
 
16302
16375
  // src/tools/team-claim.ts
16303
16376
  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.");
16377
+ const teamInfo = requireTeamMember(deps, sessionId);
16307
16378
  const claimerName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
16308
16379
  const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?").get(args.task_id, teamInfo.teamId);
16309
16380
  if (!task)
@@ -16323,9 +16394,7 @@ async function executeTeamClaim(deps, args, sessionId) {
16323
16394
 
16324
16395
  // src/tools/team-results.ts
16325
16396
  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");
16397
+ const team = requireTeamMember(deps, sessionId);
16329
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);
16330
16399
  if (rows.length === 0)
16331
16400
  return "No unread messages.";
@@ -16342,16 +16411,14 @@ ${r.content}
16342
16411
  var lastCallTime = new Map;
16343
16412
  var RATE_LIMIT_MS = 30000;
16344
16413
  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.");
16414
+ const teamInfo = requireTeamMember(deps, sessionId);
16348
16415
  const now = Date.now();
16349
16416
  const last = lastCallTime.get(teamInfo.teamId);
16350
16417
  if (last !== undefined && now - last < RATE_LIMIT_MS) {
16351
16418
  return "No changes since last check.";
16352
16419
  }
16353
16420
  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);
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);
16355
16422
  const tasks = deps.db.query("SELECT status FROM team_task WHERE team_id = ?").all(teamInfo.teamId);
16356
16423
  const lines = [];
16357
16424
  lines.push(`Team: ${teamInfo.teamName} (you are the ${teamInfo.role})`);
@@ -16365,6 +16432,9 @@ async function executeTeamStatus(deps, sessionId) {
16365
16432
  const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : "";
16366
16433
  const plan = m.plan_approval !== "none" ? `, plan: ${m.plan_approval}` : "";
16367
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
+ }
16368
16438
  }
16369
16439
  }
16370
16440
  if (tasks.length > 0) {
@@ -16401,9 +16471,7 @@ async function executeTeamStatus(deps, sessionId) {
16401
16471
 
16402
16472
  // src/tools/team-view.ts
16403
16473
  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.");
16474
+ const teamInfo = requireTeamMember(deps, sessionId);
16407
16475
  const member = deps.db.query("SELECT session_id, status, agent FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
16408
16476
  if (!member)
16409
16477
  throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
@@ -16493,7 +16561,28 @@ class Watchdog {
16493
16561
  this.ttlMs = opts.ttlMs;
16494
16562
  this.checkIntervalMs = opts.checkIntervalMs ?? 60000;
16495
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
+ }
16496
16584
  async check() {
16585
+ await this.cleanupStaleWorktrees();
16497
16586
  if (this.ttlMs === 0)
16498
16587
  return;
16499
16588
  const cutoff = Date.now() - this.ttlMs;
@@ -16521,8 +16610,6 @@ class Watchdog {
16521
16610
  start() {
16522
16611
  if (this.timer)
16523
16612
  return;
16524
- if (this.ttlMs === 0)
16525
- return;
16526
16613
  this.timer = setInterval(() => this.check(), this.checkIntervalMs);
16527
16614
  }
16528
16615
  stop() {
@@ -16811,18 +16898,21 @@ var plugin = async (input) => {
16811
16898
  },
16812
16899
  async execute(args, ctx) {
16813
16900
  const result = await executeTeamShutdown(deps, args, ctx.sessionID);
16814
- 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` });
16815
16903
  return result;
16816
16904
  }
16817
16905
  }),
16818
16906
  team_cleanup: tool({
16819
16907
  description: "Clean up the team. All teammates must be shut down first. Removes team data and frees resources.",
16820
16908
  args: {
16821
- 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)
16822
16911
  },
16823
16912
  async execute(args, ctx) {
16824
16913
  const result = await executeTeamCleanup(deps, args, ctx.sessionID);
16825
- 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" });
16826
16916
  return result;
16827
16917
  }
16828
16918
  }),
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.0",
4
4
  "description": "Agent teams for OpenCode — parallel agents with peer-to-peer communication, shared tasks, and coordinated execution",
5
5
  "module": "src/index.ts",
6
6
  "main": "dist/index.js",
package/src/index.ts CHANGED
@@ -361,7 +361,8 @@ const plugin: Plugin = async (input) => {
361
361
  },
362
362
  async execute(args, ctx) {
363
363
  const result = await executeTeamShutdown(deps, args, ctx.sessionID)
364
- 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` })
365
366
  return result
366
367
  },
367
368
  }),
@@ -370,10 +371,12 @@ const plugin: Plugin = async (input) => {
370
371
  description: "Clean up the team. All teammates must be shut down first. Removes team data and frees resources.",
371
372
  args: {
372
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),
373
375
  },
374
376
  async execute(args, ctx) {
375
377
  const result = await executeTeamCleanup(deps, args, ctx.sessionID)
376
- ctx.metadata({ title: "Team cleaned up" })
378
+ const blocked = result.includes("uncommitted")
379
+ ctx.metadata({ title: blocked ? "Cleanup blocked — uncommitted changes" : "Team cleaned up" })
377
380
  return result
378
381
  },
379
382
  }),
@@ -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")
@@ -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