@hueyexe/opencode-ensemble 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,21 +1,21 @@
1
1
  # OpenCode Ensemble
2
2
 
3
- Run a team of parallel AI agents in OpenCode. Each agent gets its own session, context window, and task. They coordinate through messaging and a shared task board.
3
+ Run parallel AI agents in OpenCode. Each agent gets its own session, context window, and task. They coordinate through messaging and a shared task board.
4
4
 
5
- Built as a plugin on the public OpenCode SDK. Zero internal dependencies.
5
+ Plugin built on the public OpenCode SDK. No internal dependencies.
6
6
 
7
7
  ## What actually happens
8
8
 
9
- You ask the agent to do something complex. The agent decides it needs help, creates a team, and spawns teammates. Each teammate gets its own OpenCode session with a fresh context window and a specific task.
9
+ You ask the agent to do something complex. It creates a team, spawns teammates, and they work in parallel. Each teammate runs in its own OpenCode session with a fresh context window.
10
10
 
11
- Here's a real interaction:
11
+ A real interaction:
12
12
 
13
13
  ```
14
14
  You: "Add input validation to all API endpoints and write tests for each one."
15
15
 
16
16
  The lead agent:
17
17
  1. Creates a team called "validation"
18
- 2. Adds tasks to the shared board: one per endpoint
18
+ 2. Adds tasks to the shared board, one per endpoint
19
19
  3. Spawns 3 teammates:
20
20
  - alice: validate user endpoints (POST /users, PUT /users/:id)
21
21
  - bob: validate order endpoints (POST /orders, PUT /orders/:id)
@@ -23,7 +23,7 @@ The lead agent:
23
23
  4. carol's tasks depend on alice and bob finishing first
24
24
  ```
25
25
 
26
- From here, things happen in parallel. Alice and bob work simultaneously in their own sessions. You see toast notifications as they make progress:
26
+ Alice and bob work simultaneously. You see toast notifications as they progress:
27
27
 
28
28
  ```
29
29
  [toast] Teammate alice spawned (build)
@@ -31,18 +31,18 @@ From here, things happen in parallel. Alice and bob work simultaneously in their
31
31
  [toast] Teammate carol spawned (build)
32
32
  ```
33
33
 
34
- The teammates talk to each other and to the lead:
34
+ Teammates talk to each other and to the lead:
35
35
 
36
36
  ```
37
37
  alice -> lead: "User validation done. Added zod schemas to POST /users and PUT /users/:id."
38
- bob -> lead: "Order validation done. Found an edge case in PUT /orders/:id — negative quantities were allowed."
38
+ bob -> lead: "Order validation done. Found an edge case in PUT /orders/:id, negative quantities were allowed."
39
39
  bob -> alice: "Did you handle email format validation? I want to match the pattern for order contact emails."
40
- alice -> bob: "Yes, using z.string().email() — see src/validators/user.ts line 12."
40
+ alice -> bob: "Yes, using z.string().email(). See src/validators/user.ts line 12."
41
41
  ```
42
42
 
43
- When alice and bob finish, carol's blocked tasks unblock automatically. Carol starts writing tests using the actual validation schemas alice and bob created.
43
+ When alice and bob finish, carol's blocked tasks unblock automatically. Carol starts writing tests using the validation schemas they created.
44
44
 
45
- The lead keeps you updated throughout. You can check on things at any time:
45
+ Check on things at any time:
46
46
 
47
47
  ```
48
48
  You: "How's the team doing?"
@@ -56,7 +56,7 @@ Lead calls team_status:
56
56
  Tasks: 5 total (3 completed, 1 in_progress, 1 pending)
57
57
  ```
58
58
 
59
- Want to see what carol is actually doing? The lead can switch your view to her session:
59
+ Want to see what carol is doing? The lead can switch your view to her session:
60
60
 
61
61
  ```
62
62
  You: "Show me what carol is working on."
@@ -79,9 +79,9 @@ 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. This can be project-level or global.
82
+ Add the plugin to your OpenCode config. Project-level or global.
83
83
 
84
- **Project-level** — `opencode.json` in your project root:
84
+ **Project-level** (`opencode.json` in your project root):
85
85
 
86
86
  ```json
87
87
  {
@@ -89,7 +89,7 @@ Add the plugin to your OpenCode config. This can be project-level or global.
89
89
  }
90
90
  ```
91
91
 
92
- **Global** — `~/.config/opencode/opencode.json`:
92
+ **Global** (`~/.config/opencode/opencode.json`):
93
93
 
94
94
  ```json
95
95
  {
@@ -97,7 +97,7 @@ Add the plugin to your OpenCode config. This can be project-level or global.
97
97
  }
