@hueyexe/opencode-ensemble 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -187,6 +187,28 @@ OPENCODE_ENSEMBLE_TIMEOUT=3600000
187
187
  OPENCODE_ENSEMBLE_TIMEOUT=0
188
188
  ```
189
189
 
190
+ ### Best practices
191
+
192
+ - Start with 2-4 teammates for most tasks. More agents means more coordination overhead.
193
+ - Give each teammate 3-5 specific, self-contained tasks. Vague prompts produce vague results.
194
+ - Start with research before implementation — spawn an explore agent first to understand the codebase, then spawn build agents with that context.
195
+ - Use `worktree: false` for read-only agents (research, review, code analysis). They don't need file isolation.
196
+ - Use `plan_approval: true` for risky changes — the teammate sends a plan first, you review and approve before they write any code.
197
+ - Let the lead coordinate. Don't micromanage teammates — they message you when done or when they're blocked.
198
+ - After spawning, tell the user what you've set up and wait for results. Don't poll `team_status` in a loop.
199
+
200
+ ### How this differs from Claude Code agent teams
201
+
202
+ Claude Code has built-in agent teams. This plugin takes the same coordination model (shared tasks, peer messaging, lead coordination) and adds:
203
+
204
+ - **Git worktree isolation by default** — each teammate gets their own branch, no merge conflicts between parallel agents
205
+ - **System prompt injection** — the lead's system prompt is updated with team state (member statuses, task progress) so it stays aware across turns
206
+ - **Compaction safety** — team context is preserved when sessions get long and OpenCode compacts the conversation
207
+ - **Team-aware shell environment** — teammate shells get `ENSEMBLE_TEAM`, `ENSEMBLE_MEMBER`, `ENSEMBLE_ROLE`, and `ENSEMBLE_BRANCH` variables
208
+ - **Graceful shutdown** — teammates finish their current work before stopping, with a force flag for emergencies
209
+ - **Plan approval mode** — review teammate plans before they write code, useful for risky or unfamiliar changes
210
+ - **Works today as a plugin** — no waiting for upstream features, install and go
211
+
190
212
  ## Development
191
213
 
192
214
  ```bash
package/dist/index.js CHANGED
@@ -1,12 +1,16 @@
1
1
  // @bun
2
2
  var __defProp = Object.defineProperty;
3
+ var __returnValue = (v) => v;
4
+ function __exportSetter(name, newValue) {
5
+ this[name] = __returnValue.bind(null, newValue);
6
+ }
3
7
  var __export = (target, all) => {
4
8
  for (var name in all)
5
9
  __defProp(target, name, {
6
10
  get: all[name],
7
11
  enumerable: true,
8
12
  configurable: true,
9
- set: (newValue) => all[name] = () => newValue
13
+ set: __exportSetter.bind(all, name)
10
14
  });
11
15
  };
12
16
 
@@ -12331,7 +12335,7 @@ function tool(input) {
12331
12335
  }
12332
12336
  tool.schema = exports_external;
12333
12337
  // src/index.ts
12334
- import path2 from "path";
12338
+ import path3 from "path";
12335
12339
  import { mkdirSync } from "fs";
12336
12340
 
12337
12341
  // src/db.ts
