@hueyexe/opencode-ensemble 0.10.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 +90 -21
- package/dist/index.js +228 -19
- package/package.json +1 -1
- package/src/config.ts +34 -3
- package/src/dashboard-js-part2.ts +5 -3
- package/src/index.ts +63 -2
- package/src/progress.ts +38 -0
- package/src/system-prompt.ts +62 -3
- package/src/tools/team-message.ts +16 -0
- package/src/tools/team-spawn.ts +63 -10
- package/src/types.ts +3 -0
- package/src/watchdog.ts +44 -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.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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
|
@@ -242,11 +250,62 @@ Teammate messages arrive in the lead's session as `[Team message from alice]: ..
|
|
|
242
250
|
- **Spawn rollback**: if the initial prompt fails, the member, session, and worktree are all cleaned up
|
|
243
251
|
- **Timeout watchdog**: teammates stuck busy beyond the TTL are automatically timed out and aborted
|
|
244
252
|
- **Stall detection**: detects teammates making no progress (low output tokens or no communication) and escalates to the lead
|
|
253
|
+
- **Peer-to-peer communication**: teammates can message each other directly, with idle-flush delivery and chatty agent detection
|
|
245
254
|
- **Auto-merge on cleanup**: worktree branches are squash-merged into your working directory as unstaged changes for review
|
|
246
255
|
- **Spawn circuit breaker**: stops retrying after 3 consecutive spawn failures
|
|
247
256
|
- **Graceful shutdown**: busy teammates receive a shutdown message and finish their current work. Use `force: true` to abort immediately.
|
|
248
257
|
- **Rate limiting**: token bucket (configurable via config file or `OPENCODE_ENSEMBLE_RATE_LIMIT`, default 10 tokens/sec)
|
|
249
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
|
+
|
|
250
309
|
## Configuration
|
|
251
310
|
|
|
252
311
|
Configure via JSON files, environment variables, or both. Project config overrides global config. Env vars override everything.
|
|
@@ -258,12 +317,17 @@ Configure via JSON files, environment variables, or both. Project config overrid
|
|
|
258
317
|
```json
|
|
259
318
|
{
|
|
260
319
|
"mergeOnCleanup": true,
|
|
261
|
-
"stallThresholdMs":
|
|
262
|
-
"stallMinSteps":
|
|
263
|
-
"stallTokenThreshold":
|
|
320
|
+
"stallThresholdMs": 300000,
|
|
321
|
+
"stallMinSteps": 5,
|
|
322
|
+
"stallTokenThreshold": 200,
|
|
264
323
|
"timeoutMs": 1800000,
|
|
265
324
|
"rateLimitCapacity": 10,
|
|
266
|
-
"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
|
|
267
331
|
}
|
|
268
332
|
```
|
|
269
333
|
|
|
@@ -274,12 +338,17 @@ All fields are optional. Missing fields use defaults.
|
|
|
274
338
|
| Key | Default | Description |
|
|
275
339
|
|-----|---------|-------------|
|
|
276
340
|
| `mergeOnCleanup` | `true` | Auto-merge worktree branches on cleanup (squash + unstage) |
|
|
277
|
-
| `stallThresholdMs` | `
|
|
278
|
-
| `stallMinSteps` | `
|
|
279
|
-
| `stallTokenThreshold` | `
|
|
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 |
|
|
280
344
|
| `timeoutMs` | `1800000` (30 min) | Hard timeout for busy teammates. `0` disables. |
|
|
281
345
|
| `rateLimitCapacity` | `10` | Token bucket capacity for team tool calls. `0` disables. |
|
|
282
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. |
|
|
283
352
|
|
|
284
353
|
### Environment variables
|
|
285
354
|
|
|
@@ -298,7 +367,7 @@ OPENCODE_ENSEMBLE_RATE_LIMIT=20
|
|
|
298
367
|
# Disable rate limiting
|
|
299
368
|
OPENCODE_ENSEMBLE_RATE_LIMIT=0
|
|
300
369
|
|
|
301
|
-
# Adjust stall detection threshold (default:
|
|
370
|
+
# Adjust stall detection threshold (default: 300000ms = 5 minutes)
|
|
302
371
|
STALL_THRESHOLD_MS=300000
|
|
303
372
|
|
|
304
373
|
# Disable stall detection
|
|
@@ -336,7 +405,7 @@ Same coordination model (shared tasks, peer messaging, lead coordination) with s
|
|
|
336
405
|
```bash
|
|
337
406
|
bun install
|
|
338
407
|
bun run typecheck
|
|
339
|
-
bun test #
|
|
408
|
+
bun test # 477 tests
|
|
340
409
|
bun run build
|
|
341
410
|
```
|
|
342
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
|
`);
|
|
@@ -15987,7 +15996,28 @@ function buildTeammateSystemPrompt(db, teamId, memberName) {
|
|
|
15987
15996
|
const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId);
|
|
15988
15997
|
if (!team)
|
|
15989
15998
|
return "";
|
|
15990
|
-
|
|
15999
|
+
const lines = [
|
|
16000
|
+
`You are "${memberName}", a teammate in team "${team.name}". Use team_message to communicate. You MUST send your results to the lead via team_message before stopping.`
|
|
16001
|
+
];
|
|
16002
|
+
const pendingMessages = db.query("SELECT id, from_name, content FROM team_message WHERE team_id = ? AND to_name = ? AND delivered = 0 ORDER BY time_created ASC").all(teamId, memberName);
|
|
16003
|
+
if (pendingMessages.length > 0) {
|
|
16004
|
+
lines.push("", "--- Messages for you ---");
|
|
16005
|
+
const MAX_MSG = 500;
|
|
16006
|
+
for (const msg of pendingMessages) {
|
|
16007
|
+
const parsed = parseTaskResult(msg.content);
|
|
16008
|
+
if (parsed) {
|
|
16009
|
+
lines.push(formatTaskResult(msg.from_name, { ...parsed, details: truncate(parsed.details, MAX_MSG) }));
|
|
16010
|
+
} else if (msg.content.length > MAX_MSG) {
|
|
16011
|
+
lines.push(`[From ${msg.from_name}]: ${msg.content.slice(0, MAX_MSG)}... (truncated)`);
|
|
16012
|
+
} else {
|
|
16013
|
+
lines.push(`[From ${msg.from_name}]: ${msg.content}`);
|
|
16014
|
+
}
|
|
16015
|
+
markDelivered(db, msg.id);
|
|
16016
|
+
}
|
|
16017
|
+
lines.push("--- End Messages ---");
|
|
16018
|
+
}
|
|
16019
|
+
return lines.join(`
|
|
16020
|
+
`);
|
|
15991
16021
|
}
|
|
15992
16022
|
function buildTeamCompactionContext(db, teamId, role, memberName) {
|
|
15993
16023
|
const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId);
|
|
@@ -16065,12 +16095,19 @@ import { readFileSync } from "fs";
|
|
|
16065
16095
|
import path2 from "path";
|
|
16066
16096
|
var DEFAULT_CONFIG = {
|
|
16067
16097
|
mergeOnCleanup: true,
|
|
16068
|
-
stallThresholdMs:
|
|
16069
|
-
stallMinSteps:
|
|
16070
|
-
stallTokenThreshold:
|
|
16098
|
+
stallThresholdMs: 300000,
|
|
16099
|
+
stallMinSteps: 5,
|
|
16100
|
+
stallTokenThreshold: 200,
|
|
16071
16101
|
timeoutMs: 30 * 60 * 1000,
|
|
16072
16102
|
rateLimitCapacity: 10,
|
|
16073
|
-
dashboardPort: 4747
|
|
16103
|
+
dashboardPort: 4747,
|
|
16104
|
+
peerMessageLimit: 5,
|
|
16105
|
+
peerMessageWindowMs: 300000,
|
|
16106
|
+
defaultModel: "",
|
|
16107
|
+
modelPool: [],
|
|
16108
|
+
modelsByAgent: {},
|
|
16109
|
+
modelAssignment: "default",
|
|
16110
|
+
promptForModels: false
|
|
16074
16111
|
};
|
|
16075
16112
|
function readConfigFile(filePath) {
|
|
16076
16113
|
try {
|
|
@@ -16091,6 +16128,23 @@ function readConfigFile(filePath) {
|
|
|
16091
16128
|
result.rateLimitCapacity = raw.rateLimitCapacity;
|
|
16092
16129
|
if (typeof raw.dashboardPort === "number")
|
|
16093
16130
|
result.dashboardPort = raw.dashboardPort;
|
|
16131
|
+
if (typeof raw.peerMessageLimit === "number")
|
|
16132
|
+
result.peerMessageLimit = raw.peerMessageLimit;
|
|
16133
|
+
if (typeof raw.peerMessageWindowMs === "number")
|
|
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;
|
|
16094
16148
|
return result;
|
|
16095
16149
|
} catch (err) {
|
|
16096
16150
|
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT")
|
|
@@ -16123,7 +16177,9 @@ class ProgressTracker {
|
|
|
16123
16177
|
steps = new Map;
|
|
16124
16178
|
lastMessageAt = new Map;
|
|
16125
16179
|
lastTaskAt = new Map;
|
|
16180
|
+
peerMessages = new Map;
|
|
16126
16181
|
reported = new Set;
|
|
16182
|
+
chattyReported = new Set;
|
|
16127
16183
|
maxSteps;
|
|
16128
16184
|
constructor(maxSteps = 10) {
|
|
16129
16185
|
this.maxSteps = maxSteps;
|
|
@@ -16135,6 +16191,31 @@ class ProgressTracker {
|
|
|
16135
16191
|
records.shift();
|
|
16136
16192
|
this.steps.set(sessionId, records);
|
|
16137
16193
|
}
|
|
16194
|
+
recordPeerMessage(sessionId) {
|
|
16195
|
+
const timestamps = this.peerMessages.get(sessionId) ?? [];
|
|
16196
|
+
timestamps.push(Date.now());
|
|
16197
|
+
this.peerMessages.set(sessionId, timestamps);
|
|
16198
|
+
}
|
|
16199
|
+
isChatty(sessionId, limit, windowMs) {
|
|
16200
|
+
if (limit === 0)
|
|
16201
|
+
return false;
|
|
16202
|
+
const timestamps = this.peerMessages.get(sessionId);
|
|
16203
|
+
if (!timestamps)
|
|
16204
|
+
return false;
|
|
16205
|
+
const cutoff = Date.now() - windowMs;
|
|
16206
|
+
const recent = timestamps.filter((t) => t >= cutoff);
|
|
16207
|
+
this.peerMessages.set(sessionId, recent);
|
|
16208
|
+
return recent.length >= limit;
|
|
16209
|
+
}
|
|
16210
|
+
markChattyReported(sessionId) {
|
|
16211
|
+
this.chattyReported.add(sessionId);
|
|
16212
|
+
}
|
|
16213
|
+
isChattyReported(sessionId) {
|
|
16214
|
+
return this.chattyReported.has(sessionId);
|
|
16215
|
+
}
|
|
16216
|
+
clearChattyReport(sessionId) {
|
|
16217
|
+
this.chattyReported.delete(sessionId);
|
|
16218
|
+
}
|
|
16138
16219
|
recordMessage(sessionId) {
|
|
16139
16220
|
this.lastMessageAt.set(sessionId, Date.now());
|
|
16140
16221
|
this.clearReport(sessionId);
|
|
@@ -16173,7 +16254,9 @@ class ProgressTracker {
|
|
|
16173
16254
|
this.steps.delete(sessionId);
|
|
16174
16255
|
this.lastMessageAt.delete(sessionId);
|
|
16175
16256
|
this.lastTaskAt.delete(sessionId);
|
|
16257
|
+
this.peerMessages.delete(sessionId);
|
|
16176
16258
|
this.reported.delete(sessionId);
|
|
16259
|
+
this.chattyReported.delete(sessionId);
|
|
16177
16260
|
}
|
|
16178
16261
|
}
|
|
16179
16262
|
|
|
@@ -16433,6 +16516,7 @@ function rAgents(t){
|
|
|
16433
16516
|
'<span class="text-[10px] px-1.5 py-[1px] rounded '+s.t+' bg-base-800/80 shrink-0">'+s.l+'</span>'+
|
|
16434
16517
|
spark+
|
|
16435
16518
|
'<span class="text-[10px] text-txt-500 ml-auto shrink-0">'+E(m.agent)+'</span>'+
|
|
16519
|
+
(m.model?chip(E(m.model),'muted'):'')+
|
|
16436
16520
|
'</div>'+
|
|
16437
16521
|
(tr?'<div class="mt-2 text-[13px] text-txt-200 leading-snug truncate">'+E(tr)+'</div>':'')+
|
|
16438
16522
|
'<div class="mt-2 flex items-center gap-1.5 flex-wrap">'+
|
|
@@ -16558,16 +16642,17 @@ function rActivity(t){
|
|
|
16558
16642
|
const p=parseR(m.content);
|
|
16559
16643
|
const deliv=m.delivered?(m.read?'\\u2713\\u2713':'\\u2713'):'';
|
|
16560
16644
|
const isFromAgent=m.fromName!=='lead'&&m.fromName!=='system';
|
|
16645
|
+
const isPeer=isFromAgent&&m.toName&&m.toName!=='lead'&&m.toName!=='all';
|
|
16561
16646
|
const align=isFromAgent?'mr-6':'ml-6';
|
|
16562
|
-
const bubbleBg=isFromAgent?'bg-blue-500/[0.06] border-blue-500/15':'bg-base-800/40 border-base-700/30';
|
|
16647
|
+
const bubbleBg=isPeer?'bg-violet-500/[0.06] border-violet-500/15':isFromAgent?'bg-blue-500/[0.06] border-blue-500/15':'bg-base-800/40 border-base-700/30';
|
|
16563
16648
|
const initial=m.fromName.charAt(0).toUpperCase();
|
|
16564
|
-
const avatarColor=m.fromName==='system'?'bg-amber-500/20 text-amber-400':isFromAgent?'bg-blue-500/20 text-blue-400':'bg-emerald-500/20 text-emerald-400';
|
|
16649
|
+
const avatarColor=m.fromName==='system'?'bg-amber-500/20 text-amber-400':isPeer?'bg-violet-500/20 text-violet-400':isFromAgent?'bg-blue-500/20 text-blue-400':'bg-emerald-500/20 text-emerald-400';
|
|
16565
16650
|
html+='<div class="'+align+(isNew?' hl':'')+' cursor-pointer" data-msg="'+E(m.id)+'" onclick="toggleMsg(\\''+E(m.id)+'\\')">';
|
|
16566
16651
|
html+='<div class="flex items-start gap-2">';
|
|
16567
16652
|
html+='<span class="w-5 h-5 rounded-full '+avatarColor+' flex items-center justify-center text-[9px] font-semibold shrink-0 mt-0.5">'+E(initial)+'</span>';
|
|
16568
16653
|
html+='<div class="flex-1 min-w-0 rounded-xl border '+bubbleBg+' px-3 py-2">';
|
|
16569
16654
|
html+='<div class="flex items-center gap-1.5 mb-1">';
|
|
16570
|
-
html+='<span class="text-[11px] font-medium '+(isFromAgent?'text-blue-400':'text-txt-300')+'">'+E(m.fromName)+'</span>';
|
|
16655
|
+
html+='<span class="text-[11px] font-medium '+(isPeer?'text-violet-400':isFromAgent?'text-blue-400':'text-txt-300')+'">'+E(m.fromName)+'</span>';
|
|
16571
16656
|
if(m.toName)html+=chip('\\u2192 '+E(m.toName),'muted');
|
|
16572
16657
|
html+=chip(relT(m.timeCreated),'muted');
|
|
16573
16658
|
if(deliv)html+='<span class="text-txt-500 text-[10px] ml-auto">'+deliv+'</span>';
|
|
@@ -16804,6 +16889,27 @@ function requireTeamMember(deps, sessionId) {
|
|
|
16804
16889
|
|
|
16805
16890
|
// src/tools/team-spawn.ts
|
|
16806
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
|
+
}
|
|
16807
16913
|
function getSpawnTimeout() {
|
|
16808
16914
|
return Number(process.env.SPAWN_TIMEOUT_MS) || 120000;
|
|
16809
16915
|
}
|
|
@@ -16927,8 +17033,12 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
16927
17033
|
}
|
|
16928
17034
|
const planApproval = usePlanApproval ? "pending" : "none";
|
|
16929
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}`);
|
|
16930
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)
|
|
16931
|
-
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, args.agent,
|
|
17041
|
+
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, args.agent, resolvedModel ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]);
|
|
16932
17042
|
deps.registry.register(teamInfo.teamId, args.name, childSessionId);
|
|
16933
17043
|
const context = [
|
|
16934
17044
|
`You are "${args.name}", a teammate in team "${teamInfo.teamName}".`,
|
|
@@ -16953,6 +17063,9 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
16953
17063
|
} else {
|
|
16954
17064
|
context.push("", "Tools available to you:", "- team_message: send a message to the lead or another teammate", "- team_broadcast: send a message to all team members", "- team_tasks_list: view the shared team task board", "- team_tasks_add: add tasks to the shared board", "- team_tasks_complete: mark a task complete on the shared board", "- team_claim: claim a pending task from the shared board");
|
|
16955
17065
|
}
|
|
17066
|
+
if (otherMembers.length > 0) {
|
|
17067
|
+
context.push("", "Collaboration:", "- Check team_tasks_list to see what other teammates are working on.", "- If you need information another teammate has, message them directly via team_message.", "- If you discover something relevant to another teammate's task, share it with them.", "- Use team_broadcast for updates that affect the whole team.", "- Keep peer messages focused and actionable \u2014 coordinate, don't chat.");
|
|
17068
|
+
}
|
|
16956
17069
|
context.push("", "When you finish your task:");
|
|
16957
17070
|
if (!isReadOnly && worktreeBranch) {
|
|
16958
17071
|
context.push(`1. Commit your changes: git add -A && git commit -m "your summary"`);
|
|
@@ -16977,13 +17090,19 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
16977
17090
|
}
|
|
16978
17091
|
const contextStr = context.join(`
|
|
16979
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
|
+
}
|
|
16980
17097
|
log(`spawn:promptAsync:fire name=${args.name} sessionId=${childSessionId}`);
|
|
16981
17098
|
deps.client.session.promptAsync({
|
|
16982
17099
|
sessionID: childSessionId,
|
|
16983
17100
|
parts: [{ type: "text", text: contextStr }],
|
|
16984
|
-
agent: args.agent
|
|
16985
|
-
|
|
16986
|
-
|
|
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`);
|
|
16987
17106
|
try {
|
|
16988
17107
|
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId]);
|
|
16989
17108
|
deps.registry.unregister(childSessionId);
|
|
@@ -16994,17 +17113,18 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
16994
17113
|
if (worktreeDir) {
|
|
16995
17114
|
deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => {});
|
|
16996
17115
|
}
|
|
17116
|
+
const modelInfo = resolvedModel ? ` (model: ${resolvedModel})` : "";
|
|
16997
17117
|
deps.client.tui.showToast({
|
|
16998
17118
|
title: "Team",
|
|
16999
|
-
message: `Teammate "${args.name}" failed to start
|
|
17119
|
+
message: `Teammate "${args.name}" failed to start${modelInfo}: ${errMsg}`,
|
|
17000
17120
|
variant: "error",
|
|
17001
|
-
duration:
|
|
17121
|
+
duration: 8000
|
|
17002
17122
|
}).catch(() => {});
|
|
17003
17123
|
sendMessage(deps.db, {
|
|
17004
17124
|
teamId: teamInfo.teamId,
|
|
17005
17125
|
from: "system",
|
|
17006
17126
|
to: "lead",
|
|
17007
|
-
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.`
|
|
17008
17128
|
});
|
|
17009
17129
|
} catch {}
|
|
17010
17130
|
});
|
|
@@ -17020,6 +17140,19 @@ async function executeTeamMessage(deps, args, sessionId) {
|
|
|
17020
17140
|
const teamInfo = requireTeamMember(deps, sessionId);
|
|
17021
17141
|
const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
|
|
17022
17142
|
const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to);
|
|
17143
|
+
if (!recipientSessionId && args.to !== "lead") {
|
|
17144
|
+
if (args.approve || args.reject) {
|
|
17145
|
+
throw new Error(`Cannot approve/reject plan for "${args.to}" \u2014 they haven't been spawned yet.`);
|
|
17146
|
+
}
|
|
17147
|
+
sendMessage(deps.db, {
|
|
17148
|
+
teamId: teamInfo.teamId,
|
|
17149
|
+
from: senderName,
|
|
17150
|
+
to: args.to,
|
|
17151
|
+
content: args.text
|
|
17152
|
+
});
|
|
17153
|
+
log(`team_message:queued from=${senderName} to=${args.to} (recipient not yet spawned)`);
|
|
17154
|
+
return `Message queued for ${args.to} \u2014 they haven't been spawned yet. It will be delivered when they join the team.`;
|
|
17155
|
+
}
|
|
17023
17156
|
if (!recipientSessionId)
|
|
17024
17157
|
throw new Error(`Recipient "${args.to}" not found in team "${teamInfo.teamName}"`);
|
|
17025
17158
|
let messageText = args.text;
|
|
@@ -17580,6 +17713,8 @@ class Watchdog {
|
|
|
17580
17713
|
stallMinSteps;
|
|
17581
17714
|
stallTokenThreshold;
|
|
17582
17715
|
cwd;
|
|
17716
|
+
peerMessageLimit;
|
|
17717
|
+
peerMessageWindowMs;
|
|
17583
17718
|
timer;
|
|
17584
17719
|
constructor(opts) {
|
|
17585
17720
|
this.db = opts.db;
|
|
@@ -17592,6 +17727,8 @@ class Watchdog {
|
|
|
17592
17727
|
this.stallMinSteps = opts.stallMinSteps ?? 3;
|
|
17593
17728
|
this.stallTokenThreshold = opts.stallTokenThreshold ?? 500;
|
|
17594
17729
|
this.cwd = opts.cwd;
|
|
17730
|
+
this.peerMessageLimit = opts.peerMessageLimit ?? 0;
|
|
17731
|
+
this.peerMessageWindowMs = opts.peerMessageWindowMs ?? 300000;
|
|
17595
17732
|
}
|
|
17596
17733
|
static STALE_THRESHOLD_MS = Number(process.env.STALE_WORKTREE_THRESHOLD_MS) || 300000;
|
|
17597
17734
|
async cleanupStaleWorktrees() {
|
|
@@ -17649,9 +17786,36 @@ class Watchdog {
|
|
|
17649
17786
|
} catch {}
|
|
17650
17787
|
}
|
|
17651
17788
|
}
|
|
17789
|
+
async checkChatty() {
|
|
17790
|
+
if (!this.progressTracker || this.peerMessageLimit === 0)
|
|
17791
|
+
return;
|
|
17792
|
+
const busy = this.db.query(`SELECT tm.team_id, tm.name, tm.session_id
|
|
17793
|
+
FROM team_member tm
|
|
17794
|
+
JOIN team t ON tm.team_id = t.id
|
|
17795
|
+
WHERE t.status = 'active' AND tm.status = 'busy'`).all();
|
|
17796
|
+
for (const member of busy) {
|
|
17797
|
+
if (this.progressTracker.isChattyReported(member.session_id))
|
|
17798
|
+
continue;
|
|
17799
|
+
if (!this.progressTracker.isChatty(member.session_id, this.peerMessageLimit, this.peerMessageWindowMs))
|
|
17800
|
+
continue;
|
|
17801
|
+
this.progressTracker.markChattyReported(member.session_id);
|
|
17802
|
+
this.client.session.promptAsync({
|
|
17803
|
+
sessionID: member.session_id,
|
|
17804
|
+
parts: [{ type: "text", text: "[System]: You've sent several messages to teammates. Focus on completing your task and send your results to the lead via team_message." }]
|
|
17805
|
+
}).catch(() => {});
|
|
17806
|
+
sendMessage(this.db, {
|
|
17807
|
+
teamId: member.team_id,
|
|
17808
|
+
from: "system",
|
|
17809
|
+
to: "lead",
|
|
17810
|
+
content: `Agent "${member.name}" is sending many peer messages and may be over-coordinating. Consider checking on them.`
|
|
17811
|
+
});
|
|
17812
|
+
log(`watchdog:chatty member=${member.name} limit=${this.peerMessageLimit}`);
|
|
17813
|
+
}
|
|
17814
|
+
}
|
|
17652
17815
|
async check() {
|
|
17653
17816
|
await this.cleanupStaleWorktrees();
|
|
17654
17817
|
await this.checkStalled();
|
|
17818
|
+
await this.checkChatty();
|
|
17655
17819
|
if (this.ttlMs === 0)
|
|
17656
17820
|
return;
|
|
17657
17821
|
const cutoff = Date.now() - this.ttlMs;
|
|
@@ -17717,7 +17881,7 @@ var plugin = async (input) => {
|
|
|
17717
17881
|
const rawClient = new OpencodeClient({ client: pluginTransport });
|
|
17718
17882
|
initLog(rawClient);
|
|
17719
17883
|
const client3 = wrapThrowingClient(rawClient);
|
|
17720
|
-
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 };
|
|
17721
17885
|
if (!isWorktreeInstance(input.directory)) {
|
|
17722
17886
|
log("init:recovery:start (main instance)");
|
|
17723
17887
|
const recovery = await recoverStaleMembers(db, client3, input.directory);
|
|
@@ -17761,7 +17925,9 @@ var plugin = async (input) => {
|
|
|
17761
17925
|
stallThresholdMs: config2.stallThresholdMs,
|
|
17762
17926
|
stallMinSteps: config2.stallMinSteps,
|
|
17763
17927
|
stallTokenThreshold: config2.stallTokenThreshold,
|
|
17764
|
-
cwd: input.directory
|
|
17928
|
+
cwd: input.directory,
|
|
17929
|
+
peerMessageLimit: config2.peerMessageLimit,
|
|
17930
|
+
peerMessageWindowMs: config2.peerMessageWindowMs
|
|
17765
17931
|
});
|
|
17766
17932
|
watchdog.start();
|
|
17767
17933
|
return {
|
|
@@ -17775,6 +17941,31 @@ var plugin = async (input) => {
|
|
|
17775
17941
|
notifyTeamEvent(client3, "shutdown", { memberName: transition.memberName });
|
|
17776
17942
|
} else if (transition.to === "ready" && transition.from === "busy") {
|
|
17777
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
|
+
}
|
|
17778
17969
|
const nudgeKey = `${transition.teamId}:${transition.memberName}`;
|
|
17779
17970
|
if (!nudgedMembers.has(nudgeKey) && shouldNudgeIdleMember(db, transition.teamId, transition.memberName)) {
|
|
17780
17971
|
nudgedMembers.add(nudgeKey);
|
|
@@ -17829,6 +18020,22 @@ var plugin = async (input) => {
|
|
|
17829
18020
|
});
|
|
17830
18021
|
}
|
|
17831
18022
|
}
|
|
18023
|
+
const member = db.query(`SELECT tm.team_id, tm.name FROM team_member tm
|
|
18024
|
+
JOIN team t ON tm.team_id = t.id
|
|
18025
|
+
WHERE tm.session_id = ? AND t.status = 'active'`).get(sessionID);
|
|
18026
|
+
if (member) {
|
|
18027
|
+
const staleThreshold = Date.now() - 5000;
|
|
18028
|
+
const peerMsgs = db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND to_name = ? AND delivered = 0 AND time_created < ?").get(member.team_id, member.name, staleThreshold);
|
|
18029
|
+
if (peerMsgs.c > 0) {
|
|
18030
|
+
log(`wake-peer: ${member.name} has ${peerMsgs.c} pending peer messages`);
|
|
18031
|
+
client3.session.promptAsync({
|
|
18032
|
+
sessionID,
|
|
18033
|
+
parts: [{ type: "text", text: `[System: ${peerMsgs.c} new message(s) from teammates]` }]
|
|
18034
|
+
}).catch((err) => {
|
|
18035
|
+
log(`wake-peer:failed err=${err instanceof Error ? err.message : String(err)}`);
|
|
18036
|
+
});
|
|
18037
|
+
}
|
|
18038
|
+
}
|
|
17832
18039
|
}
|
|
17833
18040
|
}
|
|
17834
18041
|
if (event.type === "session.created") {
|
|
@@ -17861,7 +18068,7 @@ var plugin = async (input) => {
|
|
|
17861
18068
|
if (!teamInfo)
|
|
17862
18069
|
return;
|
|
17863
18070
|
log(`system-prompt:transform role=${teamInfo.role} session=${input2.sessionID}`);
|
|
17864
|
-
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");
|
|
17865
18072
|
log(`system-prompt:injected role=${teamInfo.role} len=${prompt.length}`);
|
|
17866
18073
|
output.system.push(prompt);
|
|
17867
18074
|
},
|
|
@@ -17931,6 +18138,8 @@ var plugin = async (input) => {
|
|
|
17931
18138
|
async execute(args, ctx) {
|
|
17932
18139
|
const result = await executeTeamMessage(deps, args, ctx.sessionID);
|
|
17933
18140
|
progressTracker.recordMessage(ctx.sessionID);
|
|
18141
|
+
if (args.to !== "lead")
|
|
18142
|
+
progressTracker.recordPeerMessage(ctx.sessionID);
|
|
17934
18143
|
ctx.metadata({ title: `Message \u2192 ${args.to}` });
|
|
17935
18144
|
return result;
|
|
17936
18145
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hueyexe/opencode-ensemble",
|
|
3
|
-
"version": "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
|
@@ -17,17 +17,38 @@ export interface EnsembleConfig {
|
|
|
17
17
|
rateLimitCapacity?: number
|
|
18
18
|
/** Dashboard server port (default: 4747, 0 to disable) */
|
|
19
19
|
dashboardPort?: number
|
|
20
|
+
/** Max peer messages per agent per window before nudge (default: 5, 0 to disable) */
|
|
21
|
+
peerMessageLimit?: number
|
|
22
|
+
/** Time window for peer message rate limiting in ms (default: 300000 = 5 min) */
|
|
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
|
|
20
34
|
}
|
|
21
35
|
|
|
22
36
|
/** Default configuration values. */
|
|
23
37
|
export const DEFAULT_CONFIG: Required<EnsembleConfig> = {
|
|
24
38
|
mergeOnCleanup: true,
|
|
25
|
-
stallThresholdMs:
|
|
26
|
-
stallMinSteps:
|
|
27
|
-
stallTokenThreshold:
|
|
39
|
+
stallThresholdMs: 300_000,
|
|
40
|
+
stallMinSteps: 5,
|
|
41
|
+
stallTokenThreshold: 200,
|
|
28
42
|
timeoutMs: 30 * 60 * 1000,
|
|
29
43
|
rateLimitCapacity: 10,
|
|
30
44
|
dashboardPort: 4747,
|
|
45
|
+
peerMessageLimit: 5,
|
|
46
|
+
peerMessageWindowMs: 300_000,
|
|
47
|
+
defaultModel: "",
|
|
48
|
+
modelPool: [],
|
|
49
|
+
modelsByAgent: {},
|
|
50
|
+
modelAssignment: "default",
|
|
51
|
+
promptForModels: false,
|
|
31
52
|
}
|
|
32
53
|
|
|
33
54
|
/** Read a JSON config file, returning an empty object on missing/invalid. */
|
|
@@ -44,6 +65,16 @@ function readConfigFile(filePath: string): Partial<EnsembleConfig> {
|
|
|
44
65
|
if (typeof raw.timeoutMs === "number") result.timeoutMs = raw.timeoutMs
|
|
45
66
|
if (typeof raw.rateLimitCapacity === "number") result.rateLimitCapacity = raw.rateLimitCapacity
|
|
46
67
|
if (typeof raw.dashboardPort === "number") result.dashboardPort = raw.dashboardPort
|
|
68
|
+
if (typeof raw.peerMessageLimit === "number") result.peerMessageLimit = raw.peerMessageLimit
|
|
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
|
|
47
78
|
return result
|
|
48
79
|
} catch (err) {
|
|
49
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">'+
|
|
@@ -189,16 +190,17 @@ function rActivity(t){
|
|
|
189
190
|
const p=parseR(m.content);
|
|
190
191
|
const deliv=m.delivered?(m.read?'\\u2713\\u2713':'\\u2713'):'';
|
|
191
192
|
const isFromAgent=m.fromName!=='lead'&&m.fromName!=='system';
|
|
193
|
+
const isPeer=isFromAgent&&m.toName&&m.toName!=='lead'&&m.toName!=='all';
|
|
192
194
|
const align=isFromAgent?'mr-6':'ml-6';
|
|
193
|
-
const bubbleBg=isFromAgent?'bg-blue-500/[0.06] border-blue-500/15':'bg-base-800/40 border-base-700/30';
|
|
195
|
+
const bubbleBg=isPeer?'bg-violet-500/[0.06] border-violet-500/15':isFromAgent?'bg-blue-500/[0.06] border-blue-500/15':'bg-base-800/40 border-base-700/30';
|
|
194
196
|
const initial=m.fromName.charAt(0).toUpperCase();
|
|
195
|
-
const avatarColor=m.fromName==='system'?'bg-amber-500/20 text-amber-400':isFromAgent?'bg-blue-500/20 text-blue-400':'bg-emerald-500/20 text-emerald-400';
|
|
197
|
+
const avatarColor=m.fromName==='system'?'bg-amber-500/20 text-amber-400':isPeer?'bg-violet-500/20 text-violet-400':isFromAgent?'bg-blue-500/20 text-blue-400':'bg-emerald-500/20 text-emerald-400';
|
|
196
198
|
html+='<div class="'+align+(isNew?' hl':'')+' cursor-pointer" data-msg="'+E(m.id)+'" onclick="toggleMsg(\\''+E(m.id)+'\\')">';
|
|
197
199
|
html+='<div class="flex items-start gap-2">';
|
|
198
200
|
html+='<span class="w-5 h-5 rounded-full '+avatarColor+' flex items-center justify-center text-[9px] font-semibold shrink-0 mt-0.5">'+E(initial)+'</span>';
|
|
199
201
|
html+='<div class="flex-1 min-w-0 rounded-xl border '+bubbleBg+' px-3 py-2">';
|
|
200
202
|
html+='<div class="flex items-center gap-1.5 mb-1">';
|
|
201
|
-
html+='<span class="text-[11px] font-medium '+(isFromAgent?'text-blue-400':'text-txt-300')+'">'+E(m.fromName)+'</span>';
|
|
203
|
+
html+='<span class="text-[11px] font-medium '+(isPeer?'text-violet-400':isFromAgent?'text-blue-400':'text-txt-300')+'">'+E(m.fromName)+'</span>';
|
|
202
204
|
if(m.toName)html+=chip('\\u2192 '+E(m.toName),'muted');
|
|
203
205
|
html+=chip(relT(m.timeCreated),'muted');
|
|
204
206
|
if(deliv)html+='<span class="text-txt-500 text-[10px] ml-auto">'+deliv+'</span>';
|
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
|
|
@@ -122,6 +123,8 @@ const plugin: Plugin = async (input) => {
|
|
|
122
123
|
stallMinSteps: config.stallMinSteps,
|
|
123
124
|
stallTokenThreshold: config.stallTokenThreshold,
|
|
124
125
|
cwd: input.directory,
|
|
126
|
+
peerMessageLimit: config.peerMessageLimit,
|
|
127
|
+
peerMessageWindowMs: config.peerMessageWindowMs,
|
|
125
128
|
})
|
|
126
129
|
watchdog.start()
|
|
127
130
|
|
|
@@ -139,6 +142,39 @@ const plugin: Plugin = async (input) => {
|
|
|
139
142
|
notifyTeamEvent(client, "shutdown", { memberName: transition.memberName })
|
|
140
143
|
} else if (transition.to === "ready" && transition.from === "busy") {
|
|
141
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
|
+
|
|
142
178
|
// Nudge teammate if they went idle without reporting to the lead (once only)
|
|
143
179
|
const nudgeKey = `${transition.teamId}:${transition.memberName}`
|
|
144
180
|
if (!nudgedMembers.has(nudgeKey) && shouldNudgeIdleMember(db, transition.teamId, transition.memberName)) {
|
|
@@ -206,6 +242,29 @@ const plugin: Plugin = async (input) => {
|
|
|
206
242
|
})
|
|
207
243
|
}
|
|
208
244
|
}
|
|
245
|
+
|
|
246
|
+
// Also flush peer messages for teammates that just went idle
|
|
247
|
+
// Only flush messages older than 5s to avoid double-delivery with the direct promptAsync path
|
|
248
|
+
const member = db.query(
|
|
249
|
+
`SELECT tm.team_id, tm.name FROM team_member tm
|
|
250
|
+
JOIN team t ON tm.team_id = t.id
|
|
251
|
+
WHERE tm.session_id = ? AND t.status = 'active'`
|
|
252
|
+
).get(sessionID) as { team_id: string; name: string } | null
|
|
253
|
+
if (member) {
|
|
254
|
+
const staleThreshold = Date.now() - 5000
|
|
255
|
+
const peerMsgs = db.query(
|
|
256
|
+
"SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND to_name = ? AND delivered = 0 AND time_created < ?"
|
|
257
|
+
).get(member.team_id, member.name, staleThreshold) as { c: number }
|
|
258
|
+
if (peerMsgs.c > 0) {
|
|
259
|
+
log(`wake-peer: ${member.name} has ${peerMsgs.c} pending peer messages`)
|
|
260
|
+
client.session.promptAsync({
|
|
261
|
+
sessionID,
|
|
262
|
+
parts: [{ type: "text", text: `[System: ${peerMsgs.c} new message(s) from teammates]` }],
|
|
263
|
+
}).catch((err) => {
|
|
264
|
+
log(`wake-peer:failed err=${err instanceof Error ? err.message : String(err)}`)
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
}
|
|
209
268
|
}
|
|
210
269
|
}
|
|
211
270
|
|
|
@@ -245,7 +304,7 @@ const plugin: Plugin = async (input) => {
|
|
|
245
304
|
if (!teamInfo) return
|
|
246
305
|
log(`system-prompt:transform role=${teamInfo.role} session=${input.sessionID}`)
|
|
247
306
|
const prompt = teamInfo.role === "lead"
|
|
248
|
-
? buildLeadSystemPrompt(db, teamInfo.teamId)
|
|
307
|
+
? buildLeadSystemPrompt(db, teamInfo.teamId, config)
|
|
249
308
|
: buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown")
|
|
250
309
|
log(`system-prompt:injected role=${teamInfo.role} len=${prompt.length}`)
|
|
251
310
|
output.system.push(prompt)
|
|
@@ -325,6 +384,8 @@ const plugin: Plugin = async (input) => {
|
|
|
325
384
|
const result = await executeTeamMessage(deps, args, ctx.sessionID)
|
|
326
385
|
// Track message activity for stall detection
|
|
327
386
|
progressTracker.recordMessage(ctx.sessionID)
|
|
387
|
+
// Track peer messages for chatty detection
|
|
388
|
+
if (args.to !== "lead") progressTracker.recordPeerMessage(ctx.sessionID)
|
|
328
389
|
ctx.metadata({ title: `Message → ${args.to}` })
|
|
329
390
|
return result
|
|
330
391
|
},
|
package/src/progress.ts
CHANGED
|
@@ -12,7 +12,9 @@ export class ProgressTracker {
|
|
|
12
12
|
private steps = new Map<string, StepRecord[]>()
|
|
13
13
|
private lastMessageAt = new Map<string, number>()
|
|
14
14
|
private lastTaskAt = new Map<string, number>()
|
|
15
|
+
private peerMessages = new Map<string, number[]>()
|
|
15
16
|
private reported = new Set<string>()
|
|
17
|
+
private chattyReported = new Set<string>()
|
|
16
18
|
private readonly maxSteps: number
|
|
17
19
|
|
|
18
20
|
constructor(maxSteps = 10) {
|
|
@@ -27,6 +29,40 @@ export class ProgressTracker {
|
|
|
27
29
|
this.steps.set(sessionId, records)
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
/** Record a peer message (not to lead). Used for chatty detection. */
|
|
33
|
+
recordPeerMessage(sessionId: string): void {
|
|
34
|
+
const timestamps = this.peerMessages.get(sessionId) ?? []
|
|
35
|
+
timestamps.push(Date.now())
|
|
36
|
+
this.peerMessages.set(sessionId, timestamps)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Check if agent is chatty: more than `limit` peer messages within `windowMs`. */
|
|
40
|
+
isChatty(sessionId: string, limit: number, windowMs: number): boolean {
|
|
41
|
+
if (limit === 0) return false
|
|
42
|
+
const timestamps = this.peerMessages.get(sessionId)
|
|
43
|
+
if (!timestamps) return false
|
|
44
|
+
const cutoff = Date.now() - windowMs
|
|
45
|
+
const recent = timestamps.filter(t => t >= cutoff)
|
|
46
|
+
// Prune old timestamps
|
|
47
|
+
this.peerMessages.set(sessionId, recent)
|
|
48
|
+
return recent.length >= limit
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Mark chatty as reported (avoid spam). */
|
|
52
|
+
markChattyReported(sessionId: string): void {
|
|
53
|
+
this.chattyReported.add(sessionId)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Check if chatty already reported. */
|
|
57
|
+
isChattyReported(sessionId: string): boolean {
|
|
58
|
+
return this.chattyReported.has(sessionId)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Clear chatty report (e.g., when agent sends result to lead). */
|
|
62
|
+
clearChattyReport(sessionId: string): void {
|
|
63
|
+
this.chattyReported.delete(sessionId)
|
|
64
|
+
}
|
|
65
|
+
|
|
30
66
|
/** Record that this member sent a team_message. Clears stall report. */
|
|
31
67
|
recordMessage(sessionId: string): void {
|
|
32
68
|
this.lastMessageAt.set(sessionId, Date.now())
|
|
@@ -80,6 +116,8 @@ export class ProgressTracker {
|
|
|
80
116
|
this.steps.delete(sessionId)
|
|
81
117
|
this.lastMessageAt.delete(sessionId)
|
|
82
118
|
this.lastTaskAt.delete(sessionId)
|
|
119
|
+
this.peerMessages.delete(sessionId)
|
|
83
120
|
this.reported.delete(sessionId)
|
|
121
|
+
this.chattyReported.delete(sessionId)
|
|
84
122
|
}
|
|
85
123
|
}
|
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.",
|
|
@@ -125,13 +158,39 @@ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
|
|
|
125
158
|
|
|
126
159
|
/**
|
|
127
160
|
* Build the system prompt injected into a teammate's session.
|
|
128
|
-
*
|
|
161
|
+
* Includes role reminder and delivers any pending peer messages.
|
|
129
162
|
*/
|
|
130
163
|
export function buildTeammateSystemPrompt(db: Database, teamId: string, memberName: string): string {
|
|
131
164
|
const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId) as { name: string } | null
|
|
132
165
|
if (!team) return ""
|
|
133
166
|
|
|
134
|
-
|
|
167
|
+
const lines = [
|
|
168
|
+
`You are "${memberName}", a teammate in team "${team.name}". Use team_message to communicate. You MUST send your results to the lead via team_message before stopping.`,
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
// Deliver pending peer messages addressed to this teammate
|
|
172
|
+
const pendingMessages = db.query(
|
|
173
|
+
"SELECT id, from_name, content FROM team_message WHERE team_id = ? AND to_name = ? AND delivered = 0 ORDER BY time_created ASC"
|
|
174
|
+
).all(teamId, memberName) as Array<{ id: string; from_name: string; content: string }>
|
|
175
|
+
|
|
176
|
+
if (pendingMessages.length > 0) {
|
|
177
|
+
lines.push("", "--- Messages for you ---")
|
|
178
|
+
const MAX_MSG = 500
|
|
179
|
+
for (const msg of pendingMessages) {
|
|
180
|
+
const parsed = parseTaskResult(msg.content)
|
|
181
|
+
if (parsed) {
|
|
182
|
+
lines.push(formatTaskResult(msg.from_name, { ...parsed, details: truncate(parsed.details, MAX_MSG) }))
|
|
183
|
+
} else if (msg.content.length > MAX_MSG) {
|
|
184
|
+
lines.push(`[From ${msg.from_name}]: ${msg.content.slice(0, MAX_MSG)}... (truncated)`)
|
|
185
|
+
} else {
|
|
186
|
+
lines.push(`[From ${msg.from_name}]: ${msg.content}`)
|
|
187
|
+
}
|
|
188
|
+
markDelivered(db, msg.id)
|
|
189
|
+
}
|
|
190
|
+
lines.push("--- End Messages ---")
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return lines.join("\n")
|
|
135
194
|
}
|
|
136
195
|
|
|
137
196
|
/**
|
|
@@ -18,6 +18,22 @@ export async function executeTeamMessage(
|
|
|
18
18
|
const senderName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
|
|
19
19
|
|
|
20
20
|
const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to)
|
|
21
|
+
|
|
22
|
+
// If recipient not found, store the message for later delivery (they may not be spawned yet)
|
|
23
|
+
// Reject approve/reject flags for unspawned recipients — plan approval requires the member to exist
|
|
24
|
+
if (!recipientSessionId && args.to !== "lead") {
|
|
25
|
+
if (args.approve || args.reject) {
|
|
26
|
+
throw new Error(`Cannot approve/reject plan for "${args.to}" — they haven't been spawned yet.`)
|
|
27
|
+
}
|
|
28
|
+
sendMessage(deps.db, {
|
|
29
|
+
teamId: teamInfo.teamId,
|
|
30
|
+
from: senderName,
|
|
31
|
+
to: args.to,
|
|
32
|
+
content: args.text,
|
|
33
|
+
})
|
|
34
|
+
log(`team_message:queued from=${senderName} to=${args.to} (recipient not yet spawned)`)
|
|
35
|
+
return `Message queued for ${args.to} — they haven't been spawned yet. It will be delivered when they join the team.`
|
|
36
|
+
}
|
|
21
37
|
if (!recipientSessionId) throw new Error(`Recipient "${args.to}" not found in team "${teamInfo.teamName}"`)
|
|
22
38
|
|
|
23
39
|
// Handle plan approval/rejection
|
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
|
|
@@ -253,6 +288,19 @@ export async function executeTeamSpawn(
|
|
|
253
288
|
)
|
|
254
289
|
}
|
|
255
290
|
|
|
291
|
+
// Collaboration guidance for peer-to-peer communication
|
|
292
|
+
if (otherMembers.length > 0) {
|
|
293
|
+
context.push(
|
|
294
|
+
"",
|
|
295
|
+
"Collaboration:",
|
|
296
|
+
"- Check team_tasks_list to see what other teammates are working on.",
|
|
297
|
+
"- If you need information another teammate has, message them directly via team_message.",
|
|
298
|
+
"- If you discover something relevant to another teammate's task, share it with them.",
|
|
299
|
+
"- Use team_broadcast for updates that affect the whole team.",
|
|
300
|
+
"- Keep peer messages focused and actionable — coordinate, don't chat.",
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
|
|
256
304
|
context.push("", "When you finish your task:")
|
|
257
305
|
if (!isReadOnly && worktreeBranch) {
|
|
258
306
|
context.push(`1. Commit your changes: git add -A && git commit -m "your summary"`)
|
|
@@ -303,16 +351,23 @@ export async function executeTeamSpawn(
|
|
|
303
351
|
|
|
304
352
|
const contextStr = context.join("\n")
|
|
305
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
|
+
|
|
306
360
|
// Fire-and-forget: send prompt to teammate session.
|
|
307
361
|
log(`spawn:promptAsync:fire name=${args.name} sessionId=${childSessionId}`)
|
|
308
362
|
deps.client.session.promptAsync({
|
|
309
363
|
sessionID: childSessionId,
|
|
310
364
|
parts: [{ type: "text", text: contextStr }],
|
|
311
365
|
agent: args.agent,
|
|
312
|
-
|
|
313
|
-
|
|
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`)
|
|
314
370
|
try {
|
|
315
|
-
// Async rollback: clean up DB (by session_id to avoid deleting a re-spawned member with the same name), registry, session, and worktree
|
|
316
371
|
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId])
|
|
317
372
|
deps.registry.unregister(childSessionId)
|
|
318
373
|
deps.client.session.abort({ sessionID: childSessionId }).catch(() => { /* best effort */ })
|
|
@@ -322,20 +377,18 @@ export async function executeTeamSpawn(
|
|
|
322
377
|
if (worktreeDir) {
|
|
323
378
|
deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => { /* best effort */ })
|
|
324
379
|
}
|
|
325
|
-
|
|
380
|
+
const modelInfo = resolvedModel ? ` (model: ${resolvedModel})` : ""
|
|
326
381
|
deps.client.tui.showToast({
|
|
327
382
|
title: "Team",
|
|
328
|
-
message: `Teammate "${args.name}" failed to start
|
|
383
|
+
message: `Teammate "${args.name}" failed to start${modelInfo}: ${errMsg}`,
|
|
329
384
|
variant: "error",
|
|
330
|
-
duration:
|
|
385
|
+
duration: 8000,
|
|
331
386
|
}).catch(() => { /* TUI may not be available */ })
|
|
332
|
-
// Notify the lead model so it can react (retry, adjust plan, etc.)
|
|
333
|
-
// Message delivered via system prompt transform on the lead's next turn.
|
|
334
387
|
sendMessage(deps.db, {
|
|
335
388
|
teamId: teamInfo.teamId,
|
|
336
389
|
from: "system",
|
|
337
390
|
to: "lead",
|
|
338
|
-
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.`,
|
|
339
392
|
})
|
|
340
393
|
} catch { /* rollback failed — watchdog will clean up stale member */ }
|
|
341
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. */
|
package/src/watchdog.ts
CHANGED
|
@@ -24,6 +24,10 @@ interface WatchdogOpts {
|
|
|
24
24
|
stallTokenThreshold?: number
|
|
25
25
|
/** Project directory for git operations. */
|
|
26
26
|
cwd?: string
|
|
27
|
+
/** Max peer messages per agent per window before nudge. 0 disables. */
|
|
28
|
+
peerMessageLimit?: number
|
|
29
|
+
/** Time window for peer message rate limiting in ms. */
|
|
30
|
+
peerMessageWindowMs?: number
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
/**
|
|
@@ -41,6 +45,8 @@ export class Watchdog {
|
|
|
41
45
|
private readonly stallMinSteps: number
|
|
42
46
|
private readonly stallTokenThreshold: number
|
|
43
47
|
private readonly cwd?: string
|
|
48
|
+
private readonly peerMessageLimit: number
|
|
49
|
+
private readonly peerMessageWindowMs: number
|
|
44
50
|
private timer: ReturnType<typeof setInterval> | undefined
|
|
45
51
|
|
|
46
52
|
constructor(opts: WatchdogOpts) {
|
|
@@ -54,6 +60,8 @@ export class Watchdog {
|
|
|
54
60
|
this.stallMinSteps = opts.stallMinSteps ?? 3
|
|
55
61
|
this.stallTokenThreshold = opts.stallTokenThreshold ?? 500
|
|
56
62
|
this.cwd = opts.cwd
|
|
63
|
+
this.peerMessageLimit = opts.peerMessageLimit ?? 0
|
|
64
|
+
this.peerMessageWindowMs = opts.peerMessageWindowMs ?? 300_000
|
|
57
65
|
}
|
|
58
66
|
|
|
59
67
|
private static STALE_THRESHOLD_MS = Number(process.env.STALE_WORKTREE_THRESHOLD_MS) || 300_000
|
|
@@ -133,10 +141,46 @@ export class Watchdog {
|
|
|
133
141
|
}
|
|
134
142
|
}
|
|
135
143
|
|
|
144
|
+
/** Check for chatty agents sending too many peer messages. */
|
|
145
|
+
async checkChatty(): Promise<void> {
|
|
146
|
+
if (!this.progressTracker || this.peerMessageLimit === 0) return
|
|
147
|
+
|
|
148
|
+
const busy = this.db.query(
|
|
149
|
+
`SELECT tm.team_id, tm.name, tm.session_id
|
|
150
|
+
FROM team_member tm
|
|
151
|
+
JOIN team t ON tm.team_id = t.id
|
|
152
|
+
WHERE t.status = 'active' AND tm.status = 'busy'`
|
|
153
|
+
).all() as Array<{ team_id: string; name: string; session_id: string }>
|
|
154
|
+
|
|
155
|
+
for (const member of busy) {
|
|
156
|
+
if (this.progressTracker.isChattyReported(member.session_id)) continue
|
|
157
|
+
if (!this.progressTracker.isChatty(member.session_id, this.peerMessageLimit, this.peerMessageWindowMs)) continue
|
|
158
|
+
|
|
159
|
+
this.progressTracker.markChattyReported(member.session_id)
|
|
160
|
+
|
|
161
|
+
// Nudge the agent
|
|
162
|
+
this.client.session.promptAsync({
|
|
163
|
+
sessionID: member.session_id,
|
|
164
|
+
parts: [{ type: "text", text: "[System]: You've sent several messages to teammates. Focus on completing your task and send your results to the lead via team_message." }],
|
|
165
|
+
}).catch(() => { /* best effort */ })
|
|
166
|
+
|
|
167
|
+
// Notify the lead
|
|
168
|
+
sendMessage(this.db, {
|
|
169
|
+
teamId: member.team_id,
|
|
170
|
+
from: "system",
|
|
171
|
+
to: "lead",
|
|
172
|
+
content: `Agent "${member.name}" is sending many peer messages and may be over-coordinating. Consider checking on them.`,
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
log(`watchdog:chatty member=${member.name} limit=${this.peerMessageLimit}`)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
136
179
|
/** Run a single check for stale busy members. */
|
|
137
180
|
async check(): Promise<void> {
|
|
138
181
|
await this.cleanupStaleWorktrees()
|
|
139
182
|
await this.checkStalled()
|
|
183
|
+
await this.checkChatty()
|
|
140
184
|
if (this.ttlMs === 0) return
|
|
141
185
|
|
|
142
186
|
const cutoff = Date.now() - this.ttlMs
|