98
98
  ```
99
99
 
100
- That's it. OpenCode auto-installs npm plugins at startup (cached in `~/.cache/opencode/node_modules/`). No `npm install` or `bun add` needed.
100
+ OpenCode auto-installs npm plugins at startup (cached in `~/.cache/opencode/node_modules/`). No `npm install` needed.
101
101
 
102
102
  ### Local development
103
103
 
@@ -115,15 +115,15 @@ export { default } from "/path/to/opencode-ensemble/src/index.ts"
115
115
 
116
116
  ## Tools
117
117
 
118
- 13 tools total. The lead has all of them. Teammates get 6 (messaging + tasks).
118
+ 13 tools. The lead has all of them. Teammates get 6 (messaging + tasks).
119
119
 
120
120
  **Team lifecycle** (lead only)
121
121
 
122
122
  | Tool | What it does |
123
123
  |------|-------------|
124
124
  | `team_create` | Create a team. Caller becomes the lead. |
125
- | `team_spawn` | Start a new teammate with a task. |
126
- | `team_shutdown` | Ask a teammate to stop. |
125
+ | `team_spawn` | Start a new teammate with a task. Supports `plan_approval` mode. |
126
+ | `team_shutdown` | Ask a teammate to stop. Supports `force` flag for immediate abort. |
127
127
  | `team_cleanup` | Remove the team when done. |
128
128
  | `team_status` | See all members, their status, and a task summary. |
129
129
  | `team_view` | Switch the TUI to a teammate's session. |
@@ -132,7 +132,7 @@ export { default } from "/path/to/opencode-ensemble/src/index.ts"
132
132
 
133
133
  | Tool | What it does |
134
134
  |------|-------------|
135
- | `team_message` | Send a direct message to a teammate or the lead. |
135
+ | `team_message` | Send a direct message to a teammate or the lead. Also handles plan approval/rejection. |
136
136
  | `team_broadcast` | Message everyone on the team. |
137
137
  | `team_results` | Retrieve full message content (messages to lead are truncated on delivery). |
138
138
 
@@ -147,28 +147,31 @@ export { default } from "/path/to/opencode-ensemble/src/index.ts"
147
147
 
148
148
  ## What you see in the TUI
149
149
 
150
- The plugin works within OpenCode's existing TUI. There's no custom team panel (that requires core TUI changes, which are [in progress upstream](https://github.com/sst/opencode)).
150
+ The plugin works within OpenCode's existing TUI. No custom team panel (that requires core TUI changes, which are [in progress upstream](https://github.com/sst/opencode)).
151
151
 
152
- What you do get:
152
+ What you get:
153
153
 
154
154
  - **Toast notifications** when teammates spawn, finish, error, shut down, or get rate-limited
155
155
  - **Working progress toasts** showing who's still active after every status change (e.g. "Working: alice, bob (2/3)")
156
156
  - **Rich tool titles** in the sidebar (e.g. "Spawned alice (build)", "Message -> bob", "Task board (3 tasks)")
157
157
  - **Session switching** via `team_view` to see any teammate's full chat log
158
- - **Status checks** via `team_status` for a snapshot of the whole team (also fires a toast)
158
+ - **Status checks** via `team_status` for a snapshot of the whole team
159
159
 
160
- Teammate messages arrive in the lead's session as `[Team message from alice]: ...` blocks. They look like user messages in the TUI because that's how `promptAsync` delivery works. The content is clearly labeled with the sender's name.
160
+ Teammate messages arrive in the lead's session as `[Team message from alice]: ...` blocks. They look like user messages because that's how `promptAsync` delivery works. Content is clearly labeled with the sender's name.
161
161
 
162
162
  ## Architecture
163
163
 
164
164
  - **SQLite** (`bun:sqlite`, WAL mode) for teams, members, tasks, and messages
165
165
  - **promptAsync** for message delivery: injects a message and starts the prompt loop in one call
166
- - **Git worktree isolation**: each teammate gets their own worktree by default, so multiple agents can edit files without conflicts. The lead merges branches after cleanup. Opt out with `worktree: false` for read-only agents.
166
+ - **Git worktree isolation**: each teammate gets their own worktree by default, so multiple agents can edit files without conflicts. Opt out with `worktree: false` for read-only agents.
167
+ - **System prompt injection**: the lead's system prompt includes team state (member statuses, task counts) on every LLM call. Teammates get a short role reminder.
168
+ - **Compaction safety**: team context is preserved when OpenCode compacts long conversations
169
+ - **Shell environment**: teammate shells get `ENSEMBLE_TEAM`, `ENSEMBLE_MEMBER`, `ENSEMBLE_ROLE`, and `ENSEMBLE_BRANCH` variables
167
170
  - **Sub-agent isolation**: teammates' sub-agents can't use team tools (parent chain tracking, max depth 10)
168
171
  - **Crash recovery**: stale busy members marked as errored on restart, orphaned sessions aborted, orphaned worktrees cleaned up, undelivered messages redelivered
169
172
  - **Spawn rollback**: if the initial prompt fails, the member, session, and worktree are all cleaned up
170
173
  - **Timeout watchdog**: teammates stuck busy beyond the TTL are automatically timed out and aborted
171
- - **Shutdown resilience**: abort failures handled gracefully, busy-after-shutdown triggers re-abort
174
+ - **Graceful shutdown**: busy teammates receive a shutdown message and finish their current work. Use `force: true` to abort immediately.
172
175
  - **Rate limiting**: token bucket (configurable via `OPENCODE_ENSEMBLE_RATE_LIMIT`, default 10 tokens/sec)
173
176
 
174
177
  ## Configuration
@@ -187,12 +190,34 @@ OPENCODE_ENSEMBLE_TIMEOUT=3600000
187
190
  OPENCODE_ENSEMBLE_TIMEOUT=0
188
191
  ```
