@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 +52 -27
- package/dist/index.js +200 -40
- package/package.json +3 -3
- package/src/index.ts +49 -5
- package/src/schema.ts +3 -0
- package/src/system-prompt.ts +111 -0
- package/src/tools/team-message.ts +36 -5
- package/src/tools/team-shutdown.ts +66 -35
- package/src/tools/team-spawn.ts +35 -5
- package/src/tools/team-status.ts +4 -3
- package/src/types.ts +2 -0
package/README.md
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
# OpenCode Ensemble
|
|
2
2
|
|
|
3
|
-
Run
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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()
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
82
|
+
Add the plugin to your OpenCode config. Project-level or global.
|
|
83
83
|
|
|
84
|
-
**Project-level**
|
|
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**
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
- **
|
|
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 #
|
|
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: (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 &&
|
|
12888
|
-
const truncated =
|
|
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}]: ${
|
|
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
|
-
|
|
12953
|
-
|
|
12954
|
-
await deps.
|
|
12955
|
-
|
|
12956
|
-
|
|
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
|
-
|
|
12969
|
-
|
|
12970
|
-
|
|
12971
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
13553
|
-
const
|
|
13554
|
-
|
|
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.
|
|
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.
|
|
42
|
-
"@opencode-ai/sdk": "^1.2.
|
|
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
|
|
307
|
-
const
|
|
308
|
-
|
|
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:
|
|
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 &&
|
|
33
|
-
const truncated =
|
|
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}]: ${
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
31
|
-
|
|
32
|
-
await deps.
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
//
|
|
46
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
//
|
|
64
|
+
// promptAsync failed — best effort
|
|
61
65
|
}
|
|
62
66
|
|
|
63
|
-
|
|
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
|
}
|
package/src/tools/team-spawn.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/tools/team-status.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|