@@ -12407,7 +12411,9 @@ var MIGRATIONS = [
12407
12411
  `ALTER TABLE team_message ADD COLUMN read INTEGER NOT NULL DEFAULT 0;
12408
12412
  CREATE INDEX IF NOT EXISTS team_message_unread_idx ON team_message(team_id, read) WHERE read = 0;`,
12409
12413
  `ALTER TABLE team_member ADD COLUMN worktree_dir TEXT;
12410
- ALTER TABLE team_member ADD COLUMN worktree_branch TEXT;`
12414
+ ALTER TABLE team_member ADD COLUMN worktree_branch TEXT;`,
12415
+ `ALTER TABLE team_member ADD COLUMN plan_approval TEXT NOT NULL DEFAULT 'none'
12416
+ CHECK(plan_approval IN ('none', 'pending', 'approved', 'rejected'));`
12411
12417
  ];
12412
12418
  function applyMigrations(db) {
12413
12419
  const { user_version: current } = db.query("PRAGMA user_version").get();
@@ -12727,6 +12733,71 @@ async function notifyWorkingProgress(client, db, teamId) {
12727
12733
  } catch {}
12728
12734
  }
12729
12735
 
12736
+ // src/system-prompt.ts
12737
+ var STATUS_DISPLAY = {
12738
+ busy: "working",
12739
+ ready: "idle",
12740
+ shutdown_requested: "shutting down",
12741
+ shutdown: "shut down",
12742
+ error: "error"
12743
+ };
12744
+ function buildLeadSystemPrompt(db, teamId) {
12745
+ const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId);
12746
+ if (!team)
12747
+ return "";
12748
+ const members = db.query("SELECT name, status FROM team_member WHERE team_id = ?").all(teamId);
12749
+ const taskCounts = db.query("SELECT status, COUNT(*) as count FROM team_task WHERE team_id = ? GROUP BY status").all(teamId);
12750
+ const countMap = {};
12751
+ for (const row of taskCounts) {
12752
+ countMap[row.status] = row.count;
12753
+ }
12754
+ const completed = countMap["completed"] ?? 0;
12755
+ const inProgress = countMap["in_progress"] ?? 0;
12756
+ const pending = countMap["pending"] ?? 0;
12757
+ const memberList = members.map((m) => `${m.name} [${STATUS_DISPLAY[m.status] ?? m.status}]`).join(", ");
12758
+ const teammateLine = members.length > 0 ? `Teammates: ${memberList}` : "Teammates: none";
12759
+ return [
12760
+ `You are leading team "${team.name}" with ${members.length} active teammates.`,
12761
+ teammateLine,
12762
+ `Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`,
12763
+ "",
12764
+ "Teammates work asynchronously and message you when done.",
12765
+ "Do NOT poll team_status or team_tasks_list repeatedly \u2014 wait for messages.",
12766
+ "After spawning teammates, tell the user what you've set up and wait.",
12767
+ "When all teammates finish, summarize results and suggest next steps."
12768
+ ].join(`
12769
+ `);
12770
+ }
12771
+ function buildTeammateSystemPrompt(db, teamId, memberName) {
12772
+ const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId);
12773
+ if (!team)
12774
+ return "";
12775
+ return `You are "${memberName}", a teammate in team "${team.name}". Use team_message to communicate.`;
12776
+ }
12777
+ function buildTeamCompactionContext(db, teamId, role, memberName) {
12778
+ const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId);
12779
+ if (!team)
12780
+ return "";
12781
+ const members = db.query("SELECT name, status FROM team_member WHERE team_id = ?").all(teamId);
12782
+ const taskCounts = db.query("SELECT status, COUNT(*) as count FROM team_task WHERE team_id = ? GROUP BY status").all(teamId);
12783
+ const countMap = {};
12784
+ for (const row of taskCounts) {
12785
+ countMap[row.status] = row.count;
12786
+ }
12787
+ const completed = countMap["completed"] ?? 0;
12788
+ const inProgress = countMap["in_progress"] ?? 0;
12789
+ const pending = countMap["pending"] ?? 0;
12790
+ const roleLine = role === "lead" ? `[Team Context] You are the lead of team "${team.name}".` : `[Team Context] You are a teammate named "${memberName}" in team "${team.name}".`;
12791
+ const memberList = members.map((m) => `${m.name} (${STATUS_DISPLAY[m.status] ?? m.status})`).join(", ");
12792
+ const membersLine = members.length > 0 ? `Members: ${memberList}` : "Members: none";
12793
+ return [
12794
+ roleLine,
12795
+ membersLine,
12796
+ `Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`
12797
+ ].join(`
12798
+ `);
12799
+ }
12800
+
12730
12801
  // src/types.ts
12731
12802
  function findTeamBySession(db, registry2, sessionId) {
12732
12803
  const entry = registry2.getBySession(sessionId);
@@ -12767,6 +12838,7 @@ async function executeTeamCreate(deps, args, sessionId) {
12767
12838
  }
12768
12839
 
12769
12840
  // src/tools/team-spawn.ts
12841
+ import path2 from "path";
12770
12842
  async function executeTeamSpawn(deps, args, sessionId) {
12771
12843
  const nameError = validateMemberName(args.name);
12772
12844
  if (nameError)
@@ -12780,6 +12852,7 @@ async function executeTeamSpawn(deps, args, sessionId) {
12780
12852
  if (existing)
12781
12853
  throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
12782
12854
  const useWorktree = args.worktree !== false;
12855
+ const usePlanApproval = args.plan_approval === true;
12783
12856
  let worktreeDir = null;
12784
12857
  let worktreeBranch = null;
12785
12858
  if (useWorktree) {
@@ -12827,9 +12900,10 @@ async function executeTeamSpawn(deps, args, sessionId) {
12827
12900
  }
12828
12901
  throw new Error("Failed to create teammate session");
12829
12902
  }
12903
+ const planApproval = usePlanApproval ? "pending" : "none";
12830
12904
  const now = Date.now();
12831
- deps.db.run(`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, time_created, time_updated)
12832
- VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, now, now]);
12905
+ deps.db.run(`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, plan_approval, time_created, time_updated)
12906
+ VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, planApproval, now, now]);
12833
12907
  deps.registry.register(teamInfo.teamId, args.name, childSessionId);
12834
12908
  const context = [
12835
12909
  `You are "${args.name}", a teammate in team "${teamInfo.teamName}".`,
@@ -12838,7 +12912,21 @@ async function executeTeamSpawn(deps, args, sessionId) {
12838
12912
  if (worktreeBranch) {
12839
12913
  context.push(`You are working on branch "${worktreeBranch}" in your own worktree. Your changes are isolated from other teammates.`);
12840
12914
  }
12841
- context.push("", "Tools available to you:", "- team_message: send a message to the lead or another teammate", "- team_broadcast: send a message to all team members", "- team_tasks_list: view the shared team task board", "- team_tasks_add: add tasks to the shared board", "- team_tasks_complete: mark a task complete on the shared board", "- team_claim: claim a pending task from the shared board", "", "When you finish your task:", "1. If you claimed a task, mark it complete using team_tasks_complete.", "2. Send ONE message to the lead with your findings using team_message.", "3. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.", "", "If you are blocked, send ONE message to the lead describing the specific blocker.", "", "Your plain text output is NOT visible to the team. You MUST use team_message to communicate.", "", "Your task:", args.prompt);
12915
+ if (usePlanApproval) {
12916
+ context.push("", "IMPORTANT: You are in PLAN MODE.", "Read and explore the codebase, then send your implementation plan to the lead via team_message.", "Do NOT write or modify any files until the lead approves your plan.", "Wait for the lead's approval message before proceeding with implementation.");
12917
+ }
12918
+ context.push("", "Tools available to you:", "- team_message: send a message to the lead or another teammate", "- team_broadcast: send a message to all team members", "- team_tasks_list: view the shared team task board", "- team_tasks_add: add tasks to the shared board", "- team_tasks_complete: mark a task complete on the shared board", "- team_claim: claim a pending task from the shared board", "", "When you finish your task:", "1. If you claimed a task, mark it complete using team_tasks_complete.", "2. Send ONE message to the lead with your findings using team_message.", "3. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.", "", "If you are blocked, send ONE message to the lead describing the specific blocker.", "", "Your plain text output is NOT visible to the team. You MUST use team_message to communicate.");
12919
+ try {
12920
+ const agentsPath = path2.join(deps.directory, "AGENTS.md");
12921
+ const file2 = Bun.file(agentsPath);
12922
+ if (await file2.exists()) {
12923
+ const content = await file2.text();
12924
+ const truncated = content.length > 2000 ? content.slice(0, 2000) + `
12925
+ ...(truncated)` : content;
12926
+ context.push("", "Project guidelines (from AGENTS.md):", truncated);
12927
+ }
12928
+ } catch {}
12929
+ context.push("", "Your task:", args.prompt);
12842
12930
  if (args.claim_task) {
12843
12931
  context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`);
12844
12932
  }
@@ -12863,7 +12951,8 @@ async function executeTeamSpawn(deps, args, sessionId) {
12863
12951
  throw new Error(`Failed to send initial prompt to teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`);
12864
12952
  }
12865
12953
  const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : "";
12866
- return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`;
12954
+ const planInfo = usePlanApproval ? " [plan mode \u2014 will send plan for approval]" : "";
12955
+ return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`;
12867
12956
  }
12868
12957
 
12869
12958
  // src/tools/team-message.ts
@@ -12875,20 +12964,40 @@ async function executeTeamMessage(deps, args, sessionId) {
12875
12964
  const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to);
12876
12965
  if (!recipientSessionId)