189
192
 
193
+ ## Best practices
194
+
195
+ - Start with 2-3 teammates. More agents means more coordination overhead.
196
+ - Give each teammate specific, self-contained tasks. Vague prompts produce vague results.
197
+ - Spawn an explore agent first to understand the codebase, then spawn build agents with that context.
198
+ - Use `worktree: false` for read-only agents (research, review, code analysis).
199
+ - Use `plan_approval: true` for risky changes. The teammate sends a plan first, you review and approve before they write any code.
200
+ - Don't micromanage. Teammates message you when done or when they're blocked.
201
+ - Don't poll `team_status` in a loop. Wait for messages.
202
+
203
+ ## How this differs from Claude Code agent teams
204
+
205
+ Same coordination model (shared tasks, peer messaging, lead coordination) with some additions:
206
+
207
+ - **Git worktree isolation by default**: each teammate gets their own branch, no merge conflicts between parallel agents
208
+ - **System prompt injection**: the lead's system prompt is updated with team state so it stays aware across turns
209
+ - **Compaction safety**: team context is preserved when sessions get long
210
+ - **Team-aware shell environment**: `ENSEMBLE_TEAM`, `ENSEMBLE_MEMBER`, `ENSEMBLE_ROLE`, `ENSEMBLE_BRANCH`
211
+ - **Graceful shutdown**: teammates finish current work before stopping, with a force flag for emergencies
212
+ - **Plan approval mode**: review teammate plans before they write code
213
+ - **Works today as a plugin**: install and go, no upstream changes needed
214
+
190
215
  ## Development
191
216
 
192
217
  ```bash
193
218
  bun install
194
219
  bun run typecheck
195
- bun test # 144 tests
220
+ bun test # 286 tests
196
221
  bun run build
197
222
  ```
198
223
 
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);
@@ -13549,9 +13708,10 @@ var plugin = async (input) => {
13549
13708
  args: {},
13550
13709
  async execute(_args, ctx) {
13551
13710
  const result = await executeTeamStatus(deps, ctx.sessionID);
13552
- const members = deps.db.query("SELECT status FROM team_member WHERE team_id IN (SELECT id FROM team WHERE lead_session_id = ? OR id IN (SELECT team_id FROM team_member WHERE session_id = ?))").all(ctx.sessionID, ctx.sessionID);
13553
- const busy = members.filter((m) => m.status === "busy").length;
13554
- ctx.metadata({ title: `Team: ${members.length} members (${busy} working)` });
13711
+ const statusMap = { busy: "working", ready: "idle", shutdown_requested: "stopping", shutdown: "done", error: "error" };
13712
+ const members = deps.db.query("SELECT name, status FROM team_member WHERE team_id IN (SELECT id FROM team WHERE lead_session_id = ? OR id IN (SELECT team_id FROM team_member WHERE session_id = ?))").all(ctx.sessionID, ctx.sessionID);
13713
+ const summary = members.map((m) => `${m.name}: ${statusMap[m.status] ?? m.status}`).join(", ");
13714
+ ctx.metadata({ title: summary || "No teammates" });
13555
13715
  return result;
13556
13716
  }
13557
13717
  }),
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.1",
4
4
  "description": "Agent teams for OpenCode — parallel agents with peer-to-peer communication, shared tasks, and coordinated execution",
5
5
  "module": "src/index.ts",
6
6
  "main": "dist/index.js",
@@ -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)
@@ -303,9 +346,10 @@ const plugin: Plugin = async (input) => {
303
346
  args: {},
304
347
  async execute(_args, ctx) {
305
348
  const result = await executeTeamStatus(deps, ctx.sessionID)
306
- const members = deps.db.query("SELECT status FROM team_member WHERE team_id IN (SELECT id FROM team WHERE lead_session_id = ? OR id IN (SELECT team_id FROM team_member WHERE session_id = ?))").all(ctx.sessionID, ctx.sessionID) as Array<{ status: string }>
307
- const busy = members.filter(m => m.status === "busy").length
308
- ctx.metadata({ title: `Team: ${members.length} members (${busy} working)` })
349
+ const statusMap: Record<string, string> = { busy: "working", ready: "idle", shutdown_requested: "stopping", shutdown: "done", error: "error" }
350
+ const members = deps.db.query("SELECT name, status FROM team_member WHERE team_id IN (SELECT id FROM team WHERE lead_session_id = ? OR id IN (SELECT team_id FROM team_member WHERE session_id = ?))").all(ctx.sessionID, ctx.sessionID) as Array<{ name: string; status: string }>
351
+ const summary = members.map(m => `${m.name}: ${statusMap[m.status] ?? m.status}`).join(", ")
352
+ ctx.metadata({ title: summary || "No teammates" })
309
353
  return result
310
354
  },
311
355
  }),
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
  /**