@hueyexe/opencode-ensemble 0.11.0 → 0.12.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 +91 -22
- package/dist/index.js +149 -17
- package/package.json +1 -1
- package/src/config.ts +26 -3
- package/src/dashboard-js-part2.ts +1 -0
- package/src/index.ts +36 -2
- package/src/system-prompt.ts +34 -1
- package/src/tools/merge-helper.ts +22 -0
- package/src/tools/team-cleanup.ts +14 -2
- package/src/tools/team-merge.ts +16 -3
- package/src/tools/team-spawn.ts +50 -10
- package/src/types.ts +3 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
|
|
4
4
|
[](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
|
|
5
|
-
[]()
|
|
6
6
|
[]()
|
|
7
7
|
[]()
|
|
8
8
|
[](./LICENSE)
|
|
@@ -11,6 +11,16 @@ Run parallel AI agents in OpenCode. Each agent gets its own session, context win
|
|
|
11
11
|
|
|
12
12
|
Plugin built on the public OpenCode SDK. No internal dependencies.
|
|
13
13
|
|
|
14
|
+
## Quick Start
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"plugin": ["@hueyexe/opencode-ensemble@0.12.1"]
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Add to your `opencode.json`, restart OpenCode, and ask it to do something that benefits from parallel work. The agent handles the rest. See [Install](#install) for full setup including worktree permissions.
|
|
23
|
+
|
|
14
24
|
## What actually happens
|
|
15
25
|
|
|
16
26
|
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.
|
|
@@ -126,7 +136,7 @@ Add to your OpenCode config with a pinned version. Project-level or global.
|
|
|
126
136
|
|
|
127
137
|
```json
|
|
128
138
|
{
|
|
129
|
-
"plugin": ["@hueyexe/opencode-ensemble@0.
|
|
139
|
+
"plugin": ["@hueyexe/opencode-ensemble@0.12.1"]
|
|
130
140
|
}
|
|
131
141
|
```
|
|
132
142
|
|
|
@@ -134,7 +144,7 @@ Add to your OpenCode config with a pinned version. Project-level or global.
|
|
|
134
144
|
|
|
135
145
|
```json
|
|
136
146
|
{
|
|
137
|
-
"plugin": ["@hueyexe/opencode-ensemble@0.
|
|
147
|
+
"plugin": ["@hueyexe/opencode-ensemble@0.12.1"]
|
|
138
148
|
}
|
|
139
149
|
```
|
|
140
150
|
|
|
@@ -170,17 +180,15 @@ This is required. Without it, you'll see "Permission required — Access externa
|
|
|
170
180
|
|
|
171
181
|
### Local development
|
|
172
182
|
|
|
173
|
-
|
|
183
|
+
To test a local build, point your plugin config at the built output:
|
|
174
184
|
|
|
175
|
-
```
|
|
176
|
-
|
|
185
|
+
```json
|
|
186
|
+
{
|
|
187
|
+
"plugin": ["/path/to/opencode-ensemble/dist/index.js"]
|
|
188
|
+
}
|
|
177
189
|
```
|
|
178
190
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
```ts
|
|
182
|
-
export { default } from "/path/to/opencode-ensemble/src/index.ts"
|
|
183
|
-
```
|
|
191
|
+
Build with `bun run build`, then restart OpenCode to pick up changes.
|
|
184
192
|
|
|
185
193
|
## Tools
|
|
186
194
|
|
|
@@ -193,7 +201,7 @@ export { default } from "/path/to/opencode-ensemble/src/index.ts"
|
|
|
193
201
|
| `team_create` | Create a team. Caller becomes the lead. |
|
|
194
202
|
| `team_spawn` | Start a new teammate with a task. Supports `plan_approval` mode. |
|
|
195
203
|
| `team_shutdown` | Ask a teammate to stop. Preserves their branch before aborting. Supports `force` flag. |
|
|
196
|
-
| `team_merge` | Merge a shutdown teammate's branch into working directory (unstaged). |
|
|
204
|
+
| `team_merge` | Merge a shutdown teammate's branch into working directory (unstaged). Blocks if you have local changes to overlapping files. |
|
|
197
205
|
| `team_cleanup` | Remove the team when done. Safety-net merges any forgotten branches. |
|
|
198
206
|
| `team_status` | See all members, their status, and a task summary. |
|
|
199
207
|
| `team_view` | Switch the TUI to a teammate's session. |
|
|
@@ -217,7 +225,7 @@ export { default } from "/path/to/opencode-ensemble/src/index.ts"
|
|
|
217
225
|
|
|
218
226
|
## What you see in the TUI
|
|
219
227
|
|
|
220
|
-
The plugin works within OpenCode's existing TUI.
|
|
228
|
+
The plugin works within OpenCode's existing TUI. For deeper visibility, open the [dashboard](#dashboard) at `http://localhost:4747`.
|
|
221
229
|
|
|
222
230
|
What you get:
|
|
223
231
|
|
|
@@ -244,10 +252,61 @@ Teammate messages arrive in the lead's session as `[Team message from alice]: ..
|
|
|
244
252
|
- **Stall detection**: detects teammates making no progress (low output tokens or no communication) and escalates to the lead
|
|
245
253
|
- **Peer-to-peer communication**: teammates can message each other directly, with idle-flush delivery and chatty agent detection
|
|
246
254
|
- **Auto-merge on cleanup**: worktree branches are squash-merged into your working directory as unstaged changes for review
|
|
255
|
+
- **Overlap detection**: `team_merge` blocks when you have local changes to files the agent also modified, preventing silent overwrites
|
|
247
256
|
- **Spawn circuit breaker**: stops retrying after 3 consecutive spawn failures
|
|
248
257
|
- **Graceful shutdown**: busy teammates receive a shutdown message and finish their current work. Use `force: true` to abort immediately.
|
|
249
258
|
- **Rate limiting**: token bucket (configurable via config file or `OPENCODE_ENSEMBLE_RATE_LIMIT`, default 10 tokens/sec)
|
|
250
259
|
|
|
260
|
+
## Model Selection
|
|
261
|
+
|
|
262
|
+
Control which AI models your agents use. By default, agents use whatever model OpenCode is configured with. You can override this per-agent, per-agent-type, or with automatic rotation.
|
|
263
|
+
|
|
264
|
+
**All agents use the same model:**
|
|
265
|
+
```json
|
|
266
|
+
{
|
|
267
|
+
"defaultModel": "anthropic/claude-sonnet-4-6"
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
**Different models for different agent types:**
|
|
272
|
+
```json
|
|
273
|
+
{
|
|
274
|
+
"modelsByAgent": {
|
|
275
|
+
"build": "anthropic/claude-opus-4-6",
|
|
276
|
+
"explore": "opencode/gpt-5-nano"
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
**Rotate through a pool for diverse perspectives:**
|
|
282
|
+
```json
|
|
283
|
+
{
|
|
284
|
+
"modelPool": ["anthropic/claude-opus-4-6", "anthropic/claude-sonnet-4-6", "openai/gpt-5.4"],
|
|
285
|
+
"modelAssignment": "rotate"
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
**Ask the user before spawning:**
|
|
290
|
+
```json
|
|
291
|
+
{
|
|
292
|
+
"promptForModels": true,
|
|
293
|
+
"modelPool": ["anthropic/claude-opus-4-6", "opencode/big-pickle", "opencode/gpt-5-nano"]
|
|
294
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
When `promptForModels` is true, the lead uses the question tool to ask which models to use before spawning any agents. The user can pick the same model for all agents, mix from the pool, or choose per agent.
|
|
298
|
+
|
|
299
|
+
**Resolution order** — when an agent is spawned, the model is determined by:
|
|
300
|
+
1. Explicit `model` param on `team_spawn` (lead or user chose it)
|
|
301
|
+
2. `modelsByAgent` mapping for this agent type
|
|
302
|
+
3. `modelAssignment` strategy (`rotate` or `random` from `modelPool`)
|
|
303
|
+
4. `defaultModel`
|
|
304
|
+
5. OpenCode's default model
|
|
305
|
+
|
|
306
|
+
The lead can always override by passing `model` directly on `team_spawn`, regardless of config.
|
|
307
|
+
|
|
308
|
+
Model IDs use the `provider/model` format from [models.dev](https://models.dev) (e.g. `anthropic/claude-opus-4-6`, `openai/gpt-5.4`). For OpenCode Zen models, use the `opencode/` prefix (e.g. `opencode/big-pickle`).
|
|
309
|
+
|
|
251
310
|
## Configuration
|
|
252
311
|
|
|
253
312
|
Configure via JSON files, environment variables, or both. Project config overrides global config. Env vars override everything.
|
|
@@ -259,12 +318,17 @@ Configure via JSON files, environment variables, or both. Project config overrid
|
|
|
259
318
|
```json
|
|
260
319
|
{
|
|
261
320
|
"mergeOnCleanup": true,
|
|
262
|
-
"stallThresholdMs":
|
|
263
|
-
"stallMinSteps":
|
|
264
|
-
"stallTokenThreshold":
|
|
321
|
+
"stallThresholdMs": 300000,
|
|
322
|
+
"stallMinSteps": 5,
|
|
323
|
+
"stallTokenThreshold": 200,
|
|
265
324
|
"timeoutMs": 1800000,
|
|
266
325
|
"rateLimitCapacity": 10,
|
|
267
|
-
"dashboardPort": 4747
|
|
326
|
+
"dashboardPort": 4747,
|
|
327
|
+
"defaultModel": "anthropic/claude-sonnet-4-6",
|
|
328
|
+
"modelPool": ["anthropic/claude-opus-4-6", "anthropic/claude-sonnet-4-6", "openai/gpt-5.4"],
|
|
329
|
+
"modelsByAgent": {},
|
|
330
|
+
"modelAssignment": "default",
|
|
331
|
+
"promptForModels": false
|
|
268
332
|
}
|
|
269
333
|
```
|
|
270
334
|
|
|
@@ -275,12 +339,17 @@ All fields are optional. Missing fields use defaults.
|
|
|
275
339
|
| Key | Default | Description |
|
|
276
340
|
|-----|---------|-------------|
|
|
277
341
|
| `mergeOnCleanup` | `true` | Auto-merge worktree branches on cleanup (squash + unstage) |
|
|
278
|
-
| `stallThresholdMs` | `
|
|
279
|
-
| `stallMinSteps` | `
|
|
280
|
-
| `stallTokenThreshold` | `
|
|
342
|
+
| `stallThresholdMs` | `300000` (5 min) | Time without communication before stall escalation. `0` disables. |
|
|
343
|
+
| `stallMinSteps` | `5` | Min model steps before token-based stall check kicks in |
|
|
344
|
+
| `stallTokenThreshold` | `200` | Output tokens per step below which the agent is considered stalled |
|
|
281
345
|
| `timeoutMs` | `1800000` (30 min) | Hard timeout for busy teammates. `0` disables. |
|
|
282
346
|
| `rateLimitCapacity` | `10` | Token bucket capacity for team tool calls. `0` disables. |
|
|
283
347
|
| `dashboardPort` | `4747` | Dashboard server port. `0` disables. |
|
|
348
|
+
| `defaultModel` | `""` | Default model for all agents (e.g. `"anthropic/claude-sonnet-4-6"`). Empty = OpenCode's default. |
|
|
349
|
+
| `modelPool` | `[]` | List of models for rotation/random assignment. |
|
|
350
|
+
| `modelsByAgent` | `{}` | Map agent type to model (e.g. `{"build": "anthropic/claude-opus-4-6"}`). |
|
|
351
|
+
| `modelAssignment` | `"default"` | How to assign models: `"default"`, `"rotate"`, or `"random"`. |
|
|
352
|
+
| `promptForModels` | `false` | Lead asks user about model preferences before spawning. |
|
|
284
353
|
|
|
285
354
|
### Environment variables
|
|
286
355
|
|
|
@@ -299,7 +368,7 @@ OPENCODE_ENSEMBLE_RATE_LIMIT=20
|
|
|
299
368
|
# Disable rate limiting
|
|
300
369
|
OPENCODE_ENSEMBLE_RATE_LIMIT=0
|
|
301
370
|
|
|
302
|
-
# Adjust stall detection threshold (default:
|
|
371
|
+
# Adjust stall detection threshold (default: 300000ms = 5 minutes)
|
|
303
372
|
STALL_THRESHOLD_MS=300000
|
|
304
373
|
|
|
305
374
|
# Disable stall detection
|
|
@@ -337,7 +406,7 @@ Same coordination model (shared tasks, peer messaging, lead coordination) with s
|
|
|
337
406
|
```bash
|
|
338
407
|
bun install
|
|
339
408
|
bun run typecheck
|
|
340
|
-
bun test #
|
|
409
|
+
bun test # 482 tests
|
|
341
410
|
bun run build
|
|
342
411
|
```
|
|
343
412
|
|
package/dist/index.js
CHANGED
|
@@ -36,6 +36,7 @@ __export(exports_merge_helper, {
|
|
|
36
36
|
gitStashPop: () => gitStashPop,
|
|
37
37
|
gitStash: () => gitStash,
|
|
38
38
|
gitReset: () => gitReset,
|
|
39
|
+
getOverlappingFiles: () => getOverlappingFiles,
|
|
39
40
|
deleteBranch: () => deleteBranch
|
|
40
41
|
});
|
|
41
42
|
async function preserveBranch(sourceBranch, targetBranch, cwd) {
|
|
@@ -112,6 +113,22 @@ async function mergeBranch(branch, cwd) {
|
|
|
112
113
|
}
|
|
113
114
|
return { ok: true };
|
|
114
115
|
}
|
|
116
|
+
async function getOverlappingFiles(branch, cwd) {
|
|
117
|
+
const run = async (args) => {
|
|
118
|
+
const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
119
|
+
const out = await new Response(proc.stdout).text();
|
|
120
|
+
const exit = await proc.exited;
|
|
121
|
+
if (exit !== 0)
|
|
122
|
+
throw new Error(`git ${args.join(" ")} failed with exit code ${exit}`);
|
|
123
|
+
return out.split(`
|
|
124
|
+
`).filter(Boolean);
|
|
125
|
+
};
|
|
126
|
+
const agentFiles = new Set(await run(["diff", "--name-only", "HEAD", branch]));
|
|
127
|
+
const localChanged = await run(["diff", "--name-only", "HEAD"]);
|
|
128
|
+
const localUntracked = await run(["ls-files", "--others", "--exclude-standard"]);
|
|
129
|
+
const localFiles = [...new Set([...localChanged, ...localUntracked])];
|
|
130
|
+
return localFiles.filter((f) => agentFiles.has(f));
|
|
131
|
+
}
|
|
115
132
|
function preservedBranchName(teamName, memberName) {
|
|
116
133
|
return `ensemble/preserved/${teamName}/${memberName}`;
|
|
117
134
|
}
|
|
@@ -15927,7 +15944,7 @@ var STATUS_DISPLAY = {
|
|
|
15927
15944
|
shutdown: "shut down",
|
|
15928
15945
|
error: "error"
|
|
15929
15946
|
};
|
|
15930
|
-
function buildLeadSystemPrompt(db, teamId) {
|
|
15947
|
+
function buildLeadSystemPrompt(db, teamId, config2) {
|
|
15931
15948
|
const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId);
|
|
15932
15949
|
if (!team)
|
|
15933
15950
|
return "";
|
|
@@ -15979,6 +15996,15 @@ function buildLeadSystemPrompt(db, teamId) {
|
|
|
15979
15996
|
}
|
|
15980
15997
|
lines.push("--- End Messages ---");
|
|
15981
15998
|
}
|
|
15999
|
+
if (config2?.promptForModels) {
|
|
16000
|
+
const poolOptions = config2.modelPool && config2.modelPool.length > 0 ? config2.modelPool.map((m) => ` { label: "${m}", description: "" }`).join(`,
|
|
16001
|
+
`) : "";
|
|
16002
|
+
lines.push("", "MODEL SELECTION:", "Before spawning teammates, use the question tool to ask the user about model preferences.", "Do NOT spawn any agents until the user confirms their model preference.", "Keep descriptions simple and clear \u2014 explain what each option means in plain language.", "Example question tool call:", ' question({ questions: [{ question: "Which AI models should your team agents use?", header: "Agent models", options: [', ` { label: "Same as me (Recommended)", description: "Every agent uses the same model I'm running on. Simplest option \u2014 no extra setup needed." },`, ' { label: "Mix of models", description: "Each agent gets a different model from your configured pool. Useful for getting diverse perspectives on the same problem." },', ` { label: "I'll choose per agent", description: "You pick the exact model for each agent as I spawn them. Most control, but requires a choice per agent." }`, " ]}]})");
|
|
16003
|
+
if (poolOptions) {
|
|
16004
|
+
lines.push('If user picks "Mix of models", ask which models with multiple: true:', ' question({ questions: [{ question: "Which models should agents rotate through? Pick all that apply.", header: "Model pool", multiple: true, options: [', poolOptions, " ]}]})");
|
|
16005
|
+
}
|
|
16006
|
+
lines.push(`If user picks "I'll choose per agent", ask for each agent's model individually before each team_spawn call.`, "Pass the chosen model via the model parameter on each team_spawn call.");
|
|
16007
|
+
}
|
|
15982
16008
|
lines.push("", "CRITICAL: Spawn teammates ONE AT A TIME. Send only ONE team_spawn call per response.", "Wait for the tool result before spawning the next teammate.", "Multiple team_spawn calls in a single response will cause timeouts.", "", "Teammates work asynchronously and message you when done.", "Do NOT poll team_status or team_tasks_list repeatedly \u2014 wait for messages.", "After spawning all teammates, tell the user what you've set up and wait.", "When all teammates finish, summarize results and suggest next steps.", "", "MERGE WORKFLOW:", "After a teammate finishes and you shut them down, use team_merge to merge their branch.", "Do NOT tell teammates to commit \u2014 they handle that themselves.", "Do NOT run git merge manually \u2014 use team_merge which handles stashing and unstaging.", "team_cleanup will safety-net merge any branches you forgot, but prefer explicit team_merge.", "", "Before calling team_cleanup, verify teammates have committed their work.", "team_shutdown will warn you if a teammate has uncommitted changes.", "team_cleanup will block if any worktree has uncommitted changes \u2014 merge or commit first.");
|
|
15983
16009
|
return lines.join(`
|
|
15984
16010
|
`);
|
|
@@ -16086,14 +16112,19 @@ import { readFileSync } from "fs";
|
|
|
16086
16112
|
import path2 from "path";
|
|
16087
16113
|
var DEFAULT_CONFIG = {
|
|
16088
16114
|
mergeOnCleanup: true,
|
|
16089
|
-
stallThresholdMs:
|
|
16090
|
-
stallMinSteps:
|
|
16091
|
-
stallTokenThreshold:
|
|
16115
|
+
stallThresholdMs: 300000,
|
|
16116
|
+
stallMinSteps: 5,
|
|
16117
|
+
stallTokenThreshold: 200,
|
|
16092
16118
|
timeoutMs: 30 * 60 * 1000,
|
|
16093
16119
|
rateLimitCapacity: 10,
|
|
16094
16120
|
dashboardPort: 4747,
|
|
16095
16121
|
peerMessageLimit: 5,
|
|
16096
|
-
peerMessageWindowMs: 300000
|
|
16122
|
+
peerMessageWindowMs: 300000,
|
|
16123
|
+
defaultModel: "",
|
|
16124
|
+
modelPool: [],
|
|
16125
|
+
modelsByAgent: {},
|
|
16126
|
+
modelAssignment: "default",
|
|
16127
|
+
promptForModels: false
|
|
16097
16128
|
};
|
|
16098
16129
|
function readConfigFile(filePath) {
|
|
16099
16130
|
try {
|
|
@@ -16118,6 +16149,19 @@ function readConfigFile(filePath) {
|
|
|
16118
16149
|
result.peerMessageLimit = raw.peerMessageLimit;
|
|
16119
16150
|
if (typeof raw.peerMessageWindowMs === "number")
|
|
16120
16151
|
result.peerMessageWindowMs = raw.peerMessageWindowMs;
|
|
16152
|
+
if (typeof raw.defaultModel === "string")
|
|
16153
|
+
result.defaultModel = raw.defaultModel;
|
|
16154
|
+
if (Array.isArray(raw.modelPool) && raw.modelPool.every((m) => typeof m === "string"))
|
|
16155
|
+
result.modelPool = raw.modelPool;
|
|
16156
|
+
if (typeof raw.modelsByAgent === "object" && raw.modelsByAgent !== null && !Array.isArray(raw.modelsByAgent)) {
|
|
16157
|
+
const valid = Object.entries(raw.modelsByAgent).every(([, v]) => typeof v === "string");
|
|
16158
|
+
if (valid)
|
|
16159
|
+
result.modelsByAgent = raw.modelsByAgent;
|
|
16160
|
+
}
|
|
16161
|
+
if (typeof raw.modelAssignment === "string" && ["default", "rotate", "random"].includes(raw.modelAssignment))
|
|
16162
|
+
result.modelAssignment = raw.modelAssignment;
|
|
16163
|
+
if (typeof raw.promptForModels === "boolean")
|
|
16164
|
+
result.promptForModels = raw.promptForModels;
|
|
16121
16165
|
return result;
|
|
16122
16166
|
} catch (err) {
|
|
16123
16167
|
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT")
|
|
@@ -16489,6 +16533,7 @@ function rAgents(t){
|
|
|
16489
16533
|
'<span class="text-[10px] px-1.5 py-[1px] rounded '+s.t+' bg-base-800/80 shrink-0">'+s.l+'</span>'+
|
|
16490
16534
|
spark+
|
|
16491
16535
|
'<span class="text-[10px] text-txt-500 ml-auto shrink-0">'+E(m.agent)+'</span>'+
|
|
16536
|
+
(m.model?chip(E(m.model),'muted'):'')+
|
|
16492
16537
|
'</div>'+
|
|
16493
16538
|
(tr?'<div class="mt-2 text-[13px] text-txt-200 leading-snug truncate">'+E(tr)+'</div>':'')+
|
|
16494
16539
|
'<div class="mt-2 flex items-center gap-1.5 flex-wrap">'+
|
|
@@ -16861,6 +16906,27 @@ function requireTeamMember(deps, sessionId) {
|
|
|
16861
16906
|
|
|
16862
16907
|
// src/tools/team-spawn.ts
|
|
16863
16908
|
var spawnFailures = new Map;
|
|
16909
|
+
function parseModelId(model) {
|
|
16910
|
+
const slash = model.indexOf("/");
|
|
16911
|
+
if (slash <= 0 || slash === model.length - 1)
|
|
16912
|
+
return;
|
|
16913
|
+
return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) };
|
|
16914
|
+
}
|
|
16915
|
+
function resolveModel(explicitModel, agentType, teamMemberCount, config2) {
|
|
16916
|
+
if (explicitModel)
|
|
16917
|
+
return explicitModel;
|
|
16918
|
+
if (config2.modelsByAgent[agentType])
|
|
16919
|
+
return config2.modelsByAgent[agentType];
|
|
16920
|
+
if (config2.modelAssignment === "rotate" && config2.modelPool.length > 0) {
|
|
16921
|
+
return config2.modelPool[teamMemberCount % config2.modelPool.length];
|
|
16922
|
+
}
|
|
16923
|
+
if (config2.modelAssignment === "random" && config2.modelPool.length > 0) {
|
|
16924
|
+
return config2.modelPool[Math.floor(Math.random() * config2.modelPool.length)];
|
|
16925
|
+
}
|
|
16926
|
+
if (config2.defaultModel)
|
|
16927
|
+
return config2.defaultModel;
|
|
16928
|
+
return;
|
|
16929
|
+
}
|
|
16864
16930
|
function getSpawnTimeout() {
|
|
16865
16931
|
return Number(process.env.SPAWN_TIMEOUT_MS) || 120000;
|
|
16866
16932
|
}
|
|
@@ -16984,8 +17050,12 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
16984
17050
|
}
|
|
16985
17051
|
const planApproval = usePlanApproval ? "pending" : "none";
|
|
16986
17052
|
const now = Date.now();
|
|
17053
|
+
const memberCount = deps.db.query("SELECT COUNT(*) as c FROM team_member WHERE team_id = ?").get(teamInfo.teamId).c;
|
|
17054
|
+
const resolvedModel = resolveModel(args.model, args.agent, memberCount, deps.config);
|
|
17055
|
+
if (resolvedModel)
|
|
17056
|
+
log(`spawn:model name=${args.name} model=${resolvedModel}`);
|
|
16987
17057
|
deps.db.run(`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, workspace_id, plan_approval, time_created, time_updated)
|
|
16988
|
-
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, args.agent,
|
|
17058
|
+
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, args.agent, resolvedModel ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]);
|
|
16989
17059
|
deps.registry.register(teamInfo.teamId, args.name, childSessionId);
|
|
16990
17060
|
const context = [
|
|
16991
17061
|
`You are "${args.name}", a teammate in team "${teamInfo.teamName}".`,
|
|
@@ -17037,13 +17107,19 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
17037
17107
|
}
|
|
17038
17108
|
const contextStr = context.join(`
|
|
17039
17109
|
`);
|
|
17110
|
+
const modelParam = resolvedModel ? parseModelId(resolvedModel) : undefined;
|
|
17111
|
+
if (resolvedModel && !modelParam) {
|
|
17112
|
+
log(`spawn:model:invalid name=${args.name} model=${resolvedModel} \u2014 expected "provider/model" format, falling back to default`);
|
|
17113
|
+
}
|
|
17040
17114
|
log(`spawn:promptAsync:fire name=${args.name} sessionId=${childSessionId}`);
|
|
17041
17115
|
deps.client.session.promptAsync({
|
|
17042
17116
|
sessionID: childSessionId,
|
|
17043
17117
|
parts: [{ type: "text", text: contextStr }],
|
|
17044
|
-
agent: args.agent
|
|
17045
|
-
|
|
17046
|
-
|
|
17118
|
+
agent: args.agent,
|
|
17119
|
+
...modelParam ? { model: modelParam } : {}
|
|
17120
|
+
}).catch((err) => {
|
|
17121
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
17122
|
+
log(`spawn:promptAsync:failed name=${args.name} err=${errMsg} \u2014 rolling back`);
|
|
17047
17123
|
try {
|
|
17048
17124
|
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId]);
|
|
17049
17125
|
deps.registry.unregister(childSessionId);
|
|
@@ -17054,17 +17130,18 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
17054
17130
|
if (worktreeDir) {
|
|
17055
17131
|
deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => {});
|
|
17056
17132
|
}
|
|
17133
|
+
const modelInfo = resolvedModel ? ` (model: ${resolvedModel})` : "";
|
|
17057
17134
|
deps.client.tui.showToast({
|
|
17058
17135
|
title: "Team",
|
|
17059
|
-
message: `Teammate "${args.name}" failed to start
|
|
17136
|
+
message: `Teammate "${args.name}" failed to start${modelInfo}: ${errMsg}`,
|
|
17060
17137
|
variant: "error",
|
|
17061
|
-
duration:
|
|
17138
|
+
duration: 8000
|
|
17062
17139
|
}).catch(() => {});
|
|
17063
17140
|
sendMessage(deps.db, {
|
|
17064
17141
|
teamId: teamInfo.teamId,
|
|
17065
17142
|
from: "system",
|
|
17066
17143
|
to: "lead",
|
|
17067
|
-
content: `Teammate "${args.name}" failed to start and was removed. You may retry the spawn.`
|
|
17144
|
+
content: `Teammate "${args.name}" failed to start and was removed${modelInfo}. Error: ${errMsg}. You may retry the spawn.`
|
|
17068
17145
|
});
|
|
17069
17146
|
} catch {}
|
|
17070
17147
|
});
|
|
@@ -17264,7 +17341,7 @@ Warning: ${memberName} has uncommitted changes in their worktree. Commit or merg
|
|
|
17264
17341
|
|
|
17265
17342
|
// src/tools/team-cleanup.ts
|
|
17266
17343
|
init_merge_helper();
|
|
17267
|
-
async function executeTeamCleanup(deps, args, sessionId, isDirty = checkWorktreeDirty, merge2 = mergeBranch, delBranch = deleteBranch, mergeOnCleanup = true) {
|
|
17344
|
+
async function executeTeamCleanup(deps, args, sessionId, isDirty = checkWorktreeDirty, merge2 = mergeBranch, delBranch = deleteBranch, mergeOnCleanup = true, overlapCheck = getOverlappingFiles) {
|
|
17268
17345
|
const teamInfo = requireLead(deps, sessionId);
|
|
17269
17346
|
const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch, workspace_id FROM team_member WHERE team_id = ?").all(teamInfo.teamId);
|
|
17270
17347
|
const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
|
|
@@ -17312,9 +17389,16 @@ Commit or merge their work first, then call team_cleanup with acknowledge_uncomm
|
|
|
17312
17389
|
const unmerged = members.filter((m) => m.worktree_branch !== null);
|
|
17313
17390
|
const merged = [];
|
|
17314
17391
|
const conflicted = [];
|
|
17392
|
+
const overlapWarnings = [];
|
|
17315
17393
|
if (unmerged.length > 0 && mergeOnCleanup) {
|
|
17316
17394
|
for (const member of unmerged) {
|
|
17317
17395
|
const branch = member.worktree_branch;
|
|
17396
|
+
try {
|
|
17397
|
+
const overlap = await overlapCheck(branch, deps.directory);
|
|
17398
|
+
if (overlap.length > 0) {
|
|
17399
|
+
overlapWarnings.push(`${member.name}: ${overlap.join(", ")}`);
|
|
17400
|
+
}
|
|
17401
|
+
} catch {}
|
|
17318
17402
|
const result = await merge2(branch, deps.directory);
|
|
17319
17403
|
if (result.ok) {
|
|
17320
17404
|
await delBranch(branch, deps.directory);
|
|
@@ -17350,13 +17434,19 @@ Commit or merge their work first, then call team_cleanup with acknowledge_uncomm
|
|
|
17350
17434
|
if (conflicted.length > 0) {
|
|
17351
17435
|
parts.push(`Could not auto-merge: ${conflicted.join(", ")}. Merge manually.`);
|
|
17352
17436
|
}
|
|
17437
|
+
if (overlapWarnings.length > 0) {
|
|
17438
|
+
parts.push(`Warning: safety-net merge overwrote local changes to overlapping files:
|
|
17439
|
+
${overlapWarnings.map((w) => ` - ${w}`).join(`
|
|
17440
|
+
`)}
|
|
17441
|
+
Check git stash list or git diff to recover.`);
|
|
17442
|
+
}
|
|
17353
17443
|
return parts.join(`
|
|
17354
17444
|
`);
|
|
17355
17445
|
}
|
|
17356
17446
|
|
|
17357
17447
|
// src/tools/team-merge.ts
|
|
17358
17448
|
init_merge_helper();
|
|
17359
|
-
async function executeTeamMerge(deps, args, sessionId, merge2 = mergeBranch, delBranch = deleteBranch) {
|
|
17449
|
+
async function executeTeamMerge(deps, args, sessionId, merge2 = mergeBranch, delBranch = deleteBranch, overlapCheck = getOverlappingFiles) {
|
|
17360
17450
|
const teamInfo = requireLead(deps, sessionId);
|
|
17361
17451
|
const member = deps.db.query("SELECT status, worktree_branch FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
|
|
17362
17452
|
if (!member)
|
|
@@ -17369,6 +17459,20 @@ async function executeTeamMerge(deps, args, sessionId, merge2 = mergeBranch, del
|
|
|
17369
17459
|
}
|
|
17370
17460
|
const branch = member.worktree_branch;
|
|
17371
17461
|
log(`merge:start member=${args.member} branch=${branch}`);
|
|
17462
|
+
try {
|
|
17463
|
+
const overlap = await overlapCheck(branch, deps.directory);
|
|
17464
|
+
if (overlap.length > 0) {
|
|
17465
|
+
const files = overlap.map((f) => ` - ${f}`).join(`
|
|
17466
|
+
`);
|
|
17467
|
+
return `Cannot merge ${args.member}'s branch \u2014 you have local changes to files that ${args.member} also modified:
|
|
17468
|
+
${files}
|
|
17469
|
+
|
|
17470
|
+
Commit or stash your changes to these files first, then retry team_merge.
|
|
17471
|
+
Branch preserved: ${branch}`;
|
|
17472
|
+
}
|
|
17473
|
+
} catch {
|
|
17474
|
+
log(`merge:overlap-check:failed member=${args.member} branch=${branch}`);
|
|
17475
|
+
}
|
|
17372
17476
|
const result = await merge2(branch, deps.directory);
|
|
17373
17477
|
if (!result.ok) {
|
|
17374
17478
|
return `Merge conflict with ${args.member}'s branch (${branch}). Resolve manually:
|
|
@@ -17379,7 +17483,10 @@ Error: ${result.error}`;
|
|
|
17379
17483
|
await delBranch(branch, deps.directory);
|
|
17380
17484
|
deps.db.run("UPDATE team_member SET worktree_branch = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, args.member]);
|
|
17381
17485
|
log(`merge:done member=${args.member} branch=${branch}`);
|
|
17382
|
-
|
|
17486
|
+
const msg = `Merged ${args.member}'s changes into working directory (unstaged). Review with: git diff`;
|
|
17487
|
+
return result.error ? `${msg}
|
|
17488
|
+
|
|
17489
|
+
Warning: ${result.error}` : msg;
|
|
17383
17490
|
}
|
|
17384
17491
|
|
|
17385
17492
|
// src/tools/team-tasks-list.ts
|
|
@@ -17821,7 +17928,7 @@ var plugin = async (input) => {
|
|
|
17821
17928
|
const rawClient = new OpencodeClient({ client: pluginTransport });
|
|
17822
17929
|
initLog(rawClient);
|
|
17823
17930
|
const client3 = wrapThrowingClient(rawClient);
|
|
17824
|
-
const deps = { db, registry: registry2, tracker, client: client3, directory: input.directory };
|
|
17931
|
+
const deps = { db, registry: registry2, tracker, client: client3, directory: input.directory, config: config2 };
|
|
17825
17932
|
if (!isWorktreeInstance(input.directory)) {
|
|
17826
17933
|
log("init:recovery:start (main instance)");
|
|
17827
17934
|
const recovery = await recoverStaleMembers(db, client3, input.directory);
|
|
@@ -17881,6 +17988,31 @@ var plugin = async (input) => {
|
|
|
17881
17988
|
notifyTeamEvent(client3, "shutdown", { memberName: transition.memberName });
|
|
17882
17989
|
} else if (transition.to === "ready" && transition.from === "busy") {
|
|
17883
17990
|
notifyTeamEvent(client3, "completed", { memberName: transition.memberName });
|
|
17991
|
+
const fastIdleKey = `fastidle:${transition.teamId}:${transition.memberName}`;
|
|
17992
|
+
if (!nudgedMembers.has(fastIdleKey)) {
|
|
17993
|
+
const memberInfo = db.query("SELECT time_created, model FROM team_member WHERE team_id = ? AND name = ?").get(transition.teamId, transition.memberName);
|
|
17994
|
+
if (memberInfo) {
|
|
17995
|
+
const spawnAge = Date.now() - memberInfo.time_created;
|
|
17996
|
+
const msgCount = db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND from_name = ?").get(transition.teamId, transition.memberName).c;
|
|
17997
|
+
if (spawnAge < 15000 && msgCount === 0) {
|
|
17998
|
+
nudgedMembers.add(fastIdleKey);
|
|
17999
|
+
const modelInfo = memberInfo.model ? ` (model: ${memberInfo.model})` : "";
|
|
18000
|
+
log(`fast-idle: ${transition.memberName} went idle ${Math.round(spawnAge / 1000)}s after spawn with 0 messages${modelInfo}`);
|
|
18001
|
+
sendMessage(db, {
|
|
18002
|
+
teamId: transition.teamId,
|
|
18003
|
+
from: "system",
|
|
18004
|
+
to: "lead",
|
|
18005
|
+
content: `Warning: Teammate "${transition.memberName}" went idle immediately after spawning with no output${modelInfo}. This usually means the model failed to start (authentication error, invalid model, or provider issue). Check your API key and model configuration, then retry the spawn.`
|
|
18006
|
+
});
|
|
18007
|
+
client3.tui.showToast({
|
|
18008
|
+
title: "Team",
|
|
18009
|
+
message: `${transition.memberName} failed to produce output${modelInfo}`,
|
|
18010
|
+
variant: "warning",
|
|
18011
|
+
duration: 8000
|
|
18012
|
+
}).catch(() => {});
|
|
18013
|
+
}
|
|
18014
|
+
}
|
|
18015
|
+
}
|
|
17884
18016
|
const nudgeKey = `${transition.teamId}:${transition.memberName}`;
|
|
17885
18017
|
if (!nudgedMembers.has(nudgeKey) && shouldNudgeIdleMember(db, transition.teamId, transition.memberName)) {
|
|
17886
18018
|
nudgedMembers.add(nudgeKey);
|
|
@@ -17983,7 +18115,7 @@ var plugin = async (input) => {
|
|
|
17983
18115
|
if (!teamInfo)
|
|
17984
18116
|
return;
|
|
17985
18117
|
log(`system-prompt:transform role=${teamInfo.role} session=${input2.sessionID}`);
|
|
17986
|
-
const prompt = teamInfo.role === "lead" ? buildLeadSystemPrompt(db, teamInfo.teamId) : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown");
|
|
18118
|
+
const prompt = teamInfo.role === "lead" ? buildLeadSystemPrompt(db, teamInfo.teamId, config2) : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown");
|
|
17987
18119
|
log(`system-prompt:injected role=${teamInfo.role} len=${prompt.length}`);
|
|
17988
18120
|
output.system.push(prompt);
|
|
17989
18121
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hueyexe/opencode-ensemble",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "Agent teams for OpenCode — parallel agents with peer-to-peer communication, shared tasks, and coordinated execution",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"main": "dist/index.js",
|
package/src/config.ts
CHANGED
|
@@ -21,19 +21,34 @@ export interface EnsembleConfig {
|
|
|
21
21
|
peerMessageLimit?: number
|
|
22
22
|
/** Time window for peer message rate limiting in ms (default: 300000 = 5 min) */
|
|
23
23
|
peerMessageWindowMs?: number
|
|
24
|
+
/** Default model for all agents (e.g. "opencode/zen-sonnet-4-6") */
|
|
25
|
+
defaultModel?: string
|
|
26
|
+
/** Pool of models for rotation/random assignment */
|
|
27
|
+
modelPool?: string[]
|
|
28
|
+
/** Map agent type to specific model e.g. {"build": "anthropic/claude-opus-4-6"} */
|
|
29
|
+
modelsByAgent?: Record<string, string>
|
|
30
|
+
/** How to assign models: "default" | "rotate" | "random" (default: "default") */
|
|
31
|
+
modelAssignment?: "default" | "rotate" | "random"
|
|
32
|
+
/** Lead asks user about model preferences before spawning (default: false) */
|
|
33
|
+
promptForModels?: boolean
|
|
24
34
|
}
|
|
25
35
|
|
|
26
36
|
/** Default configuration values. */
|
|
27
37
|
export const DEFAULT_CONFIG: Required<EnsembleConfig> = {
|
|
28
38
|
mergeOnCleanup: true,
|
|
29
|
-
stallThresholdMs:
|
|
30
|
-
stallMinSteps:
|
|
31
|
-
stallTokenThreshold:
|
|
39
|
+
stallThresholdMs: 300_000,
|
|
40
|
+
stallMinSteps: 5,
|
|
41
|
+
stallTokenThreshold: 200,
|
|
32
42
|
timeoutMs: 30 * 60 * 1000,
|
|
33
43
|
rateLimitCapacity: 10,
|
|
34
44
|
dashboardPort: 4747,
|
|
35
45
|
peerMessageLimit: 5,
|
|
36
46
|
peerMessageWindowMs: 300_000,
|
|
47
|
+
defaultModel: "",
|
|
48
|
+
modelPool: [],
|
|
49
|
+
modelsByAgent: {},
|
|
50
|
+
modelAssignment: "default",
|
|
51
|
+
promptForModels: false,
|
|
37
52
|
}
|
|
38
53
|
|
|
39
54
|
/** Read a JSON config file, returning an empty object on missing/invalid. */
|
|
@@ -52,6 +67,14 @@ function readConfigFile(filePath: string): Partial<EnsembleConfig> {
|
|
|
52
67
|
if (typeof raw.dashboardPort === "number") result.dashboardPort = raw.dashboardPort
|
|
53
68
|
if (typeof raw.peerMessageLimit === "number") result.peerMessageLimit = raw.peerMessageLimit
|
|
54
69
|
if (typeof raw.peerMessageWindowMs === "number") result.peerMessageWindowMs = raw.peerMessageWindowMs
|
|
70
|
+
if (typeof raw.defaultModel === "string") result.defaultModel = raw.defaultModel
|
|
71
|
+
if (Array.isArray(raw.modelPool) && raw.modelPool.every((m: unknown) => typeof m === "string")) result.modelPool = raw.modelPool as string[]
|
|
72
|
+
if (typeof raw.modelsByAgent === "object" && raw.modelsByAgent !== null && !Array.isArray(raw.modelsByAgent)) {
|
|
73
|
+
const valid = Object.entries(raw.modelsByAgent as Record<string, unknown>).every(([, v]) => typeof v === "string")
|
|
74
|
+
if (valid) result.modelsByAgent = raw.modelsByAgent as Record<string, string>
|
|
75
|
+
}
|
|
76
|
+
if (typeof raw.modelAssignment === "string" && ["default", "rotate", "random"].includes(raw.modelAssignment)) result.modelAssignment = raw.modelAssignment as "default" | "rotate" | "random"
|
|
77
|
+
if (typeof raw.promptForModels === "boolean") result.promptForModels = raw.promptForModels
|
|
55
78
|
return result
|
|
56
79
|
} catch (err) {
|
|
57
80
|
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return {}
|
|
@@ -64,6 +64,7 @@ function rAgents(t){
|
|
|
64
64
|
'<span class="text-[10px] px-1.5 py-[1px] rounded '+s.t+' bg-base-800/80 shrink-0">'+s.l+'</span>'+
|
|
65
65
|
spark+
|
|
66
66
|
'<span class="text-[10px] text-txt-500 ml-auto shrink-0">'+E(m.agent)+'</span>'+
|
|
67
|
+
(m.model?chip(E(m.model),'muted'):'')+
|
|
67
68
|
'</div>'+
|
|
68
69
|
(tr?'<div class="mt-2 text-[13px] text-txt-200 leading-snug truncate">'+E(tr)+'</div>':'')+
|
|
69
70
|
'<div class="mt-2 flex items-center gap-1.5 flex-wrap">'+
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { MemberRegistry, DescendantTracker } from "./state"
|
|
|
10
10
|
import { isWorktreeInstance } from "./util"
|
|
11
11
|
import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation, shouldNudgeIdleMember } from "./hooks"
|
|
12
12
|
import { notifyTeamEvent, notifyWorkingProgress } from "./notify"
|
|
13
|
+
import { sendMessage } from "./messaging"
|
|
13
14
|
import { buildLeadSystemPrompt, buildTeammateSystemPrompt, buildTeamCompactionContext } from "./system-prompt"
|
|
14
15
|
import { log, initLog } from "./log"
|
|
15
16
|
import { findTeamBySession } from "./types"
|
|
@@ -66,7 +67,7 @@ const plugin: Plugin = async (input) => {
|
|
|
66
67
|
const rawClient = new OpencodeClient({ client: pluginTransport })
|
|
67
68
|
initLog(rawClient)
|
|
68
69
|
const client = wrapThrowingClient(rawClient)
|
|
69
|
-
const deps: ToolDeps = { db, registry, tracker, client, directory: input.directory }
|
|
70
|
+
const deps: ToolDeps = { db, registry, tracker, client, directory: input.directory, config }
|
|
70
71
|
|
|
71
72
|
// Recovery only runs for the main project instance — NOT for teammate worktree instances.
|
|
72
73
|
// Worktree instances are created during session.create. Running recovery there makes HTTP
|
|
@@ -141,6 +142,39 @@ const plugin: Plugin = async (input) => {
|
|
|
141
142
|
notifyTeamEvent(client, "shutdown", { memberName: transition.memberName })
|
|
142
143
|
} else if (transition.to === "ready" && transition.from === "busy") {
|
|
143
144
|
notifyTeamEvent(client, "completed", { memberName: transition.memberName })
|
|
145
|
+
|
|
146
|
+
// Fast-idle detection: if agent went idle within 15s of spawn with zero messages,
|
|
147
|
+
// the model likely failed silently (auth error, invalid model, etc.)
|
|
148
|
+
const fastIdleKey = `fastidle:${transition.teamId}:${transition.memberName}`
|
|
149
|
+
if (!nudgedMembers.has(fastIdleKey)) {
|
|
150
|
+
const memberInfo = db.query(
|
|
151
|
+
"SELECT time_created, model FROM team_member WHERE team_id = ? AND name = ?"
|
|
152
|
+
).get(transition.teamId, transition.memberName) as { time_created: number; model: string | null } | null
|
|
153
|
+
if (memberInfo) {
|
|
154
|
+
const spawnAge = Date.now() - memberInfo.time_created
|
|
155
|
+
const msgCount = (db.query(
|
|
156
|
+
"SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND from_name = ?"
|
|
157
|
+
).get(transition.teamId, transition.memberName) as { c: number }).c
|
|
158
|
+
if (spawnAge < 15_000 && msgCount === 0) {
|
|
159
|
+
nudgedMembers.add(fastIdleKey)
|
|
160
|
+
const modelInfo = memberInfo.model ? ` (model: ${memberInfo.model})` : ""
|
|
161
|
+
log(`fast-idle: ${transition.memberName} went idle ${Math.round(spawnAge / 1000)}s after spawn with 0 messages${modelInfo}`)
|
|
162
|
+
sendMessage(db, {
|
|
163
|
+
teamId: transition.teamId,
|
|
164
|
+
from: "system",
|
|
165
|
+
to: "lead",
|
|
166
|
+
content: `Warning: Teammate "${transition.memberName}" went idle immediately after spawning with no output${modelInfo}. This usually means the model failed to start (authentication error, invalid model, or provider issue). Check your API key and model configuration, then retry the spawn.`,
|
|
167
|
+
})
|
|
168
|
+
client.tui.showToast({
|
|
169
|
+
title: "Team",
|
|
170
|
+
message: `${transition.memberName} failed to produce output${modelInfo}`,
|
|
171
|
+
variant: "warning",
|
|
172
|
+
duration: 8000,
|
|
173
|
+
}).catch(() => { /* TUI may not be available */ })
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
144
178
|
// Nudge teammate if they went idle without reporting to the lead (once only)
|
|
145
179
|
const nudgeKey = `${transition.teamId}:${transition.memberName}`
|
|
146
180
|
if (!nudgedMembers.has(nudgeKey) && shouldNudgeIdleMember(db, transition.teamId, transition.memberName)) {
|
|
@@ -270,7 +304,7 @@ const plugin: Plugin = async (input) => {
|
|
|
270
304
|
if (!teamInfo) return
|
|
271
305
|
log(`system-prompt:transform role=${teamInfo.role} session=${input.sessionID}`)
|
|
272
306
|
const prompt = teamInfo.role === "lead"
|
|
273
|
-
? buildLeadSystemPrompt(db, teamInfo.teamId)
|
|
307
|
+
? buildLeadSystemPrompt(db, teamInfo.teamId, config)
|
|
274
308
|
: buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown")
|
|
275
309
|
log(`system-prompt:injected role=${teamInfo.role} len=${prompt.length}`)
|
|
276
310
|
output.system.push(prompt)
|
package/src/system-prompt.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite"
|
|
2
2
|
import { markDelivered } from "./messaging"
|
|
3
3
|
import { parseTaskResult, formatTaskResult } from "./result-parser"
|
|
4
|
+
import type { EnsembleConfig } from "./config"
|
|
4
5
|
|
|
5
6
|
/** Truncate a string to maxLen chars, appending "..." if truncated. */
|
|
6
7
|
function truncate(s: string, maxLen: number): string {
|
|
@@ -20,7 +21,7 @@ const STATUS_DISPLAY: Record<string, string> = {
|
|
|
20
21
|
* Build the system prompt injected into the lead's session.
|
|
21
22
|
* Includes team name, member statuses, task counts, and anti-polling guidance.
|
|
22
23
|
*/
|
|
23
|
-
export function buildLeadSystemPrompt(db: Database, teamId: string): string {
|
|
24
|
+
export function buildLeadSystemPrompt(db: Database, teamId: string, config?: Required<EnsembleConfig>): string {
|
|
24
25
|
const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId) as { name: string } | null
|
|
25
26
|
if (!team) return ""
|
|
26
27
|
|
|
@@ -98,6 +99,38 @@ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
|
|
|
98
99
|
lines.push("--- End Messages ---")
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
// Model selection guidance (only when promptForModels is enabled)
|
|
103
|
+
if (config?.promptForModels) {
|
|
104
|
+
const poolOptions = (config.modelPool && config.modelPool.length > 0)
|
|
105
|
+
? config.modelPool.map(m => ` { label: "${m}", description: "" }`).join(",\n")
|
|
106
|
+
: ""
|
|
107
|
+
lines.push(
|
|
108
|
+
"",
|
|
109
|
+
"MODEL SELECTION:",
|
|
110
|
+
"Before spawning teammates, use the question tool to ask the user about model preferences.",
|
|
111
|
+
"Do NOT spawn any agents until the user confirms their model preference.",
|
|
112
|
+
"Keep descriptions simple and clear — explain what each option means in plain language.",
|
|
113
|
+
"Example question tool call:",
|
|
114
|
+
' question({ questions: [{ question: "Which AI models should your team agents use?", header: "Agent models", options: [',
|
|
115
|
+
' { label: "Same as me (Recommended)", description: "Every agent uses the same model I\'m running on. Simplest option — no extra setup needed." },',
|
|
116
|
+
' { label: "Mix of models", description: "Each agent gets a different model from your configured pool. Useful for getting diverse perspectives on the same problem." },',
|
|
117
|
+
' { label: "I\'ll choose per agent", description: "You pick the exact model for each agent as I spawn them. Most control, but requires a choice per agent." }',
|
|
118
|
+
" ]}]})",
|
|
119
|
+
)
|
|
120
|
+
if (poolOptions) {
|
|
121
|
+
lines.push(
|
|
122
|
+
'If user picks "Mix of models", ask which models with multiple: true:',
|
|
123
|
+
" question({ questions: [{ question: \"Which models should agents rotate through? Pick all that apply.\", header: \"Model pool\", multiple: true, options: [",
|
|
124
|
+
poolOptions,
|
|
125
|
+
" ]}]})",
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
lines.push(
|
|
129
|
+
'If user picks "I\'ll choose per agent", ask for each agent\'s model individually before each team_spawn call.',
|
|
130
|
+
"Pass the chosen model via the model parameter on each team_spawn call.",
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
101
134
|
lines.push(
|
|
102
135
|
"",
|
|
103
136
|
"CRITICAL: Spawn teammates ONE AT A TIME. Send only ONE team_spawn call per response.",
|
|
@@ -9,6 +9,9 @@ export interface MergeResult {
|
|
|
9
9
|
/** Injectable function for testing. */
|
|
10
10
|
export type MergeBranchFn = (branch: string, cwd: string) => Promise<MergeResult>
|
|
11
11
|
|
|
12
|
+
/** Injectable function for overlap detection before merge. */
|
|
13
|
+
export type OverlapCheckFn = (branch: string, cwd: string) => Promise<string[]>
|
|
14
|
+
|
|
12
15
|
/** Injectable function for preserving a branch before worktree deletion. */
|
|
13
16
|
export type PreserveBranchFn = (sourceBranch: string, targetBranch: string, cwd: string) => Promise<boolean>
|
|
14
17
|
|
|
@@ -118,6 +121,25 @@ export async function mergeBranch(branch: string, cwd: string): Promise<MergeRes
|
|
|
118
121
|
return { ok: true }
|
|
119
122
|
}
|
|
120
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Detect files that both the lead (local changes) and the agent (branch) modified.
|
|
126
|
+
* Returns the list of overlapping file paths, or empty if safe to merge.
|
|
127
|
+
*/
|
|
128
|
+
export async function getOverlappingFiles(branch: string, cwd: string): Promise<string[]> {
|
|
129
|
+
const run = async (args: string[]) => {
|
|
130
|
+
const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" })
|
|
131
|
+
const out = await new Response(proc.stdout).text()
|
|
132
|
+
const exit = await proc.exited
|
|
133
|
+
if (exit !== 0) throw new Error(`git ${args.join(" ")} failed with exit code ${exit}`)
|
|
134
|
+
return out.split("\n").filter(Boolean)
|
|
135
|
+
}
|
|
136
|
+
const agentFiles = new Set(await run(["diff", "--name-only", "HEAD", branch]))
|
|
137
|
+
const localChanged = await run(["diff", "--name-only", "HEAD"])
|
|
138
|
+
const localUntracked = await run(["ls-files", "--others", "--exclude-standard"])
|
|
139
|
+
const localFiles = [...new Set([...localChanged, ...localUntracked])]
|
|
140
|
+
return localFiles.filter(f => agentFiles.has(f))
|
|
141
|
+
}
|
|
142
|
+
|
|
121
143
|
/**
|
|
122
144
|
* Build the preserved branch name for a team member.
|
|
123
145
|
*/
|
|
@@ -2,8 +2,8 @@ import type { ToolDeps } from "../types"
|
|
|
2
2
|
import { requireLead, checkWorktreeDirty } from "./shared"
|
|
3
3
|
import type { IsDirtyFn } from "./shared"
|
|
4
4
|
import { spawnFailures } from "./team-spawn"
|
|
5
|
-
import { mergeBranch, deleteBranch, preserveBranch, preservedBranchName } from "./merge-helper"
|
|
6
|
-
import type { MergeBranchFn, DeleteBranchFn, PreserveBranchFn } from "./merge-helper"
|
|
5
|
+
import { mergeBranch, deleteBranch, preserveBranch, preservedBranchName, getOverlappingFiles } from "./merge-helper"
|
|
6
|
+
import type { MergeBranchFn, DeleteBranchFn, PreserveBranchFn, OverlapCheckFn } from "./merge-helper"
|
|
7
7
|
import { log } from "../log"
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -19,6 +19,7 @@ export async function executeTeamCleanup(
|
|
|
19
19
|
merge: MergeBranchFn = mergeBranch,
|
|
20
20
|
delBranch: DeleteBranchFn = deleteBranch,
|
|
21
21
|
mergeOnCleanup = true,
|
|
22
|
+
overlapCheck: OverlapCheckFn = getOverlappingFiles,
|
|
22
23
|
): Promise<string> {
|
|
23
24
|
const teamInfo = requireLead(deps, sessionId)
|
|
24
25
|
|
|
@@ -75,10 +76,18 @@ export async function executeTeamCleanup(
|
|
|
75
76
|
const unmerged = members.filter(m => m.worktree_branch !== null)
|
|
76
77
|
const merged: string[] = []
|
|
77
78
|
const conflicted: string[] = []
|
|
79
|
+
const overlapWarnings: string[] = []
|
|
78
80
|
|
|
79
81
|
if (unmerged.length > 0 && mergeOnCleanup) {
|
|
80
82
|
for (const member of unmerged) {
|
|
81
83
|
const branch = member.worktree_branch!
|
|
84
|
+
// Warn (but don't block) if lead has local changes to overlapping files
|
|
85
|
+
try {
|
|
86
|
+
const overlap = await overlapCheck(branch, deps.directory)
|
|
87
|
+
if (overlap.length > 0) {
|
|
88
|
+
overlapWarnings.push(`${member.name}: ${overlap.join(", ")}`)
|
|
89
|
+
}
|
|
90
|
+
} catch { /* best effort */ }
|
|
82
91
|
const result = await merge(branch, deps.directory)
|
|
83
92
|
if (result.ok) {
|
|
84
93
|
await delBranch(branch, deps.directory)
|
|
@@ -122,5 +131,8 @@ export async function executeTeamCleanup(
|
|
|
122
131
|
if (conflicted.length > 0) {
|
|
123
132
|
parts.push(`Could not auto-merge: ${conflicted.join(", ")}. Merge manually.`)
|
|
124
133
|
}
|
|
134
|
+
if (overlapWarnings.length > 0) {
|
|
135
|
+
parts.push(`Warning: safety-net merge overwrote local changes to overlapping files:\n${overlapWarnings.map(w => ` - ${w}`).join("\n")}\nCheck git stash list or git diff to recover.`)
|
|
136
|
+
}
|
|
125
137
|
return parts.join("\n")
|
|
126
138
|
}
|
package/src/tools/team-merge.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
2
|
import { requireLead } from "./shared"
|
|
3
|
-
import { mergeBranch, deleteBranch } from "./merge-helper"
|
|
4
|
-
import type { MergeBranchFn, DeleteBranchFn } from "./merge-helper"
|
|
3
|
+
import { mergeBranch, deleteBranch, getOverlappingFiles } from "./merge-helper"
|
|
4
|
+
import type { MergeBranchFn, DeleteBranchFn, OverlapCheckFn } from "./merge-helper"
|
|
5
5
|
import { log } from "../log"
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -14,6 +14,7 @@ export async function executeTeamMerge(
|
|
|
14
14
|
sessionId: string,
|
|
15
15
|
merge: MergeBranchFn = mergeBranch,
|
|
16
16
|
delBranch: DeleteBranchFn = deleteBranch,
|
|
17
|
+
overlapCheck: OverlapCheckFn = getOverlappingFiles,
|
|
17
18
|
): Promise<string> {
|
|
18
19
|
const teamInfo = requireLead(deps, sessionId)
|
|
19
20
|
|
|
@@ -32,6 +33,17 @@ export async function executeTeamMerge(
|
|
|
32
33
|
const branch = member.worktree_branch
|
|
33
34
|
log(`merge:start member=${args.member} branch=${branch}`)
|
|
34
35
|
|
|
36
|
+
// Block merge if lead has local changes to files the agent also modified
|
|
37
|
+
try {
|
|
38
|
+
const overlap = await overlapCheck(branch, deps.directory)
|
|
39
|
+
if (overlap.length > 0) {
|
|
40
|
+
const files = overlap.map(f => ` - ${f}`).join("\n")
|
|
41
|
+
return `Cannot merge ${args.member}'s branch — you have local changes to files that ${args.member} also modified:\n${files}\n\nCommit or stash your changes to these files first, then retry team_merge.\nBranch preserved: ${branch}`
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
log(`merge:overlap-check:failed member=${args.member} branch=${branch}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
35
47
|
const result = await merge(branch, deps.directory)
|
|
36
48
|
if (!result.ok) {
|
|
37
49
|
return `Merge conflict with ${args.member}'s branch (${branch}). Resolve manually:\n git merge --squash ${branch}\n\nError: ${result.error}`
|
|
@@ -45,5 +57,6 @@ export async function executeTeamMerge(
|
|
|
45
57
|
)
|
|
46
58
|
|
|
47
59
|
log(`merge:done member=${args.member} branch=${branch}`)
|
|
48
|
-
|
|
60
|
+
const msg = `Merged ${args.member}'s changes into working directory (unstaged). Review with: git diff`
|
|
61
|
+
return result.error ? `${msg}\n\nWarning: ${result.error}` : msg
|
|
49
62
|
}
|
package/src/tools/team-spawn.ts
CHANGED
|
@@ -3,10 +3,40 @@ import { validateMemberName } from "../util"
|
|
|
3
3
|
import { requireLead } from "./shared"
|
|
4
4
|
import { sendMessage } from "../messaging"
|
|
5
5
|
import { log } from "../log"
|
|
6
|
+
import type { EnsembleConfig } from "../config"
|
|
6
7
|
|
|
7
8
|
/** Tracks consecutive spawn failures per team for circuit breaker. */
|
|
8
9
|
export const spawnFailures = new Map<string, { count: number; lastError: string }>()
|
|
9
10
|
|
|
11
|
+
/** Parse "provider/model" string into { providerID, modelID } for the SDK. */
|
|
12
|
+
function parseModelId(model: string): { providerID: string; modelID: string } | undefined {
|
|
13
|
+
const slash = model.indexOf("/")
|
|
14
|
+
if (slash <= 0 || slash === model.length - 1) return undefined
|
|
15
|
+
return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve which model to use for a spawned agent.
|
|
20
|
+
* Priority: explicit arg > modelsByAgent > rotation/random > defaultModel > undefined.
|
|
21
|
+
*/
|
|
22
|
+
export function resolveModel(
|
|
23
|
+
explicitModel: string | undefined,
|
|
24
|
+
agentType: string,
|
|
25
|
+
teamMemberCount: number,
|
|
26
|
+
config: Required<EnsembleConfig>,
|
|
27
|
+
): string | undefined {
|
|
28
|
+
if (explicitModel) return explicitModel
|
|
29
|
+
if (config.modelsByAgent[agentType]) return config.modelsByAgent[agentType]
|
|
30
|
+
if (config.modelAssignment === "rotate" && config.modelPool.length > 0) {
|
|
31
|
+
return config.modelPool[teamMemberCount % config.modelPool.length]
|
|
32
|
+
}
|
|
33
|
+
if (config.modelAssignment === "random" && config.modelPool.length > 0) {
|
|
34
|
+
return config.modelPool[Math.floor(Math.random() * config.modelPool.length)]
|
|
35
|
+
}
|
|
36
|
+
if (config.defaultModel) return config.defaultModel
|
|
37
|
+
return undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
10
40
|
/** Timeout for worktree.create and session.create to prevent hanging on git lock contention. */
|
|
11
41
|
function getSpawnTimeout(): number {
|
|
12
42
|
return Number(process.env.SPAWN_TIMEOUT_MS) || 120_000
|
|
@@ -182,10 +212,15 @@ export async function executeTeamSpawn(
|
|
|
182
212
|
// Register in DB
|
|
183
213
|
const planApproval = usePlanApproval ? "pending" : "none"
|
|
184
214
|
const now = Date.now()
|
|
215
|
+
// Resolve model before DB insert so the stored value matches what promptAsync uses
|
|
216
|
+
const memberCount = (deps.db.query("SELECT COUNT(*) as c FROM team_member WHERE team_id = ?").get(teamInfo.teamId) as { c: number }).c
|
|
217
|
+
const resolvedModel = resolveModel(args.model, args.agent, memberCount, deps.config)
|
|
218
|
+
if (resolvedModel) log(`spawn:model name=${args.name} model=${resolvedModel}`)
|
|
219
|
+
|
|
185
220
|
deps.db.run(
|
|
186
221
|
`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, workspace_id, plan_approval, time_created, time_updated)
|
|
187
222
|
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
188
|
-
[teamInfo.teamId, args.name, childSessionId, args.agent,
|
|
223
|
+
[teamInfo.teamId, args.name, childSessionId, args.agent, resolvedModel ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]
|
|
189
224
|
)
|
|
190
225
|
|
|
191
226
|
// Register in memory
|
|
@@ -316,16 +351,23 @@ export async function executeTeamSpawn(
|
|
|
316
351
|
|
|
317
352
|
const contextStr = context.join("\n")
|
|
318
353
|
|
|
354
|
+
// Model was already resolved before DB insert — just parse for promptAsync
|
|
355
|
+
const modelParam = resolvedModel ? parseModelId(resolvedModel) : undefined
|
|
356
|
+
if (resolvedModel && !modelParam) {
|
|
357
|
+
log(`spawn:model:invalid name=${args.name} model=${resolvedModel} — expected "provider/model" format, falling back to default`)
|
|
358
|
+
}
|
|
359
|
+
|
|
319
360
|
// Fire-and-forget: send prompt to teammate session.
|
|
320
361
|
log(`spawn:promptAsync:fire name=${args.name} sessionId=${childSessionId}`)
|
|
321
362
|
deps.client.session.promptAsync({
|
|
322
363
|
sessionID: childSessionId,
|
|
323
364
|
parts: [{ type: "text", text: contextStr }],
|
|
324
365
|
agent: args.agent,
|
|
325
|
-
|
|
326
|
-
|
|
366
|
+
...(modelParam ? { model: modelParam } : {}),
|
|
367
|
+
}).catch((err) => {
|
|
368
|
+
const errMsg = err instanceof Error ? err.message : String(err)
|
|
369
|
+
log(`spawn:promptAsync:failed name=${args.name} err=${errMsg} — rolling back`)
|
|
327
370
|
try {
|
|
328
|
-
// Async rollback: clean up DB (by session_id to avoid deleting a re-spawned member with the same name), registry, session, and worktree
|
|
329
371
|
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId])
|
|
330
372
|
deps.registry.unregister(childSessionId)
|
|
331
373
|
deps.client.session.abort({ sessionID: childSessionId }).catch(() => { /* best effort */ })
|
|
@@ -335,20 +377,18 @@ export async function executeTeamSpawn(
|
|
|
335
377
|
if (worktreeDir) {
|
|
336
378
|
deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => { /* best effort */ })
|
|
337
379
|
}
|
|
338
|
-
|
|
380
|
+
const modelInfo = resolvedModel ? ` (model: ${resolvedModel})` : ""
|
|
339
381
|
deps.client.tui.showToast({
|
|
340
382
|
title: "Team",
|
|
341
|
-
message: `Teammate "${args.name}" failed to start
|
|
383
|
+
message: `Teammate "${args.name}" failed to start${modelInfo}: ${errMsg}`,
|
|
342
384
|
variant: "error",
|
|
343
|
-
duration:
|
|
385
|
+
duration: 8000,
|
|
344
386
|
}).catch(() => { /* TUI may not be available */ })
|
|
345
|
-
// Notify the lead model so it can react (retry, adjust plan, etc.)
|
|
346
|
-
// Message delivered via system prompt transform on the lead's next turn.
|
|
347
387
|
sendMessage(deps.db, {
|
|
348
388
|
teamId: teamInfo.teamId,
|
|
349
389
|
from: "system",
|
|
350
390
|
to: "lead",
|
|
351
|
-
content: `Teammate "${args.name}" failed to start and was removed. You may retry the spawn.`,
|
|
391
|
+
content: `Teammate "${args.name}" failed to start and was removed${modelInfo}. Error: ${errMsg}. You may retry the spawn.`,
|
|
352
392
|
})
|
|
353
393
|
} catch { /* rollback failed — watchdog will clean up stale member */ }
|
|
354
394
|
})
|
package/src/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite"
|
|
2
2
|
import type { MemberRegistry, DescendantTracker } from "./state"
|
|
3
|
+
import type { EnsembleConfig } from "./config"
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Shared dependencies injected into every tool's execute function.
|
|
@@ -13,6 +14,8 @@ export interface ToolDeps {
|
|
|
13
14
|
client: PluginClient
|
|
14
15
|
/** The project root directory — used for reading AGENTS.md and other project files. */
|
|
15
16
|
directory: string
|
|
17
|
+
/** Plugin configuration. */
|
|
18
|
+
config: Required<EnsembleConfig>
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
/** A single permission rule for session-level enforcement. */
|