@hueyexe/opencode-ensemble 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@hueyexe/opencode-ensemble.svg)](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/@hueyexe/opencode-ensemble.svg)](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
5
- [![tests](https://img.shields.io/badge/tests-471%20passing-brightgreen.svg)]()
5
+ [![tests](https://img.shields.io/badge/tests-477%20passing-brightgreen.svg)]()
6
6
  [![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue.svg)]()
7
7
  [![OpenCode SDK](https://img.shields.io/badge/deps-OpenCode%20SDK%20only-blue.svg)]()
8
8
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./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.0"]
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.11.0"]
139
+ "plugin": ["@hueyexe/opencode-ensemble@0.12.0"]
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.11.0"]
147
+ "plugin": ["@hueyexe/opencode-ensemble@0.12.0"]
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
- For working on the plugin itself, create `.opencode/plugins/ensemble.ts` in your project:
183
+ To test a local build, point your plugin config at the built output:
174
184
 
175
- ```ts
176
- export { default } from "@hueyexe/opencode-ensemble"
185
+ ```json
186
+ {
187
+ "plugin": ["/path/to/opencode-ensemble/dist/index.js"]
188
+ }
177
189
  ```
178
190
 
179
- Or point directly at the source checkout:
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
 
@@ -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. No custom team panel (that requires core TUI changes, which are [in progress upstream](https://github.com/sst/opencode)).
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
 
@@ -248,6 +256,56 @@ Teammate messages arrive in the lead's session as `[Team message from alice]: ..
248
256
  - **Graceful shutdown**: busy teammates receive a shutdown message and finish their current work. Use `force: true` to abort immediately.
249
257
  - **Rate limiting**: token bucket (configurable via config file or `OPENCODE_ENSEMBLE_RATE_LIMIT`, default 10 tokens/sec)
250
258
 
259
+ ## Model Selection
260
+
261
+ 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.
262
+
263
+ **All agents use the same model:**
264
+ ```json
265
+ {
266
+ "defaultModel": "anthropic/claude-sonnet-4-6"
267
+ }
268
+ ```
269
+
270
+ **Different models for different agent types:**
271
+ ```json
272
+ {
273
+ "modelsByAgent": {
274
+ "build": "anthropic/claude-opus-4-6",
275
+ "explore": "opencode/gpt-5-nano"
276
+ }
277
+ }
278
+ ```
279
+
280
+ **Rotate through a pool for diverse perspectives:**
281
+ ```json
282
+ {
283
+ "modelPool": ["anthropic/claude-opus-4-6", "anthropic/claude-sonnet-4-6", "openai/gpt-5.4"],
284
+ "modelAssignment": "rotate"
285
+ }
286
+ ```
287
+
288
+ **Ask the user before spawning:**
289
+ ```json
290
+ {
291
+ "promptForModels": true,
292
+ "modelPool": ["anthropic/claude-opus-4-6", "opencode/big-pickle", "opencode/gpt-5-nano"]
293
+ }
294
+ ```
295
+
296
+ 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.
297
+
298
+ **Resolution order** — when an agent is spawned, the model is determined by:
299
+ 1. Explicit `model` param on `team_spawn` (lead or user chose it)
300
+ 2. `modelsByAgent` mapping for this agent type
301
+ 3. `modelAssignment` strategy (`rotate` or `random` from `modelPool`)
302
+ 4. `defaultModel`
303
+ 5. OpenCode's default model
304
+
305
+ The lead can always override by passing `model` directly on `team_spawn`, regardless of config.
306
+
307
+ 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`).
308
+
251
309
  ## Configuration
252
310
 
253
311
  Configure via JSON files, environment variables, or both. Project config overrides global config. Env vars override everything.
@@ -259,12 +317,17 @@ Configure via JSON files, environment variables, or both. Project config overrid
259
317
  ```json
260
318
  {
261
319
  "mergeOnCleanup": true,
262
- "stallThresholdMs": 180000,
263
- "stallMinSteps": 3,
264
- "stallTokenThreshold": 500,
320
+ "stallThresholdMs": 300000,
321
+ "stallMinSteps": 5,
322
+ "stallTokenThreshold": 200,
265
323
  "timeoutMs": 1800000,
266
324
  "rateLimitCapacity": 10,
267
- "dashboardPort": 4747
325
+ "dashboardPort": 4747,
326
+ "defaultModel": "anthropic/claude-sonnet-4-6",
327
+ "modelPool": ["anthropic/claude-opus-4-6", "anthropic/claude-sonnet-4-6", "openai/gpt-5.4"],
328
+ "modelsByAgent": {},
329
+ "modelAssignment": "default",
330
+ "promptForModels": false
268
331
  }
269
332
  ```
270
333
 
@@ -275,12 +338,17 @@ All fields are optional. Missing fields use defaults.
275
338
  | Key | Default | Description |
276
339
  |-----|---------|-------------|
277
340
  | `mergeOnCleanup` | `true` | Auto-merge worktree branches on cleanup (squash + unstage) |
278
- | `stallThresholdMs` | `180000` (3 min) | Time without communication before stall escalation. `0` disables. |
279
- | `stallMinSteps` | `3` | Min model steps before token-based stall check kicks in |
280
- | `stallTokenThreshold` | `500` | Output tokens per step below which the agent is considered stalled |
341
+ | `stallThresholdMs` | `300000` (5 min) | Time without communication before stall escalation. `0` disables. |
342
+ | `stallMinSteps` | `5` | Min model steps before token-based stall check kicks in |
343
+ | `stallTokenThreshold` | `200` | Output tokens per step below which the agent is considered stalled |
281
344
  | `timeoutMs` | `1800000` (30 min) | Hard timeout for busy teammates. `0` disables. |
282
345
  | `rateLimitCapacity` | `10` | Token bucket capacity for team tool calls. `0` disables. |
283
346
  | `dashboardPort` | `4747` | Dashboard server port. `0` disables. |
347
+ | `defaultModel` | `""` | Default model for all agents (e.g. `"anthropic/claude-sonnet-4-6"`). Empty = OpenCode's default. |
348
+ | `modelPool` | `[]` | List of models for rotation/random assignment. |
349
+ | `modelsByAgent` | `{}` | Map agent type to model (e.g. `{"build": "anthropic/claude-opus-4-6"}`). |
350
+ | `modelAssignment` | `"default"` | How to assign models: `"default"`, `"rotate"`, or `"random"`. |
351
+ | `promptForModels` | `false` | Lead asks user about model preferences before spawning. |
284
352
 
285
353
  ### Environment variables
286
354
 
@@ -299,7 +367,7 @@ OPENCODE_ENSEMBLE_RATE_LIMIT=20
299
367
  # Disable rate limiting
300
368
  OPENCODE_ENSEMBLE_RATE_LIMIT=0
301
369
 
302
- # Adjust stall detection threshold (default: 180000ms = 3 minutes)
370
+ # Adjust stall detection threshold (default: 300000ms = 5 minutes)
303
371
  STALL_THRESHOLD_MS=300000
304
372
 
305
373
  # Disable stall detection
@@ -337,7 +405,7 @@ Same coordination model (shared tasks, peer messaging, lead coordination) with s
337
405
  ```bash
338
406
  bun install
339
407
  bun run typecheck
340
- bun test # 471 tests
408
+ bun test # 477 tests
341
409
  bun run build
342
410
  ```
343
411
 
package/dist/index.js CHANGED
@@ -15927,7 +15927,7 @@ var STATUS_DISPLAY = {
15927
15927
  shutdown: "shut down",
15928
15928
  error: "error"
15929
15929
  };
15930
- function buildLeadSystemPrompt(db, teamId) {
15930
+ function buildLeadSystemPrompt(db, teamId, config2) {
15931
15931
  const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId);
15932
15932
  if (!team)
15933
15933
  return "";
@@ -15979,6 +15979,15 @@ function buildLeadSystemPrompt(db, teamId) {
15979
15979
  }
15980
15980
  lines.push("--- End Messages ---");
15981
15981
  }
15982
+ if (config2?.promptForModels) {
15983
+ const poolOptions = config2.modelPool && config2.modelPool.length > 0 ? config2.modelPool.map((m) => ` { label: "${m}", description: "" }`).join(`,
15984
+ `) : "";
15985
+ 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." }`, " ]}]})");
15986
+ if (poolOptions) {
15987
+ 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, " ]}]})");
15988
+ }
15989
+ 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.");
15990
+ }
15982
15991
  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
15992
  return lines.join(`
15984
15993
  `);
@@ -16086,14 +16095,19 @@ import { readFileSync } from "fs";
16086
16095
  import path2 from "path";
16087
16096
  var DEFAULT_CONFIG = {
16088
16097
  mergeOnCleanup: true,
16089
- stallThresholdMs: 180000,
16090
- stallMinSteps: 3,
16091
- stallTokenThreshold: 500,
16098
+ stallThresholdMs: 300000,
16099
+ stallMinSteps: 5,
16100
+ stallTokenThreshold: 200,
16092
16101
  timeoutMs: 30 * 60 * 1000,
16093
16102
  rateLimitCapacity: 10,
16094
16103
  dashboardPort: 4747,
16095
16104
  peerMessageLimit: 5,
16096
- peerMessageWindowMs: 300000
16105
+ peerMessageWindowMs: 300000,
16106
+ defaultModel: "",
16107
+ modelPool: [],
16108
+ modelsByAgent: {},
16109
+ modelAssignment: "default",
16110
+ promptForModels: false
16097
16111
  };
16098
16112
  function readConfigFile(filePath) {
16099
16113
  try {
@@ -16118,6 +16132,19 @@ function readConfigFile(filePath) {
16118
16132
  result.peerMessageLimit = raw.peerMessageLimit;
16119
16133
  if (typeof raw.peerMessageWindowMs === "number")
16120
16134
  result.peerMessageWindowMs = raw.peerMessageWindowMs;
16135
+ if (typeof raw.defaultModel === "string")
16136
+ result.defaultModel = raw.defaultModel;
16137
+ if (Array.isArray(raw.modelPool) && raw.modelPool.every((m) => typeof m === "string"))
16138
+ result.modelPool = raw.modelPool;
16139
+ if (typeof raw.modelsByAgent === "object" && raw.modelsByAgent !== null && !Array.isArray(raw.modelsByAgent)) {
16140
+ const valid = Object.entries(raw.modelsByAgent).every(([, v]) => typeof v === "string");
16141
+ if (valid)
16142
+ result.modelsByAgent = raw.modelsByAgent;
16143
+ }
16144
+ if (typeof raw.modelAssignment === "string" && ["default", "rotate", "random"].includes(raw.modelAssignment))
16145
+ result.modelAssignment = raw.modelAssignment;
16146
+ if (typeof raw.promptForModels === "boolean")
16147
+ result.promptForModels = raw.promptForModels;
16121
16148
  return result;
16122
16149
  } catch (err) {
16123
16150
  if (err && typeof err === "object" && "code" in err && err.code === "ENOENT")
@@ -16489,6 +16516,7 @@ function rAgents(t){
16489
16516
  '<span class="text-[10px] px-1.5 py-[1px] rounded '+s.t+' bg-base-800/80 shrink-0">'+s.l+'</span>'+
16490
16517
  spark+
16491
16518
  '<span class="text-[10px] text-txt-500 ml-auto shrink-0">'+E(m.agent)+'</span>'+
16519
+ (m.model?chip(E(m.model),'muted'):'')+
16492
16520
  '</div>'+
16493
16521
  (tr?'<div class="mt-2 text-[13px] text-txt-200 leading-snug truncate">'+E(tr)+'</div>':'')+
16494
16522
  '<div class="mt-2 flex items-center gap-1.5 flex-wrap">'+
@@ -16861,6 +16889,27 @@ function requireTeamMember(deps, sessionId) {
16861
16889
 
16862
16890
  // src/tools/team-spawn.ts
16863
16891
  var spawnFailures = new Map;
16892
+ function parseModelId(model) {
16893
+ const slash = model.indexOf("/");
16894
+ if (slash <= 0 || slash === model.length - 1)
16895
+ return;
16896
+ return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) };
16897
+ }
16898
+ function resolveModel(explicitModel, agentType, teamMemberCount, config2) {
16899
+ if (explicitModel)
16900
+ return explicitModel;
16901
+ if (config2.modelsByAgent[agentType])
16902
+ return config2.modelsByAgent[agentType];
16903
+ if (config2.modelAssignment === "rotate" && config2.modelPool.length > 0) {
16904
+ return config2.modelPool[teamMemberCount % config2.modelPool.length];
16905
+ }
16906
+ if (config2.modelAssignment === "random" && config2.modelPool.length > 0) {
16907
+ return config2.modelPool[Math.floor(Math.random() * config2.modelPool.length)];
16908
+ }
16909
+ if (config2.defaultModel)
16910
+ return config2.defaultModel;
16911
+ return;
16912
+ }
16864
16913
  function getSpawnTimeout() {
16865
16914
  return Number(process.env.SPAWN_TIMEOUT_MS) || 120000;
16866
16915
  }
@@ -16984,8 +17033,12 @@ async function executeTeamSpawn(deps, args, sessionId) {
16984
17033
  }
16985
17034
  const planApproval = usePlanApproval ? "pending" : "none";
16986
17035
  const now = Date.now();
17036
+ const memberCount = deps.db.query("SELECT COUNT(*) as c FROM team_member WHERE team_id = ?").get(teamInfo.teamId).c;
17037
+ const resolvedModel = resolveModel(args.model, args.agent, memberCount, deps.config);
17038
+ if (resolvedModel)
17039
+ log(`spawn:model name=${args.name} model=${resolvedModel}`);
16987
17040
  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, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]);
17041
+ VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, args.agent, resolvedModel ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]);
16989
17042
  deps.registry.register(teamInfo.teamId, args.name, childSessionId);
16990
17043
  const context = [
16991
17044
  `You are "${args.name}", a teammate in team "${teamInfo.teamName}".`,
@@ -17037,13 +17090,19 @@ async function executeTeamSpawn(deps, args, sessionId) {
17037
17090
  }
17038
17091
  const contextStr = context.join(`
17039
17092
  `);
17093
+ const modelParam = resolvedModel ? parseModelId(resolvedModel) : undefined;
17094
+ if (resolvedModel && !modelParam) {
17095
+ log(`spawn:model:invalid name=${args.name} model=${resolvedModel} \u2014 expected "provider/model" format, falling back to default`);
17096
+ }
17040
17097
  log(`spawn:promptAsync:fire name=${args.name} sessionId=${childSessionId}`);
17041
17098
  deps.client.session.promptAsync({
17042
17099
  sessionID: childSessionId,
17043
17100
  parts: [{ type: "text", text: contextStr }],
17044
- agent: args.agent
17045
- }).catch(() => {
17046
- log(`spawn:promptAsync:failed name=${args.name} \u2014 rolling back`);
17101
+ agent: args.agent,
17102
+ ...modelParam ? { model: modelParam } : {}
17103
+ }).catch((err) => {
17104
+ const errMsg = err instanceof Error ? err.message : String(err);
17105
+ log(`spawn:promptAsync:failed name=${args.name} err=${errMsg} \u2014 rolling back`);
17047
17106
  try {
17048
17107
  deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId]);
17049
17108
  deps.registry.unregister(childSessionId);
@@ -17054,17 +17113,18 @@ async function executeTeamSpawn(deps, args, sessionId) {
17054
17113
  if (worktreeDir) {
17055
17114
  deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => {});
17056
17115
  }
17116
+ const modelInfo = resolvedModel ? ` (model: ${resolvedModel})` : "";
17057
17117
  deps.client.tui.showToast({
17058
17118
  title: "Team",
17059
- message: `Teammate "${args.name}" failed to start and was removed`,
17119
+ message: `Teammate "${args.name}" failed to start${modelInfo}: ${errMsg}`,
17060
17120
  variant: "error",
17061
- duration: 5000
17121
+ duration: 8000
17062
17122
  }).catch(() => {});
17063
17123
  sendMessage(deps.db, {
17064
17124
  teamId: teamInfo.teamId,
17065
17125
  from: "system",
17066
17126
  to: "lead",
17067
- content: `Teammate "${args.name}" failed to start and was removed. You may retry the spawn.`
17127
+ content: `Teammate "${args.name}" failed to start and was removed${modelInfo}. Error: ${errMsg}. You may retry the spawn.`
17068
17128
  });
17069
17129
  } catch {}
17070
17130
  });
@@ -17821,7 +17881,7 @@ var plugin = async (input) => {
17821
17881
  const rawClient = new OpencodeClient({ client: pluginTransport });
17822
17882
  initLog(rawClient);
17823
17883
  const client3 = wrapThrowingClient(rawClient);
17824
- const deps = { db, registry: registry2, tracker, client: client3, directory: input.directory };
17884
+ const deps = { db, registry: registry2, tracker, client: client3, directory: input.directory, config: config2 };
17825
17885
  if (!isWorktreeInstance(input.directory)) {
17826
17886
  log("init:recovery:start (main instance)");
17827
17887
  const recovery = await recoverStaleMembers(db, client3, input.directory);
@@ -17881,6 +17941,31 @@ var plugin = async (input) => {
17881
17941
  notifyTeamEvent(client3, "shutdown", { memberName: transition.memberName });
17882
17942
  } else if (transition.to === "ready" && transition.from === "busy") {
17883
17943
  notifyTeamEvent(client3, "completed", { memberName: transition.memberName });
17944
+ const fastIdleKey = `fastidle:${transition.teamId}:${transition.memberName}`;
17945
+ if (!nudgedMembers.has(fastIdleKey)) {
17946
+ const memberInfo = db.query("SELECT time_created, model FROM team_member WHERE team_id = ? AND name = ?").get(transition.teamId, transition.memberName);
17947
+ if (memberInfo) {
17948
+ const spawnAge = Date.now() - memberInfo.time_created;
17949
+ const msgCount = db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND from_name = ?").get(transition.teamId, transition.memberName).c;
17950
+ if (spawnAge < 15000 && msgCount === 0) {
17951
+ nudgedMembers.add(fastIdleKey);
17952
+ const modelInfo = memberInfo.model ? ` (model: ${memberInfo.model})` : "";
17953
+ log(`fast-idle: ${transition.memberName} went idle ${Math.round(spawnAge / 1000)}s after spawn with 0 messages${modelInfo}`);
17954
+ sendMessage(db, {
17955
+ teamId: transition.teamId,
17956
+ from: "system",
17957
+ to: "lead",
17958
+ 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.`
17959
+ });
17960
+ client3.tui.showToast({
17961
+ title: "Team",
17962
+ message: `${transition.memberName} failed to produce output${modelInfo}`,
17963
+ variant: "warning",
17964
+ duration: 8000
17965
+ }).catch(() => {});
17966
+ }
17967
+ }
17968
+ }
17884
17969
  const nudgeKey = `${transition.teamId}:${transition.memberName}`;
17885
17970
  if (!nudgedMembers.has(nudgeKey) && shouldNudgeIdleMember(db, transition.teamId, transition.memberName)) {
17886
17971
  nudgedMembers.add(nudgeKey);
@@ -17983,7 +18068,7 @@ var plugin = async (input) => {
17983
18068
  if (!teamInfo)
17984
18069
  return;
17985
18070
  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");
18071
+ const prompt = teamInfo.role === "lead" ? buildLeadSystemPrompt(db, teamInfo.teamId, config2) : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown");
17987
18072
  log(`system-prompt:injected role=${teamInfo.role} len=${prompt.length}`);
17988
18073
  output.system.push(prompt);
17989
18074
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Agent teams for OpenCode — parallel agents with peer-to-peer communication, shared tasks, and coordinated execution",
5
5
  "module": "src/index.ts",
6
6
  "main": "dist/index.js",
package/src/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: 180_000,
30
- stallMinSteps: 3,
31
- stallTokenThreshold: 500,
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)
@@ -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.",
@@ -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, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]
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
- }).catch(() => {
326
- log(`spawn:promptAsync:failed name=${args.name} — rolling back`)
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
- // Notify user that spawn failed so the lead doesn't think the teammate is active
380
+ const modelInfo = resolvedModel ? ` (model: ${resolvedModel})` : ""
339
381
  deps.client.tui.showToast({
340
382
  title: "Team",
341
- message: `Teammate "${args.name}" failed to start and was removed`,
383
+ message: `Teammate "${args.name}" failed to start${modelInfo}: ${errMsg}`,
342
384
  variant: "error",
343
- duration: 5000,
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. */