12877
12966
  throw new Error(`Recipient "${args.to}" not found in team "${teamInfo.teamName}"`);
12967
+ let messageText = args.text;
12968
+ if (args.approve || args.reject) {
12969
+ if (args.approve && args.reject) {
12970
+ throw new Error("Cannot both approve and reject a plan.");
12971
+ }
12972
+ if (teamInfo.role !== "lead") {
12973
+ throw new Error("Only the lead can approve or reject plans.");
12974
+ }
12975
+ const recipient = deps.db.query("SELECT plan_approval FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.to);
12976
+ if (!recipient || recipient.plan_approval !== "pending") {
12977
+ throw new Error(`Recipient "${args.to}" is not in plan approval mode (plan_approval is not pending).`);
12978
+ }
12979
+ if (args.approve) {
12980
+ deps.db.run("UPDATE team_member SET plan_approval = 'approved', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamInfo.teamId, args.to]);
12981
+ messageText = `[Plan Approved] ${args.text}`;
12982
+ } else {
12983
+ deps.db.run("UPDATE team_member SET plan_approval = 'rejected', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamInfo.teamId, args.to]);
12984
+ messageText = `[Plan Rejected: ${args.reject}] ${args.text}`;
12985
+ }
12986
+ }
12878
12987
  const msgId = sendMessage(deps.db, {
12879
12988
  teamId: teamInfo.teamId,
12880
12989
  from: senderName,
12881
12990
  to: args.to,
12882
- content: args.text
12991
+ content: messageText
12883
12992
  });
12884
12993
  const isToLead = args.to === "lead";
12885
12994
  const MAX_LEAD_MSG = 500;
12886
12995
  let deliveryText;
12887
- if (isToLead && args.text.length > MAX_LEAD_MSG) {
12888
- const truncated = args.text.slice(0, MAX_LEAD_MSG);
12996
+ if (isToLead && messageText.length > MAX_LEAD_MSG) {
12997
+ const truncated = messageText.slice(0, MAX_LEAD_MSG);
12889
12998
  deliveryText = `[Team message from ${senderName}]: ${truncated}... (use team_results to read full message)`;
12890
12999
  } else {
12891
- deliveryText = `[Team message from ${senderName}]: ${args.text}`;
13000
+ deliveryText = `[Team message from ${senderName}]: ${messageText}`;
12892
13001
  }
12893
13002
  await deps.client.session.promptAsync({
12894
13003
  path: { id: recipientSessionId },
@@ -12949,29 +13058,42 @@ async function executeTeamShutdown(deps, args, sessionId) {
12949
13058
  throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
12950
13059
  if (member.status === "shutdown")
12951
13060
  throw new Error(`Teammate "${args.member}" is already shut down`);
12952
- deps.db.run("UPDATE team_member SET status = 'shutdown_requested', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamInfo.teamId, args.member]);
12953
- try {
12954
- await deps.client.session.abort({ path: { id: member.session_id } });
12955
- } catch {
12956
- try {
12957
- await deps.client.tui.showToast({
12958
- title: "Team",
12959
- message: `Failed to abort ${args.member} session \u2014 will rely on event hook`,
12960
- variant: "warning",
12961
- duration: 4000
12962
- });
12963
- } catch {}
13061
+ const force = args.force ?? false;
13062
+ if (member.status === "shutdown_requested") {
13063
+ await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id);
13064
+ const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : "";
13065
+ return `Force shut down "${args.member}".${branchInfo}`;
12964
13066
  }
13067
+ let isIdle = false;
12965
13068
  try {
12966
13069
  const statuses = await deps.client.session.status();
12967
13070
  const sessionStatus = statuses.data?.[member.session_id];
12968
- if (!sessionStatus || sessionStatus.type === "idle") {
12969
- deps.db.run("UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamInfo.teamId, args.member]);
12970
- const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : "";
12971
- return `Teammate "${args.member}" has been shut down.${branchInfo}`;
12972
- }
13071
+ isIdle = !sessionStatus || sessionStatus.type === "idle";
13072
+ } catch {}
13073
+ if (isIdle || force) {
13074
+ await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id);
13075
+ const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : "";
13076
+ return `Teammate "${args.member}" has been shut down.${branchInfo}`;
13077
+ }
13078
+ try {
13079
+ await deps.client.session.promptAsync({
13080
+ path: { id: member.session_id },
13081
+ body: {
13082
+ parts: [{
13083
+ type: "text",
13084
+ text: `[Shutdown requested]: The lead has requested you shut down. Finish your current task, send your final findings to the lead via team_message, then stop.`
13085
+ }]
13086
+ }
13087
+ });
12973
13088
  } catch {}
12974
- return `Shutdown requested for ${args.member}. Will complete when current work finishes.`;
13089
+ deps.db.run("UPDATE team_member SET status = 'shutdown_requested', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamInfo.teamId, args.member]);
13090
+ return `Shutdown requested for ${args.member}. They will finish current work and shut down. Call team_shutdown with force: true to abort immediately.`;
13091
+ }
13092
+ async function abortAndShutdown(deps, teamId, memberName, sessionId) {
13093
+ try {
13094
+ await deps.client.session.abort({ path: { id: sessionId } });
13095
+ } catch {}
13096
+ deps.db.run("UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamId, memberName]);
12975
13097
  }
12976
13098
 
12977
13099
  // src/tools/team-cleanup.ts
@@ -13140,7 +13262,7 @@ async function executeTeamStatus(deps, sessionId) {
13140
13262
  return "No changes since last check.";
13141
13263
  }
13142
13264
  lastCallTime.set(teamInfo.teamId, now);
13143
- const members = deps.db.query("SELECT name, session_id, agent, status, execution_status, worktree_branch FROM team_member WHERE team_id = ? ORDER BY time_created ASC").all(teamInfo.teamId);
13265
+ const members = deps.db.query("SELECT name, session_id, agent, status, execution_status, worktree_branch, plan_approval FROM team_member WHERE team_id = ? ORDER BY time_created ASC").all(teamInfo.teamId);
13144
13266
  const tasks = deps.db.query("SELECT status FROM team_task WHERE team_id = ?").all(teamInfo.teamId);
13145
13267
  const lines = [];
13146
13268
  lines.push(`Team: ${teamInfo.teamName} (you are the ${teamInfo.role})`);
@@ -13152,7 +13274,8 @@ async function executeTeamStatus(deps, sessionId) {
13152
13274
  for (const m of members) {
13153
13275
  const statusIcon = m.status === "busy" ? "working" : m.status === "ready" ? "idle" : m.status;
13154
13276
  const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : "";
13155
- lines.push(` ${m.name} [${statusIcon}] agent: ${m.agent}${branch} session: ${m.session_id}`);
13277
+ const plan = m.plan_approval !== "none" ? `, plan: ${m.plan_approval}` : "";
13278
+ lines.push(` ${m.name} [${statusIcon}${plan}] agent: ${m.agent}${branch} session: ${m.session_id}`);
13156
13279
  }
13157
13280
  }
13158
13281
  if (tasks.length > 0) {
@@ -13332,12 +13455,12 @@ var DEFAULT_WATCHDOG_TTL_MS = 30 * 60 * 1000;
13332
13455
  var DEFAULT_WATCHDOG_CHECK_MS = 60 * 1000;
13333
13456
  var plugin = async (input) => {
13334
13457
  const dbPath = getDbPath();
13335
- mkdirSync(path2.dirname(dbPath), { recursive: true });
13458
+ mkdirSync(path3.dirname(dbPath), { recursive: true });
13336
13459
  const db = createDb(dbPath);
13337
13460
  const registry2 = new MemberRegistry;
13338
13461
  const tracker = new DescendantTracker;
13339
13462
  const client = input.client;
13340
- const deps = { db, registry: registry2, tracker, client };
13463
+ const deps = { db, registry: registry2, tracker, client, directory: input.directory };
13341
13464
  const recovery = await recoverStaleMembers(db, client);
13342
13465
  if (recovery.interrupted > 0) {
13343
13466
  const members = db.query("SELECT tm.team_id, tm.name, tm.session_id FROM team_member tm JOIN team t ON tm.team_id = t.id WHERE t.status = 'active'").all();
@@ -13412,6 +13535,38 @@ var plugin = async (input) => {
13412
13535
  }
13413
13536
  }
13414
13537
  },
13538
+ "experimental.chat.system.transform": async (input2, output) => {
13539
+ if (!input2.sessionID)
13540
+ return;
13541
+ const teamInfo = findTeamBySession(db, registry2, input2.sessionID);
13542
+ if (!teamInfo)
13543
+ return;
13544
+ const prompt = teamInfo.role === "lead" ? buildLeadSystemPrompt(db, teamInfo.teamId) : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName);
13545
+ output.system.push(prompt);
13546
+ },
13547
+ "experimental.session.compacting": async (input2, output) => {
13548
+ const teamInfo = findTeamBySession(db, registry2, input2.sessionID);
13549
+ if (!teamInfo)
13550
+ return;
13551
+ const context = buildTeamCompactionContext(db, teamInfo.teamId, teamInfo.role, teamInfo.memberName);
13552
+ output.context.push(context);
13553
+ },
13554
+ "shell.env": async (input2, output) => {
13555
+ if (!input2.sessionID)
13556
+ return;
13557
+ const teamInfo = findTeamBySession(db, registry2, input2.sessionID);
13558
+ if (!teamInfo)
13559
+ return;
13560
+ output.env.ENSEMBLE_TEAM = teamInfo.teamName;
13561
+ output.env.ENSEMBLE_ROLE = teamInfo.role;
13562
+ if (teamInfo.memberName) {
13563
+ output.env.ENSEMBLE_MEMBER = teamInfo.memberName;
13564
+ const member = db.query("SELECT worktree_branch FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, teamInfo.memberName);
13565
+ if (member?.worktree_branch) {
13566
+ output.env.ENSEMBLE_BRANCH = member.worktree_branch;
13567
+ }
13568
+ }
13569
+ },
13415
13570
  tool: {
13416
13571
  team_create: tool({
13417
13572
  description: "Create a new agent team. You become the team lead. Use this before spawning teammates.",
@@ -13432,7 +13587,8 @@ var plugin = async (input) => {
13432
13587
  prompt: tool.schema.string().describe("Task instructions for the teammate"),
13433
13588
  model: tool.schema.string().optional().describe("Model in provider/model format (optional, uses default)"),
13434
13589
  claim_task: tool.schema.string().optional().describe("Task ID to auto-claim for this teammate (optional)"),
13435
- worktree: tool.schema.boolean().default(true).describe("Create a git worktree for file isolation (default: true, set false for read-only agents)")
13590
+ worktree: tool.schema.boolean().default(true).describe("Create a git worktree for file isolation (default: true, set false for read-only agents)"),
13591
+ plan_approval: tool.schema.boolean().default(false).describe("Require teammate to send a plan for approval before writing files (default: false)")
13436
13592
  },
13437
13593
  async execute(args, ctx) {
13438
13594
  const result = await executeTeamSpawn(deps, args, ctx.sessionID);
@@ -13444,7 +13600,9 @@ var plugin = async (input) => {
13444
13600
  description: "Send a message to a specific teammate or to the lead. Use 'lead' to message the team lead.",
13445
13601
  args: {
13446
13602
  to: tool.schema.string().describe("Recipient name ('lead' or teammate name)"),
13447
- text: tool.schema.string().describe("Message content (max 10KB)")
13603
+ text: tool.schema.string().describe("Message content (max 10KB)"),
13604
+ approve: tool.schema.boolean().optional().describe("Approve a teammate's plan (only when recipient has plan_approval='pending')"),
13605
+ reject: tool.schema.string().optional().describe("Reject a teammate's plan with reason (only when recipient has plan_approval='pending')")
13448
13606
  },
13449
13607
  async execute(args, ctx) {
13450
13608
  const result = await executeTeamMessage(deps, args, ctx.sessionID);
@@ -13523,9 +13681,10 @@ var plugin = async (input) => {
13523
13681
  }
13524
13682
  }),
13525
13683
  team_shutdown: tool({
13526
- description: "Request a teammate to shut down. The teammate finishes current work then stops.",
13684
+ description: "Request a teammate to shut down. The teammate finishes current work then stops. " + "Pass force: true to abort immediately without waiting.",
13527
13685
  args: {
13528
- member: tool.schema.string().describe("Teammate name to shut down")
13686
+ member: tool.schema.string().describe("Teammate name to shut down"),
13687
+ force: tool.schema.boolean().default(false).describe("Force immediate abort without waiting for current work to finish")
13529
13688
  },
13530
13689
  async execute(args, ctx) {
13531
13690
  const result = await executeTeamShutdown(deps, args, ctx.sessionID);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Agent teams for OpenCode — parallel agents with peer-to-peer communication, shared tasks, and coordinated execution",
5
5
  "module": "src/index.ts",
6
6
  "main": "dist/index.js",
@@ -38,7 +38,7 @@
38
38
  "typescript": "^5"
39
39
  },
40
40
  "dependencies": {
41
- "@opencode-ai/plugin": "^1.2.26",
42
- "@opencode-ai/sdk": "^1.2.26"
41
+ "@opencode-ai/plugin": "^1.2.27",
42
+ "@opencode-ai/sdk": "^1.2.27"
43
43
  }
44
44
  }
package/src/index.ts CHANGED
@@ -7,6 +7,8 @@ import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktre
7
7
  import { MemberRegistry, DescendantTracker } from "./state"
8
8
  import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation } from "./hooks"
9
9
  import { notifyTeamEvent, notifyWorkingProgress } from "./notify"
10
+ import { buildLeadSystemPrompt, buildTeammateSystemPrompt, buildTeamCompactionContext } from "./system-prompt"
11
+ import { findTeamBySession } from "./types"
10
12
  import { executeTeamCreate } from "./tools/team-create"
11
13
  import { executeTeamSpawn } from "./tools/team-spawn"
12
14
  import { executeTeamMessage } from "./tools/team-message"
@@ -47,7 +49,7 @@ const plugin: Plugin = async (input) => {
47
49
 
48
50
  // Build shared tool dependencies
49
51
  const client = input.client as unknown as PluginClient
50
- const deps: ToolDeps = { db, registry, tracker, client }
52
+ const deps: ToolDeps = { db, registry, tracker, client, directory: input.directory }
51
53
 
52
54
  // Run recovery on init — mark stale busy members as error + abort orphaned sessions
53
55
  const recovery = await recoverStaleMembers(db, client)
@@ -152,6 +154,42 @@ const plugin: Plugin = async (input) => {
152
154
  }
153
155
  },
154
156
 
157
+ // System prompt injection — keeps lead aware of team state, reminds teammates of role
158
+ "experimental.chat.system.transform": async (input, output) => {
159
+ if (!input.sessionID) return
160
+ const teamInfo = findTeamBySession(db, registry, input.sessionID)
161
+ if (!teamInfo) return
162
+ const prompt = teamInfo.role === "lead"
163
+ ? buildLeadSystemPrompt(db, teamInfo.teamId)
164
+ : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName!)
165
+ output.system.push(prompt)
166
+ },
167
+
168
+ // Compaction safety — preserves team context when sessions get long
169
+ "experimental.session.compacting": async (input, output) => {
170
+ const teamInfo = findTeamBySession(db, registry, input.sessionID)
171
+ if (!teamInfo) return
172
+ const context = buildTeamCompactionContext(db, teamInfo.teamId, teamInfo.role, teamInfo.memberName)
173
+ output.context.push(context)
174
+ },
175
+
176
+ // Team-aware shell environment for scripts and hooks
177
+ "shell.env": async (input, output) => {
178
+ if (!input.sessionID) return
179
+ const teamInfo = findTeamBySession(db, registry, input.sessionID)
180
+ if (!teamInfo) return
181
+ output.env.ENSEMBLE_TEAM = teamInfo.teamName
182
+ output.env.ENSEMBLE_ROLE = teamInfo.role
183
+ if (teamInfo.memberName) {
184
+ output.env.ENSEMBLE_MEMBER = teamInfo.memberName
185
+ const member = db.query("SELECT worktree_branch FROM team_member WHERE team_id = ? AND name = ?")
186
+ .get(teamInfo.teamId, teamInfo.memberName) as { worktree_branch: string | null } | null
187
+ if (member?.worktree_branch) {
188
+ output.env.ENSEMBLE_BRANCH = member.worktree_branch
189
+ }
190
+ }
191
+ },
192
+
155
193
  // Register all team tools
156
194
  tool: {
157
195
  team_create: tool({
@@ -177,6 +215,7 @@ const plugin: Plugin = async (input) => {
177
215
  model: tool.schema.string().optional().describe("Model in provider/model format (optional, uses default)"),
178
216
  claim_task: tool.schema.string().optional().describe("Task ID to auto-claim for this teammate (optional)"),
179
217
  worktree: tool.schema.boolean().default(true).describe("Create a git worktree for file isolation (default: true, set false for read-only agents)"),
218
+ plan_approval: tool.schema.boolean().default(false).describe("Require teammate to send a plan for approval before writing files (default: false)"),
180
219
  },
181
220
  async execute(args, ctx) {
182
221
  const result = await executeTeamSpawn(deps, args, ctx.sessionID)
@@ -190,6 +229,8 @@ const plugin: Plugin = async (input) => {
190
229
  args: {
191
230
  to: tool.schema.string().describe("Recipient name ('lead' or teammate name)"),
192
231
  text: tool.schema.string().describe("Message content (max 10KB)"),
232
+ approve: tool.schema.boolean().optional().describe("Approve a teammate's plan (only when recipient has plan_approval='pending')"),
233
+ reject: tool.schema.string().optional().describe("Reject a teammate's plan with reason (only when recipient has plan_approval='pending')"),
193
234
  },
194
235
  async execute(args, ctx) {
195
236
  const result = await executeTeamMessage(deps, args, ctx.sessionID)
@@ -274,9 +315,11 @@ const plugin: Plugin = async (input) => {
274
315
  }),
275
316
 
276
317
  team_shutdown: tool({
277
- description: "Request a teammate to shut down. The teammate finishes current work then stops.",
318
+ description: "Request a teammate to shut down. The teammate finishes current work then stops. " +
319
+ "Pass force: true to abort immediately without waiting.",
278
320
  args: {
279
321
  member: tool.schema.string().describe("Teammate name to shut down"),
322
+ force: tool.schema.boolean().default(false).describe("Force immediate abort without waiting for current work to finish"),
280
323
  },
281
324
  async execute(args, ctx) {
282
325
  const result = await executeTeamShutdown(deps, args, ctx.sessionID)
package/src/schema.ts CHANGED
@@ -73,6 +73,9 @@ export const MIGRATIONS: string[] = [
73
73
  // Migration 3: Add worktree columns to team_member for git worktree isolation
74
74
  `ALTER TABLE team_member ADD COLUMN worktree_dir TEXT;
75
75
  ALTER TABLE team_member ADD COLUMN worktree_branch TEXT;`,
76
+ // Migration 4: Add plan_approval column to team_member for plan-before-build workflow
77
+ `ALTER TABLE team_member ADD COLUMN plan_approval TEXT NOT NULL DEFAULT 'none'
78
+ CHECK(plan_approval IN ('none', 'pending', 'approved', 'rejected'));`,
76
79
  ]
77
80
 
78
81
  /**
@@ -0,0 +1,111 @@
1
+ import type { Database } from "bun:sqlite"
2
+
3
+ /** Status display mapping from DB status to human-readable label. */
4
+ const STATUS_DISPLAY: Record<string, string> = {
5
+ busy: "working",
6
+ ready: "idle",
7
+ shutdown_requested: "shutting down",
8
+ shutdown: "shut down",
9
+ error: "error",
10
+ }
11
+
12
+ /**
13
+ * Build the system prompt injected into the lead's session.
14
+ * Includes team name, member statuses, task counts, and anti-polling guidance.
15
+ */
16
+ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
17
+ const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId) as { name: string } | null
18
+ if (!team) return ""
19
+
20
+ const members = db.query("SELECT name, status FROM team_member WHERE team_id = ?").all(teamId) as Array<{ name: string; status: string }>
21
+
22
+ const taskCounts = db.query(
23
+ "SELECT status, COUNT(*) as count FROM team_task WHERE team_id = ? GROUP BY status",
24
+ ).all(teamId) as Array<{ status: string; count: number }>
25
+
26
+ const countMap: Record<string, number> = {}
27
+ for (const row of taskCounts) {
28
+ countMap[row.status] = row.count
29
+ }
30
+
31
+ const completed = countMap["completed"] ?? 0
32
+ const inProgress = countMap["in_progress"] ?? 0
33
+ const pending = countMap["pending"] ?? 0
34
+
35
+ const memberList = members
36
+ .map((m) => `${m.name} [${STATUS_DISPLAY[m.status] ?? m.status}]`)
37
+ .join(", ")
38
+
39
+ const teammateLine = members.length > 0
40
+ ? `Teammates: ${memberList}`
41
+ : "Teammates: none"
42
+
43
+ return [
44
+ `You are leading team "${team.name}" with ${members.length} active teammates.`,
45
+ teammateLine,
46
+ `Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`,
47
+ "",
48
+ "Teammates work asynchronously and message you when done.",
49
+ "Do NOT poll team_status or team_tasks_list repeatedly — wait for messages.",
50
+ "After spawning teammates, tell the user what you've set up and wait.",
51
+ "When all teammates finish, summarize results and suggest next steps.",
52
+ ].join("\n")
53
+ }
54
+
55
+ /**
56
+ * Build the system prompt injected into a teammate's session.
57
+ * Short role reminder with name, team name, and communication tool.
58
+ */
59
+ export function buildTeammateSystemPrompt(db: Database, teamId: string, memberName: string): string {
60
+ const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId) as { name: string } | null
61
+ if (!team) return ""
62
+
63
+ return `You are "${memberName}", a teammate in team "${team.name}". Use team_message to communicate.`
64
+ }
65
+
66
+ /**
67
+ * Build a concise context string for compaction.
68
+ * Includes team name, member statuses, task progress, and role statement.
69
+ */
70
+ export function buildTeamCompactionContext(
71
+ db: Database,
72
+ teamId: string,
73
+ role: "lead" | "member",
74
+ memberName?: string,
75
+ ): string {
76
+ const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId) as { name: string } | null
77
+ if (!team) return ""
78
+
79
+ const members = db.query("SELECT name, status FROM team_member WHERE team_id = ?").all(teamId) as Array<{ name: string; status: string }>
80
+
81
+ const taskCounts = db.query(
82
+ "SELECT status, COUNT(*) as count FROM team_task WHERE team_id = ? GROUP BY status",
83
+ ).all(teamId) as Array<{ status: string; count: number }>
84
+
85
+ const countMap: Record<string, number> = {}
86
+ for (const row of taskCounts) {
87
+ countMap[row.status] = row.count
88
+ }
89
+
90
+ const completed = countMap["completed"] ?? 0
91
+ const inProgress = countMap["in_progress"] ?? 0
92
+ const pending = countMap["pending"] ?? 0
93
+
94
+ const roleLine = role === "lead"
95
+ ? `[Team Context] You are the lead of team "${team.name}".`
96
+ : `[Team Context] You are a teammate named "${memberName}" in team "${team.name}".`
97
+
98
+ const memberList = members
99
+ .map((m) => `${m.name} (${STATUS_DISPLAY[m.status] ?? m.status})`)
100
+ .join(", ")
101
+
102
+ const membersLine = members.length > 0
103
+ ? `Members: ${memberList}`
104
+ : "Members: none"
105
+
106
+ return [
107
+ roleLine,
108
+ membersLine,
109
+ `Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`,
110
+ ].join("\n")
111
+ }
@@ -4,10 +4,11 @@ import { sendMessage, markDelivered } from "../messaging"
4
4
 
5
5
  /**
6
6
  * Execute the team_message tool. Sends a direct message to a teammate or lead.
7
+ * Optionally approves or rejects a teammate's plan (lead only).
7
8
  */
8
9
  export async function executeTeamMessage(
9
10
  deps: ToolDeps,
10
- args: { to: string; text: string },
11
+ args: { to: string; text: string; approve?: boolean; reject?: string },
11
12
  sessionId: string,
12
13
  ): Promise<string> {
13
14
  const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
@@ -18,22 +19,52 @@ export async function executeTeamMessage(
18
19
  const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to)
19
20
  if (!recipientSessionId) throw new Error(`Recipient "${args.to}" not found in team "${teamInfo.teamName}"`)
20
21
 
22
+ // Handle plan approval/rejection
23
+ let messageText = args.text
24
+ if (args.approve || args.reject) {
25
+ if (args.approve && args.reject) {
26
+ throw new Error("Cannot both approve and reject a plan.")
27
+ }
28
+ if (teamInfo.role !== "lead") {
29
+ throw new Error("Only the lead can approve or reject plans.")
30
+ }
31
+ const recipient = deps.db.query(
32
+ "SELECT plan_approval FROM team_member WHERE team_id = ? AND name = ?"
33
+ ).get(teamInfo.teamId, args.to) as { plan_approval: string } | null
34
+ if (!recipient || recipient.plan_approval !== "pending") {
35
+ throw new Error(`Recipient "${args.to}" is not in plan approval mode (plan_approval is not pending).`)
36
+ }
37
+ if (args.approve) {
38
+ deps.db.run(
39
+ "UPDATE team_member SET plan_approval = 'approved', time_updated = ? WHERE team_id = ? AND name = ?",
40
+ [Date.now(), teamInfo.teamId, args.to]
41
+ )
42
+ messageText = `[Plan Approved] ${args.text}`
43
+ } else {
44
+ deps.db.run(
45
+ "UPDATE team_member SET plan_approval = 'rejected', time_updated = ? WHERE team_id = ? AND name = ?",
46
+ [Date.now(), teamInfo.teamId, args.to]
47
+ )
48
+ messageText = `[Plan Rejected: ${args.reject}] ${args.text}`
49
+ }
50
+ }
51
+
21
52
  const msgId = sendMessage(deps.db, {
22
53
  teamId: teamInfo.teamId,
23
54
  from: senderName,
24
55
  to: args.to,
25
- content: args.text,
56
+ content: messageText,
26
57
  })
27
58
 
28
59
  // Deliver via promptAsync — truncate lead-bound messages over 500 chars
29
60
  const isToLead = args.to === "lead"
30
61
  const MAX_LEAD_MSG = 500
31
62
  let deliveryText: string
32
- if (isToLead && args.text.length > MAX_LEAD_MSG) {
33
- const truncated = args.text.slice(0, MAX_LEAD_MSG)
63
+ if (isToLead && messageText.length > MAX_LEAD_MSG) {
64
+ const truncated = messageText.slice(0, MAX_LEAD_MSG)
34
65
  deliveryText = `[Team message from ${senderName}]: ${truncated}... (use team_results to read full message)`
35
66
  } else {
36
- deliveryText = `[Team message from ${senderName}]: ${args.text}`
67
+ deliveryText = `[Team message from ${senderName}]: ${messageText}`
37
68
  }
38
69
 
39
70
  await deps.client.session.promptAsync({
@@ -3,13 +3,16 @@ import { findTeamBySession } from "../types"
3
3
 
4
4
  /**
5
5
  * Execute the team_shutdown tool. Requests a teammate to shut down.
6
- * Sets status to shutdown_requested, calls abort, then polls session status.
7
- * If the session is already idle after abort, transitions directly to shutdown.
8
- * Otherwise the event hook handles the transition when the session goes idle.
6
+ *
7
+ * Graceful negotiation flow:
8
+ * - If member is already shutdown_requested, treat as force (second call).
9
+ * - If member is idle or force=true, abort immediately and set status='shutdown'.
10
+ * - If member is busy and force=false, send a shutdown message via promptAsync
11
+ * and set status='shutdown_requested'. The member finishes work and reports back.
9
12
  */
10
13
  export async function executeTeamShutdown(
11
14
  deps: ToolDeps,
12
- args: { member: string },
15
+ args: { member: string; force?: boolean },
13
16
  sessionId: string,
14
17
  ): Promise<string> {
15
18
  const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
@@ -21,44 +24,72 @@ export async function executeTeamShutdown(
21
24
  if (!member) throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`)
22
25
  if (member.status === "shutdown") throw new Error(`Teammate "${args.member}" is already shut down`)
23
26
 
24
- // Set to shutdown_requested
25
- deps.db.run(
26
- "UPDATE team_member SET status = 'shutdown_requested', time_updated = ? WHERE team_id = ? AND name = ?",
27
- [Date.now(), teamInfo.teamId, args.member]
28
- )
27
+ const force = args.force ?? false
29
28
 
30
- // Call abort on the member's session (best effort)
31
- try {
32
- await deps.client.session.abort({ path: { id: member.session_id } })
33
- } catch {
34
- // Abort failed — session may already be gone. Fire a warning toast.
35
- try {
36
- await deps.client.tui.showToast({
37
- title: "Team",
38
- message: `Failed to abort ${args.member} session — will rely on event hook`,
39
- variant: "warning",
40
- duration: 4000,
41
- })
42
- } catch { /* TUI may not be available */ }
29
+ // Second call on an already-requested member → force abort
30
+ if (member.status === "shutdown_requested") {
31
+ await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id)
32
+ const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : ""
33
+ return `Force shut down "${args.member}".${branchInfo}`
43
34
  }
44
35
 
45
- // Fallback: poll session status after abort. If already idle, transition
46
- // directly to shutdown. This handles the case where abort() on an
47
- // already-idle session doesn't fire a session.status event.
36
+ // Determine if member is idle or busy
37
+ let isIdle = false
48
38
  try {
49
39
  const statuses = await deps.client.session.status()
50
40
  const sessionStatus = statuses.data?.[member.session_id]
51
- if (!sessionStatus || sessionStatus.type === "idle") {
52
- deps.db.run(
53
- "UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?",
54
- [Date.now(), teamInfo.teamId, args.member]
55
- )
56
- const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : ""
57
- return `Teammate "${args.member}" has been shut down.${branchInfo}`
58
- }
41
+ isIdle = !sessionStatus || sessionStatus.type === "idle"
42
+ } catch {
43
+ // Status poll failed — assume busy, fall through to graceful path
44
+ }
45
+
46
+ if (isIdle || force) {
47
+ await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id)
48
+ const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : ""
49
+ return `Teammate "${args.member}" has been shut down.${branchInfo}`
50
+ }
51
+
52
+ // Busy + not force → graceful: send shutdown message, set shutdown_requested
53
+ try {
54
+ await deps.client.session.promptAsync({
55
+ path: { id: member.session_id },
56
+ body: {
57
+ parts: [{
58
+ type: "text",
59
+ text: `[Shutdown requested]: The lead has requested you shut down. Finish your current task, send your final findings to the lead via team_message, then stop.`,
60
+ }],
61
+ },
62
+ })
59
63
  } catch {
60
- // Status poll failed — fall through to eventual consistency
64
+ // promptAsync failed — best effort
61
65
  }
62
66
 
63
- return `Shutdown requested for ${args.member}. Will complete when current work finishes.`
67
+ deps.db.run(
68
+ "UPDATE team_member SET status = 'shutdown_requested', time_updated = ? WHERE team_id = ? AND name = ?",
69
+ [Date.now(), teamInfo.teamId, args.member],
70
+ )
71
+
72
+ return `Shutdown requested for ${args.member}. They will finish current work and shut down. Call team_shutdown with force: true to abort immediately.`
73
+ }
74
+
75
+ /**
76
+ * Abort a member's session and set their status to shutdown.
77
+ * Best-effort: if abort fails, we still mark them as shutdown.
78
+ */
79
+ async function abortAndShutdown(
80
+ deps: ToolDeps,
81
+ teamId: string,
82
+ memberName: string,
83
+ sessionId: string,
84
+ ): Promise<void> {
85
+ try {
86
+ await deps.client.session.abort({ path: { id: sessionId } })
87
+ } catch {
88
+ // Abort failed — session may already be gone
89
+ }
90
+
91
+ deps.db.run(
92
+ "UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?",
93
+ [Date.now(), teamId, memberName],
94
+ )
64
95
  }
@@ -1,15 +1,17 @@
1
1
  import type { ToolDeps } from "../types"
2
2
  import { generateId, validateMemberName } from "../util"
3
3
  import { findTeamBySession } from "../types"
4
+ import path from "path"
4
5
 
5
6
  /**
6
7
  * Execute the team_spawn tool. Creates a child session and starts a teammate.
7
8
  * By default, each teammate gets their own git worktree for file isolation.
8
9
  * Pass worktree: false for read-only agents that don't need isolation.
10
+ * Pass plan_approval: true to require the teammate to send a plan before writing.
9
11
  */
10
12
  export async function executeTeamSpawn(
11
13
  deps: ToolDeps,
12
- args: { name: string; agent: string; prompt: string; model?: string; claim_task?: string; worktree?: boolean },
14
+ args: { name: string; agent: string; prompt: string; model?: string; claim_task?: string; worktree?: boolean; plan_approval?: boolean },
13
15
  sessionId: string,
14
16
  ): Promise<string> {
15
17
  const nameError = validateMemberName(args.name)
@@ -25,6 +27,7 @@ export async function executeTeamSpawn(
25
27
  if (existing) throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`)
26
28
 
27
29
  const useWorktree = args.worktree !== false
30
+ const usePlanApproval = args.plan_approval === true
28
31
 
29
32
  // Create worktree if enabled
30
33
  let worktreeDir: string | null = null
@@ -79,11 +82,12 @@ export async function executeTeamSpawn(
79
82
  }
80
83
 
81
84
  // Register in DB
85
+ const planApproval = usePlanApproval ? "pending" : "none"
82
86
  const now = Date.now()
83
87
  deps.db.run(
84
- `INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, time_created, time_updated)
85
- VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?)`,
86
- [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, now, now]
88
+ `INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, plan_approval, time_created, time_updated)
89
+ VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?)`,
90
+ [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, planApproval, now, now]
87
91
  )
88
92
 
89
93
  // Register in memory
@@ -99,6 +103,17 @@ export async function executeTeamSpawn(
99
103
  context.push(`You are working on branch "${worktreeBranch}" in your own worktree. Your changes are isolated from other teammates.`)
100
104
  }
101
105
 
106
+ // Plan approval mode — teammate must send plan before writing
107
+ if (usePlanApproval) {
108
+ context.push(
109
+ "",
110
+ "IMPORTANT: You are in PLAN MODE.",
111
+ "Read and explore the codebase, then send your implementation plan to the lead via team_message.",
112
+ "Do NOT write or modify any files until the lead approves your plan.",
113
+ "Wait for the lead's approval message before proceeding with implementation.",
114
+ )
115
+ }
116
+
102
117
  context.push(
103
118
  "",
104
119
  "Tools available to you:",
@@ -117,6 +132,20 @@ export async function executeTeamSpawn(
117
132
  "If you are blocked, send ONE message to the lead describing the specific blocker.",
118
133
  "",
119
134
  "Your plain text output is NOT visible to the team. You MUST use team_message to communicate.",
135
+ )
136
+
137
+ // Load project AGENTS.md if it exists
138
+ try {
139
+ const agentsPath = path.join(deps.directory, "AGENTS.md")
140
+ const file = Bun.file(agentsPath)
141
+ if (await file.exists()) {
142
+ const content = await file.text()
143
+ const truncated = content.length > 2000 ? content.slice(0, 2000) + "\n...(truncated)" : content
144
+ context.push("", "Project guidelines (from AGENTS.md):", truncated)
145
+ }
146
+ } catch { /* file may not exist or be unreadable */ }
147
+
148
+ context.push(
120
149
  "",
121
150
  "Your task:",
122
151
  args.prompt,
@@ -146,5 +175,6 @@ export async function executeTeamSpawn(
146
175
  }
147
176
 
148
177
  const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : ""
149
- return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
178
+ const planInfo = usePlanApproval ? " [plan mode — will send plan for approval]" : ""
179
+ return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
150
180
  }
@@ -24,9 +24,9 @@ export async function executeTeamStatus(
24
24
  lastCallTime.set(teamInfo.teamId, now)
25
25
 
26
26
  const members = deps.db.query(
27
- "SELECT name, session_id, agent, status, execution_status, worktree_branch FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
27
+ "SELECT name, session_id, agent, status, execution_status, worktree_branch, plan_approval FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
28
28
  ).all(teamInfo.teamId) as Array<{
29
- name: string; session_id: string; agent: string; status: string; execution_status: string; worktree_branch: string | null
29
+ name: string; session_id: string; agent: string; status: string; execution_status: string; worktree_branch: string | null; plan_approval: string
30
30
  }>
31
31
 
32
32
  const tasks = deps.db.query(
@@ -44,7 +44,8 @@ export async function executeTeamStatus(
44
44
  for (const m of members) {
45
45
  const statusIcon = m.status === "busy" ? "working" : m.status === "ready" ? "idle" : m.status
46
46
  const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : ""
47
- lines.push(` ${m.name} [${statusIcon}] agent: ${m.agent}${branch} session: ${m.session_id}`)
47
+ const plan = m.plan_approval !== "none" ? `, plan: ${m.plan_approval}` : ""
48
+ lines.push(` ${m.name} [${statusIcon}${plan}] agent: ${m.agent}${branch} session: ${m.session_id}`)
48
49
  }
49
50
  }
50
51
 
package/src/types.ts CHANGED
@@ -11,6 +11,8 @@ export interface ToolDeps {
11
11
  tracker: DescendantTracker
12
12
  /** The OpenCode SDK client — used for session.create, promptAsync, abort, etc. */
13
13
  client: PluginClient
14
+ /** The project root directory — used for reading AGENTS.md and other project files. */
15
+ directory: string
14
16
  }
15
17
 
16
18
  /**