@hueyexe/opencode-ensemble 0.7.1 → 0.8.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 +66 -11
- package/dist/index.js +304 -20
- package/package.json +1 -1
- package/src/config.ts +75 -0
- package/src/index.ts +34 -13
- package/src/progress.ts +85 -0
- package/src/system-prompt.ts +61 -1
- package/src/tools/team-cleanup.ts +83 -6
- package/src/tools/team-spawn.ts +15 -0
- package/src/tools/team-status.ts +25 -3
- package/src/watchdog.ts +67 -0
package/README.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# OpenCode Ensemble
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
|
|
4
|
+
[](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
|
|
5
|
+
[]()
|
|
6
|
+
[]()
|
|
7
|
+
[]()
|
|
8
|
+
[](./LICENSE)
|
|
9
|
+
|
|
3
10
|
Run parallel AI agents in OpenCode. Each agent gets its own session, context window, and task. They coordinate through messaging and a shared task board.
|
|
4
11
|
|
|
5
12
|
Plugin built on the public OpenCode SDK. No internal dependencies.
|
|
@@ -50,9 +57,10 @@ You: "How's the team doing?"
|
|
|
50
57
|
Lead calls team_status:
|
|
51
58
|
Team: validation (you are the lead)
|
|
52
59
|
Members:
|
|
53
|
-
alice [idle] agent: build
|
|
54
|
-
bob [idle] agent: build
|
|
55
|
-
carol [working]
|
|
60
|
+
alice [idle 2m, last msg: 1m ago] agent: build branch: ensemble-validation-alice
|
|
61
|
+
bob [idle 1m, last msg: 30s ago] agent: build branch: ensemble-validation-bob
|
|
62
|
+
carol [working 5m, last msg: 3m ago] agent: build branch: ensemble-validation-carol
|
|
63
|
+
task: Write integration tests for validated endpoints
|
|
56
64
|
Tasks: 5 total (3 completed, 1 in_progress, 1 pending)
|
|
57
65
|
```
|
|
58
66
|
|
|
@@ -66,7 +74,7 @@ Lead calls team_view({ member: "carol" })
|
|
|
66
74
|
-> Use the session picker (ctrl+p) to go back to the lead
|
|
67
75
|
```
|
|
68
76
|
|
|
69
|
-
When everything is done, the lead shuts down teammates and cleans up:
|
|
77
|
+
When everything is done, the lead shuts down teammates and cleans up. Worktree branches are automatically merged into your working directory as unstaged changes for review:
|
|
70
78
|
|
|
71
79
|
```
|
|
72
80
|
[toast] alice shut down
|
|
@@ -74,9 +82,13 @@ When everything is done, the lead shuts down teammates and cleans up:
|
|
|
74
82
|
[toast] carol shut down
|
|
75
83
|
|
|
76
84
|
Lead: "All validation and tests are complete. 5 endpoints validated,
|
|
77
|
-
12 test cases added. Team cleaned up.
|
|
85
|
+
12 test cases added. Team cleaned up.
|
|
86
|
+
Merged 3 branch(es) into working directory (unstaged).
|
|
87
|
+
Review changes with: git diff"
|
|
78
88
|
```
|
|
79
89
|
|
|
90
|
+
All teammate changes are now in your working directory, unstaged, ready for you to review file-by-file with `git diff`.
|
|
91
|
+
|
|
80
92
|
## Install
|
|
81
93
|
|
|
82
94
|
Two steps: add the plugin, then allowlist worktree paths.
|
|
@@ -193,23 +205,66 @@ Teammate messages arrive in the lead's session as `[Team message from alice]: ..
|
|
|
193
205
|
- **Crash recovery**: stale busy members marked as errored on restart, orphaned sessions aborted, orphaned worktrees cleaned up, undelivered messages redelivered
|
|
194
206
|
- **Spawn rollback**: if the initial prompt fails, the member, session, and worktree are all cleaned up
|
|
195
207
|
- **Timeout watchdog**: teammates stuck busy beyond the TTL are automatically timed out and aborted
|
|
208
|
+
- **Stall detection**: detects teammates making no progress (low output tokens or no communication) and escalates to the lead
|
|
209
|
+
- **Auto-merge on cleanup**: worktree branches are squash-merged into your working directory as unstaged changes for review
|
|
210
|
+
- **Spawn circuit breaker**: stops retrying after 3 consecutive spawn failures
|
|
196
211
|
- **Graceful shutdown**: busy teammates receive a shutdown message and finish their current work. Use `force: true` to abort immediately.
|
|
197
|
-
- **Rate limiting**: token bucket (configurable via `OPENCODE_ENSEMBLE_RATE_LIMIT`, default 10 tokens/sec)
|
|
212
|
+
- **Rate limiting**: token bucket (configurable via config file or `OPENCODE_ENSEMBLE_RATE_LIMIT`, default 10 tokens/sec)
|
|
198
213
|
|
|
199
214
|
## Configuration
|
|
200
215
|
|
|
216
|
+
Configure via JSON files, environment variables, or both. Project config overrides global config. Env vars override everything.
|
|
217
|
+
|
|
218
|
+
### Config file
|
|
219
|
+
|
|
220
|
+
**Global** (`~/.config/opencode/ensemble.json`):
|
|
221
|
+
|
|
222
|
+
```json
|
|
223
|
+
{
|
|
224
|
+
"mergeOnCleanup": true,
|
|
225
|
+
"stallThresholdMs": 180000,
|
|
226
|
+
"stallMinSteps": 3,
|
|
227
|
+
"stallTokenThreshold": 500,
|
|
228
|
+
"timeoutMs": 1800000,
|
|
229
|
+
"rateLimitCapacity": 10
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
**Project** (`.opencode/ensemble.json` in your project root) — same shape, overrides global per-key.
|
|
234
|
+
|
|
235
|
+
All fields are optional. Missing fields use defaults.
|
|
236
|
+
|
|
237
|
+
| Key | Default | Description |
|
|
238
|
+
|-----|---------|-------------|
|
|
239
|
+
| `mergeOnCleanup` | `true` | Auto-merge worktree branches on cleanup (squash + unstage) |
|
|
240
|
+
| `stallThresholdMs` | `180000` (3 min) | Time without communication before stall escalation. `0` disables. |
|
|
241
|
+
| `stallMinSteps` | `3` | Min model steps before token-based stall check kicks in |
|
|
242
|
+
| `stallTokenThreshold` | `500` | Output tokens per step below which the agent is considered stalled |
|
|
243
|
+
| `timeoutMs` | `1800000` (30 min) | Hard timeout for busy teammates. `0` disables. |
|
|
244
|
+
| `rateLimitCapacity` | `10` | Token bucket capacity for team tool calls. `0` disables. |
|
|
245
|
+
|
|
246
|
+
### Environment variables
|
|
247
|
+
|
|
248
|
+
Env vars override config file values. Useful for CI or one-off overrides.
|
|
249
|
+
|
|
201
250
|
```bash
|
|
251
|
+
# Adjust teammate timeout (default: 1800000ms = 30 minutes)
|
|
252
|
+
OPENCODE_ENSEMBLE_TIMEOUT=3600000
|
|
253
|
+
|
|
254
|
+
# Disable timeout watchdog
|
|
255
|
+
OPENCODE_ENSEMBLE_TIMEOUT=0
|
|
256
|
+
|
|
202
257
|
# Adjust rate limit (default: 10 tokens, refills 2/sec)
|
|
203
258
|
OPENCODE_ENSEMBLE_RATE_LIMIT=20
|
|
204
259
|
|
|
205
260
|
# Disable rate limiting
|
|
206
261
|
OPENCODE_ENSEMBLE_RATE_LIMIT=0
|
|
207
262
|
|
|
208
|
-
# Adjust
|
|
209
|
-
|
|
263
|
+
# Adjust stall detection threshold (default: 180000ms = 3 minutes)
|
|
264
|
+
STALL_THRESHOLD_MS=300000
|
|
210
265
|
|
|
211
|
-
# Disable
|
|
212
|
-
|
|
266
|
+
# Disable stall detection
|
|
267
|
+
STALL_THRESHOLD_MS=0
|
|
213
268
|
```
|
|
214
269
|
|
|
215
270
|
## Best practices
|
|
@@ -243,7 +298,7 @@ Same coordination model (shared tasks, peer messaging, lead coordination) with s
|
|
|
243
298
|
```bash
|
|
244
299
|
bun install
|
|
245
300
|
bun run typecheck
|
|
246
|
-
bun test #
|
|
301
|
+
bun test # 423 tests
|
|
247
302
|
bun run build
|
|
248
303
|
```
|
|
249
304
|
|
package/dist/index.js
CHANGED
|
@@ -15287,7 +15287,7 @@ class OpencodeClient extends HeyApiClient {
|
|
|
15287
15287
|
}
|
|
15288
15288
|
}
|
|
15289
15289
|
// src/index.ts
|
|
15290
|
-
import
|
|
15290
|
+
import path3 from "path";
|
|
15291
15291
|
import { mkdirSync } from "fs";
|
|
15292
15292
|
|
|
15293
15293
|
// src/db.ts
|
|
@@ -15742,6 +15742,9 @@ async function notifyWorkingProgress(client3, db, teamId) {
|
|
|
15742
15742
|
}
|
|
15743
15743
|
|
|
15744
15744
|
// src/system-prompt.ts
|
|
15745
|
+
function truncate(s, maxLen) {
|
|
15746
|
+
return s.length > maxLen ? `${s.slice(0, maxLen)}...` : s;
|
|
15747
|
+
}
|
|
15745
15748
|
var STATUS_DISPLAY = {
|
|
15746
15749
|
busy: "working",
|
|
15747
15750
|
ready: "idle",
|
|
@@ -15770,6 +15773,20 @@ function buildLeadSystemPrompt(db, teamId) {
|
|
|
15770
15773
|
teammateLine,
|
|
15771
15774
|
`Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`
|
|
15772
15775
|
];
|
|
15776
|
+
const activeTasks = db.query("SELECT content, assignee FROM team_task WHERE team_id = ? AND status = 'in_progress' ORDER BY time_updated DESC LIMIT 5").all(teamId);
|
|
15777
|
+
const recentCompleted = db.query("SELECT content, assignee FROM team_task WHERE team_id = ? AND status = 'completed' ORDER BY time_updated DESC LIMIT 3").all(teamId);
|
|
15778
|
+
if (activeTasks.length > 0) {
|
|
15779
|
+
lines.push("Active tasks:");
|
|
15780
|
+
for (const t of activeTasks) {
|
|
15781
|
+
lines.push(` [in_progress] ${truncate(t.content, 120)}${t.assignee ? ` \u2192 ${t.assignee}` : ""}`);
|
|
15782
|
+
}
|
|
15783
|
+
}
|
|
15784
|
+
if (recentCompleted.length > 0) {
|
|
15785
|
+
lines.push("Recently completed:");
|
|
15786
|
+
for (const t of recentCompleted) {
|
|
15787
|
+
lines.push(` [completed] ${truncate(t.content, 120)}${t.assignee ? ` \u2192 ${t.assignee}` : ""}`);
|
|
15788
|
+
}
|
|
15789
|
+
}
|
|
15773
15790
|
if (pendingMessages.length > 0) {
|
|
15774
15791
|
lines.push("", "--- Team Messages ---");
|
|
15775
15792
|
const MAX_MSG = 500;
|
|
@@ -15814,9 +15831,31 @@ function buildTeamCompactionContext(db, teamId, role, memberName) {
|
|
|
15814
15831
|
membersLine,
|
|
15815
15832
|
`Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`
|
|
15816
15833
|
];
|
|
15817
|
-
if (role === "member") {
|
|
15834
|
+
if (role === "member" && memberName) {
|
|
15835
|
+
lines.push("IMPORTANT: You MUST send your results to the lead via team_message before stopping.");
|
|
15836
|
+
const member = db.query("SELECT prompt FROM team_member WHERE team_id = ? AND name = ?").get(teamId, memberName);
|
|
15837
|
+
if (member?.prompt) {
|
|
15838
|
+
lines.push(`Your original task: ${truncate(member.prompt, 300)}`);
|
|
15839
|
+
}
|
|
15840
|
+
const recentMsgs = db.query("SELECT from_name, content FROM team_message WHERE team_id = ? AND (from_name = ? OR to_name = ?) ORDER BY time_created DESC LIMIT 3").all(teamId, memberName, memberName);
|
|
15841
|
+
if (recentMsgs.length > 0) {
|
|
15842
|
+
lines.push("Recent context:");
|
|
15843
|
+
for (const msg of recentMsgs) {
|
|
15844
|
+
lines.push(` [${msg.from_name}]: ${truncate(msg.content, 200)}`);
|
|
15845
|
+
}
|
|
15846
|
+
}
|
|
15847
|
+
} else if (role === "member") {
|
|
15818
15848
|
lines.push("IMPORTANT: You MUST send your results to the lead via team_message before stopping.");
|
|
15819
15849
|
}
|
|
15850
|
+
if (role === "lead") {
|
|
15851
|
+
const completedTasks = db.query("SELECT content, assignee FROM team_task WHERE team_id = ? AND status = 'completed' ORDER BY time_updated DESC LIMIT 5").all(teamId);
|
|
15852
|
+
if (completedTasks.length > 0) {
|
|
15853
|
+
lines.push("Recently completed:");
|
|
15854
|
+
for (const t of completedTasks) {
|
|
15855
|
+
lines.push(` [completed] ${truncate(t.content, 120)}${t.assignee ? ` (by ${t.assignee})` : ""}`);
|
|
15856
|
+
}
|
|
15857
|
+
}
|
|
15858
|
+
}
|
|
15820
15859
|
return lines.join(`
|
|
15821
15860
|
`);
|
|
15822
15861
|
}
|
|
@@ -15854,6 +15893,120 @@ function resolveRecipientSession(db, registry2, teamId, recipientName) {
|
|
|
15854
15893
|
return entry?.sessionId;
|
|
15855
15894
|
}
|
|
15856
15895
|
|
|
15896
|
+
// src/config.ts
|
|
15897
|
+
import { readFileSync } from "fs";
|
|
15898
|
+
import path2 from "path";
|
|
15899
|
+
var DEFAULT_CONFIG = {
|
|
15900
|
+
mergeOnCleanup: true,
|
|
15901
|
+
stallThresholdMs: 180000,
|
|
15902
|
+
stallMinSteps: 3,
|
|
15903
|
+
stallTokenThreshold: 500,
|
|
15904
|
+
timeoutMs: 30 * 60 * 1000,
|
|
15905
|
+
rateLimitCapacity: 10
|
|
15906
|
+
};
|
|
15907
|
+
function readConfigFile(filePath) {
|
|
15908
|
+
try {
|
|
15909
|
+
const text = readFileSync(filePath, "utf-8");
|
|
15910
|
+
const raw = JSON.parse(text);
|
|
15911
|
+
const result = {};
|
|
15912
|
+
if (typeof raw.mergeOnCleanup === "boolean")
|
|
15913
|
+
result.mergeOnCleanup = raw.mergeOnCleanup;
|
|
15914
|
+
if (typeof raw.stallThresholdMs === "number")
|
|
15915
|
+
result.stallThresholdMs = raw.stallThresholdMs;
|
|
15916
|
+
if (typeof raw.stallMinSteps === "number")
|
|
15917
|
+
result.stallMinSteps = raw.stallMinSteps;
|
|
15918
|
+
if (typeof raw.stallTokenThreshold === "number")
|
|
15919
|
+
result.stallTokenThreshold = raw.stallTokenThreshold;
|
|
15920
|
+
if (typeof raw.timeoutMs === "number")
|
|
15921
|
+
result.timeoutMs = raw.timeoutMs;
|
|
15922
|
+
if (typeof raw.rateLimitCapacity === "number")
|
|
15923
|
+
result.rateLimitCapacity = raw.rateLimitCapacity;
|
|
15924
|
+
return result;
|
|
15925
|
+
} catch (err) {
|
|
15926
|
+
if (err && typeof err === "object" && "code" in err && err.code === "ENOENT")
|
|
15927
|
+
return {};
|
|
15928
|
+
console.warn(`[ensemble] Invalid config at ${filePath}, using defaults`);
|
|
15929
|
+
return {};
|
|
15930
|
+
}
|
|
15931
|
+
}
|
|
15932
|
+
function loadConfig(projectDir) {
|
|
15933
|
+
const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
|
15934
|
+
const globalPath = path2.join(homeDir, ".config", "opencode", "ensemble.json");
|
|
15935
|
+
const projectPath = path2.join(projectDir, ".opencode", "ensemble.json");
|
|
15936
|
+
const global = readConfigFile(globalPath);
|
|
15937
|
+
const project = readConfigFile(projectPath);
|
|
15938
|
+
const merged = { ...DEFAULT_CONFIG, ...global, ...project };
|
|
15939
|
+
const timeout = process.env.OPENCODE_ENSEMBLE_TIMEOUT;
|
|
15940
|
+
if (timeout !== undefined)
|
|
15941
|
+
merged.timeoutMs = timeout === "0" ? 0 : parseInt(timeout, 10) || merged.timeoutMs;
|
|
15942
|
+
const rateLimit = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT;
|
|
15943
|
+
if (rateLimit !== undefined)
|
|
15944
|
+
merged.rateLimitCapacity = rateLimit === "0" ? 0 : parseInt(rateLimit, 10) || merged.rateLimitCapacity;
|
|
15945
|
+
const stall = process.env.STALL_THRESHOLD_MS;
|
|
15946
|
+
if (stall !== undefined)
|
|
15947
|
+
merged.stallThresholdMs = stall === "0" ? 0 : parseInt(stall, 10) || merged.stallThresholdMs;
|
|
15948
|
+
return merged;
|
|
15949
|
+
}
|
|
15950
|
+
|
|
15951
|
+
// src/progress.ts
|
|
15952
|
+
class ProgressTracker {
|
|
15953
|
+
steps = new Map;
|
|
15954
|
+
lastMessageAt = new Map;
|
|
15955
|
+
lastTaskAt = new Map;
|
|
15956
|
+
reported = new Set;
|
|
15957
|
+
maxSteps;
|
|
15958
|
+
constructor(maxSteps = 10) {
|
|
15959
|
+
this.maxSteps = maxSteps;
|
|
15960
|
+
}
|
|
15961
|
+
recordStep(sessionId, outputTokens) {
|
|
15962
|
+
const records = this.steps.get(sessionId) ?? [];
|
|
15963
|
+
records.push({ outputTokens, timestamp: Date.now() });
|
|
15964
|
+
if (records.length > this.maxSteps)
|
|
15965
|
+
records.shift();
|
|
15966
|
+
this.steps.set(sessionId, records);
|
|
15967
|
+
}
|
|
15968
|
+
recordMessage(sessionId) {
|
|
15969
|
+
this.lastMessageAt.set(sessionId, Date.now());
|
|
15970
|
+
this.clearReport(sessionId);
|
|
15971
|
+
}
|
|
15972
|
+
recordTaskComplete(sessionId) {
|
|
15973
|
+
this.lastTaskAt.set(sessionId, Date.now());
|
|
15974
|
+
this.clearReport(sessionId);
|
|
15975
|
+
}
|
|
15976
|
+
isTokenStalled(sessionId, minSteps, threshold) {
|
|
15977
|
+
const records = this.steps.get(sessionId);
|
|
15978
|
+
if (!records || records.length < minSteps)
|
|
15979
|
+
return false;
|
|
15980
|
+
const recent = records.slice(-minSteps);
|
|
15981
|
+
return recent.every((r) => r.outputTokens < threshold);
|
|
15982
|
+
}
|
|
15983
|
+
isTimeStalled(sessionId, thresholdMs) {
|
|
15984
|
+
const records = this.steps.get(sessionId);
|
|
15985
|
+
if (!records || records.length === 0)
|
|
15986
|
+
return false;
|
|
15987
|
+
const msgAt = this.lastMessageAt.get(sessionId) ?? 0;
|
|
15988
|
+
const taskAt = this.lastTaskAt.get(sessionId) ?? 0;
|
|
15989
|
+
const lastStep = records[records.length - 1]?.timestamp ?? 0;
|
|
15990
|
+
const baseline = Math.max(msgAt, taskAt, lastStep);
|
|
15991
|
+
return Date.now() - baseline >= thresholdMs;
|
|
15992
|
+
}
|
|
15993
|
+
markReported(sessionId) {
|
|
15994
|
+
this.reported.add(sessionId);
|
|
15995
|
+
}
|
|
15996
|
+
isReported(sessionId) {
|
|
15997
|
+
return this.reported.has(sessionId);
|
|
15998
|
+
}
|
|
15999
|
+
clearReport(sessionId) {
|
|
16000
|
+
this.reported.delete(sessionId);
|
|
16001
|
+
}
|
|
16002
|
+
remove(sessionId) {
|
|
16003
|
+
this.steps.delete(sessionId);
|
|
16004
|
+
this.lastMessageAt.delete(sessionId);
|
|
16005
|
+
this.lastTaskAt.delete(sessionId);
|
|
16006
|
+
this.reported.delete(sessionId);
|
|
16007
|
+
}
|
|
16008
|
+
}
|
|
16009
|
+
|
|
15857
16010
|
// src/tools/team-create.ts
|
|
15858
16011
|
async function executeTeamCreate(deps, args, sessionId) {
|
|
15859
16012
|
const nameError = validateTeamName(args.name);
|
|
@@ -15900,6 +16053,7 @@ function requireTeamMember(deps, sessionId) {
|
|
|
15900
16053
|
}
|
|
15901
16054
|
|
|
15902
16055
|
// src/tools/team-spawn.ts
|
|
16056
|
+
var spawnFailures = new Map;
|
|
15903
16057
|
function getSpawnTimeout() {
|
|
15904
16058
|
return Number(process.env.SPAWN_TIMEOUT_MS) || 120000;
|
|
15905
16059
|
}
|
|
@@ -15920,6 +16074,10 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15920
16074
|
if (nameError)
|
|
15921
16075
|
throw new Error(nameError);
|
|
15922
16076
|
const teamInfo = requireLead(deps, sessionId);
|
|
16077
|
+
const failures = spawnFailures.get(teamInfo.teamId);
|
|
16078
|
+
if (failures && failures.count >= 3) {
|
|
16079
|
+
throw new Error(`Spawn circuit breaker tripped for team "${teamInfo.teamName}": 3 consecutive failures. Last error: ${failures.lastError}. Investigate before retrying \u2014 the circuit breaker resets on the next successful spawn.`);
|
|
16080
|
+
}
|
|
15923
16081
|
const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.name);
|
|
15924
16082
|
if (existing)
|
|
15925
16083
|
throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
|
|
@@ -15989,6 +16147,9 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15989
16147
|
log(`spawn:session:done name=${args.name} sessionId=${childSessionId}`);
|
|
15990
16148
|
} catch (err) {
|
|
15991
16149
|
log(`spawn:session:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`);
|
|
16150
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
16151
|
+
const prev = spawnFailures.get(teamInfo.teamId);
|
|
16152
|
+
spawnFailures.set(teamInfo.teamId, { count: (prev?.count ?? 0) + 1, lastError: errMsg });
|
|
15992
16153
|
if (workspaceId) {
|
|
15993
16154
|
try {
|
|
15994
16155
|
await deps.client.workspace.remove({ id: workspaceId });
|
|
@@ -16095,6 +16256,7 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
16095
16256
|
});
|
|
16096
16257
|
const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : "";
|
|
16097
16258
|
const planInfo = usePlanApproval ? " [plan mode \u2014 will send plan for approval]" : "";
|
|
16259
|
+
spawnFailures.delete(teamInfo.teamId);
|
|
16098
16260
|
log(`spawn:done name=${args.name} sessionId=${childSessionId}`);
|
|
16099
16261
|
return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`;
|
|
16100
16262
|
}
|
|
@@ -16251,7 +16413,40 @@ Warning: ${memberName} has uncommitted changes in their worktree. Commit or merg
|
|
|
16251
16413
|
}
|
|
16252
16414
|
|
|
16253
16415
|
// src/tools/team-cleanup.ts
|
|
16254
|
-
async function
|
|
16416
|
+
async function mergeAllBranches(branches, cwd) {
|
|
16417
|
+
const merged = [];
|
|
16418
|
+
const conflicted = [];
|
|
16419
|
+
const stash = Bun.spawn(["git", "stash", "--include-untracked"], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
16420
|
+
const stashOut = await new Response(stash.stdout).text();
|
|
16421
|
+
const stashExit = await stash.exited;
|
|
16422
|
+
const didStash = stashExit === 0 && !stashOut.includes("No local changes");
|
|
16423
|
+
for (const branch of branches) {
|
|
16424
|
+
const merge2 = Bun.spawn(["git", "merge", "--squash", branch], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
16425
|
+
const stderrPromise = new Response(merge2.stderr).text();
|
|
16426
|
+
const exit = await merge2.exited;
|
|
16427
|
+
if (exit !== 0) {
|
|
16428
|
+
const stderr = await stderrPromise;
|
|
16429
|
+
log(`cleanup:merge:conflict branch=${branch} err=${stderr.trim()}`);
|
|
16430
|
+
const abort = Bun.spawn(["git", "merge", "--abort"], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
16431
|
+
await abort.exited;
|
|
16432
|
+
conflicted.push(branch);
|
|
16433
|
+
} else {
|
|
16434
|
+
const del = Bun.spawn(["git", "branch", "-D", branch], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
16435
|
+
await del.exited;
|
|
16436
|
+
merged.push(branch);
|
|
16437
|
+
}
|
|
16438
|
+
}
|
|
16439
|
+
if (merged.length > 0) {
|
|
16440
|
+
const reset = Bun.spawn(["git", "reset", "HEAD"], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
16441
|
+
await reset.exited;
|
|
16442
|
+
}
|
|
16443
|
+
if (didStash) {
|
|
16444
|
+
const pop = Bun.spawn(["git", "stash", "pop", "--index"], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
16445
|
+
await pop.exited;
|
|
16446
|
+
}
|
|
16447
|
+
return { merged, conflicted };
|
|
16448
|
+
}
|
|
16449
|
+
async function executeTeamCleanup(deps, args, sessionId, isDirty = checkWorktreeDirty, mergeAll = mergeAllBranches, mergeOnCleanup = true) {
|
|
16255
16450
|
const teamInfo = requireLead(deps, sessionId);
|
|
16256
16451
|
const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch, workspace_id FROM team_member WHERE team_id = ?").all(teamInfo.teamId);
|
|
16257
16452
|
const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
|
|
@@ -16288,7 +16483,20 @@ Commit or merge their work first, then call team_cleanup with acknowledge_uncomm
|
|
|
16288
16483
|
} catch {}
|
|
16289
16484
|
}
|
|
16290
16485
|
}
|
|
16291
|
-
const branches =
|
|
16486
|
+
const branches = members.map((m) => m.worktree_branch).filter((b) => b !== null);
|
|
16487
|
+
let mergeResult = "";
|
|
16488
|
+
if (branches.length > 0 && mergeOnCleanup) {
|
|
16489
|
+
const { merged, conflicted } = await mergeAll(branches, deps.directory);
|
|
16490
|
+
const parts = [];
|
|
16491
|
+
if (merged.length > 0) {
|
|
16492
|
+
parts.push(`Merged ${merged.length} branch(es) into working directory (unstaged). Review changes with: git diff`);
|
|
16493
|
+
}
|
|
16494
|
+
if (conflicted.length > 0) {
|
|
16495
|
+
parts.push(`Could not auto-merge: ${conflicted.join(", ")}. Merge manually with: git merge <branch>`);
|
|
16496
|
+
}
|
|
16497
|
+
mergeResult = parts.join(`
|
|
16498
|
+
`);
|
|
16499
|
+
}
|
|
16292
16500
|
for (const member of members) {
|
|
16293
16501
|
if (member.workspace_id) {
|
|
16294
16502
|
try {
|
|
@@ -16302,12 +16510,14 @@ Commit or merge their work first, then call team_cleanup with acknowledge_uncomm
|
|
|
16302
16510
|
deps.db.run("UPDATE team_member SET worktree_dir = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name]);
|
|
16303
16511
|
} catch {}
|
|
16304
16512
|
}
|
|
16305
|
-
if (member.worktree_branch) {
|
|
16306
|
-
branches.push(member.worktree_branch);
|
|
16307
|
-
}
|
|
16308
16513
|
}
|
|
16309
16514
|
deps.db.run("UPDATE team SET status = 'archived', time_updated = ? WHERE id = ?", [Date.now(), teamInfo.teamId]);
|
|
16310
16515
|
deps.registry.unregisterTeam(teamInfo.teamId);
|
|
16516
|
+
spawnFailures.delete(teamInfo.teamId);
|
|
16517
|
+
if (mergeResult) {
|
|
16518
|
+
return `Team "${teamInfo.teamName}" cleaned up.
|
|
16519
|
+
${mergeResult}`;
|
|
16520
|
+
}
|
|
16311
16521
|
if (branches.length > 0) {
|
|
16312
16522
|
const mergeCommands = branches.map((b) => ` git merge ${b}`).join(`
|
|
16313
16523
|
`);
|
|
@@ -16421,6 +16631,13 @@ ${r.content}
|
|
|
16421
16631
|
// src/tools/team-status.ts
|
|
16422
16632
|
var lastCallTime = new Map;
|
|
16423
16633
|
var RATE_LIMIT_MS = 30000;
|
|
16634
|
+
function formatDuration(ms) {
|
|
16635
|
+
if (ms < 60000)
|
|
16636
|
+
return `${Math.floor(ms / 1000)}s`;
|
|
16637
|
+
if (ms < 3600000)
|
|
16638
|
+
return `${Math.floor(ms / 60000)}m`;
|
|
16639
|
+
return `${Math.floor(ms / 3600000)}h`;
|
|
16640
|
+
}
|
|
16424
16641
|
async function executeTeamStatus(deps, sessionId) {
|
|
16425
16642
|
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16426
16643
|
const now = Date.now();
|
|
@@ -16429,7 +16646,7 @@ async function executeTeamStatus(deps, sessionId) {
|
|
|
16429
16646
|
return "No changes since last check.";
|
|
16430
16647
|
}
|
|
16431
16648
|
lastCallTime.set(teamInfo.teamId, now);
|
|
16432
|
-
const members = deps.db.query("SELECT name, session_id, agent, status, execution_status, worktree_branch, worktree_dir, plan_approval FROM team_member WHERE team_id = ? ORDER BY time_created ASC").all(teamInfo.teamId);
|
|
16649
|
+
const members = deps.db.query("SELECT name, session_id, agent, status, execution_status, worktree_branch, worktree_dir, plan_approval, time_updated FROM team_member WHERE team_id = ? ORDER BY time_created ASC").all(teamInfo.teamId);
|
|
16433
16650
|
const tasks = deps.db.query("SELECT status FROM team_task WHERE team_id = ?").all(teamInfo.teamId);
|
|
16434
16651
|
const lines = [];
|
|
16435
16652
|
lines.push(`Team: ${teamInfo.teamName} (you are the ${teamInfo.role})`);
|
|
@@ -16440,9 +16657,17 @@ async function executeTeamStatus(deps, sessionId) {
|
|
|
16440
16657
|
lines.push("Members:");
|
|
16441
16658
|
for (const m of members) {
|
|
16442
16659
|
const statusIcon = m.status === "busy" ? "working" : m.status === "ready" ? "idle" : m.status;
|
|
16660
|
+
const duration3 = formatDuration(now - m.time_updated);
|
|
16443
16661
|
const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : "";
|
|
16444
16662
|
const plan = m.plan_approval !== "none" ? `, plan: ${m.plan_approval}` : "";
|
|
16445
|
-
|
|
16663
|
+
const lastMsg = deps.db.query("SELECT MAX(time_created) as last_msg FROM team_message WHERE team_id = ? AND from_name = ?").get(teamInfo.teamId, m.name);
|
|
16664
|
+
const msgInfo = lastMsg?.last_msg ? `last msg: ${formatDuration(now - lastMsg.last_msg)} ago` : "no messages yet";
|
|
16665
|
+
lines.push(` ${m.name} [${statusIcon} ${duration3}, ${msgInfo}${plan}] agent: ${m.agent}${branch}`);
|
|
16666
|
+
const task = deps.db.query("SELECT content FROM team_task WHERE team_id = ? AND assignee = ? AND status = 'in_progress' LIMIT 1").get(teamInfo.teamId, m.name);
|
|
16667
|
+
if (task) {
|
|
16668
|
+
const truncated = task.content.length > 80 ? `${task.content.slice(0, 80)}...` : task.content;
|
|
16669
|
+
lines.push(` task: ${truncated}`);
|
|
16670
|
+
}
|
|
16446
16671
|
if (m.worktree_dir) {
|
|
16447
16672
|
lines.push(` worktree: ${m.worktree_dir}`);
|
|
16448
16673
|
}
|
|
@@ -16564,6 +16789,10 @@ class Watchdog {
|
|
|
16564
16789
|
registry;
|
|
16565
16790
|
ttlMs;
|
|
16566
16791
|
checkIntervalMs;
|
|
16792
|
+
progressTracker;
|
|
16793
|
+
stallThresholdMs;
|
|
16794
|
+
stallMinSteps;
|
|
16795
|
+
stallTokenThreshold;
|
|
16567
16796
|
timer;
|
|
16568
16797
|
constructor(opts) {
|
|
16569
16798
|
this.db = opts.db;
|
|
@@ -16571,6 +16800,10 @@ class Watchdog {
|
|
|
16571
16800
|
this.registry = opts.registry;
|
|
16572
16801
|
this.ttlMs = opts.ttlMs;
|
|
16573
16802
|
this.checkIntervalMs = opts.checkIntervalMs ?? 60000;
|
|
16803
|
+
this.progressTracker = opts.progressTracker;
|
|
16804
|
+
this.stallThresholdMs = opts.stallThresholdMs ?? 0;
|
|
16805
|
+
this.stallMinSteps = opts.stallMinSteps ?? 3;
|
|
16806
|
+
this.stallTokenThreshold = opts.stallTokenThreshold ?? 500;
|
|
16574
16807
|
}
|
|
16575
16808
|
static STALE_THRESHOLD_MS = Number(process.env.STALE_WORKTREE_THRESHOLD_MS) || 300000;
|
|
16576
16809
|
async cleanupStaleWorktrees() {
|
|
@@ -16592,8 +16825,45 @@ class Watchdog {
|
|
|
16592
16825
|
} catch {}
|
|
16593
16826
|
}
|
|
16594
16827
|
}
|
|
16828
|
+
async checkStalled() {
|
|
16829
|
+
if (!this.progressTracker || this.stallThresholdMs === 0)
|
|
16830
|
+
return;
|
|
16831
|
+
const busy = this.db.query(`SELECT tm.team_id, tm.name, tm.session_id
|
|
16832
|
+
FROM team_member tm
|
|
16833
|
+
JOIN team t ON tm.team_id = t.id
|
|
16834
|
+
WHERE t.status = 'active' AND tm.status = 'busy'`).all();
|
|
16835
|
+
for (const member of busy) {
|
|
16836
|
+
if (this.progressTracker.isReported(member.session_id))
|
|
16837
|
+
continue;
|
|
16838
|
+
const tokenStalled = this.progressTracker.isTokenStalled(member.session_id, this.stallMinSteps, this.stallTokenThreshold);
|
|
16839
|
+
const timeStalled = this.progressTracker.isTimeStalled(member.session_id, this.stallThresholdMs);
|
|
16840
|
+
if (!tokenStalled && !timeStalled)
|
|
16841
|
+
continue;
|
|
16842
|
+
this.progressTracker.markReported(member.session_id);
|
|
16843
|
+
const reason = tokenStalled ? "low output tokens" : "no communication";
|
|
16844
|
+
this.client.session.promptAsync({
|
|
16845
|
+
sessionID: member.session_id,
|
|
16846
|
+
parts: [{ type: "text", text: "[System]: You appear stalled \u2014 no progress detected. Report your current status to the lead via team_message, or wrap up your work." }]
|
|
16847
|
+
}).catch(() => {});
|
|
16848
|
+
sendMessage(this.db, {
|
|
16849
|
+
teamId: member.team_id,
|
|
16850
|
+
from: "system",
|
|
16851
|
+
to: "lead",
|
|
16852
|
+
content: `Teammate "${member.name}" appears stalled (${reason}). Consider checking on them via team_message or shutting them down.`
|
|
16853
|
+
});
|
|
16854
|
+
try {
|
|
16855
|
+
await this.client.tui.showToast({
|
|
16856
|
+
title: "Team",
|
|
16857
|
+
message: `${member.name} appears stalled`,
|
|
16858
|
+
variant: "warning",
|
|
16859
|
+
duration: 5000
|
|
16860
|
+
});
|
|
16861
|
+
} catch {}
|
|
16862
|
+
}
|
|
16863
|
+
}
|
|
16595
16864
|
async check() {
|
|
16596
16865
|
await this.cleanupStaleWorktrees();
|
|
16866
|
+
await this.checkStalled();
|
|
16597
16867
|
if (this.ttlMs === 0)
|
|
16598
16868
|
return;
|
|
16599
16869
|
const cutoff = Date.now() - this.ttlMs;
|
|
@@ -16635,18 +16905,18 @@ class Watchdog {
|
|
|
16635
16905
|
}
|
|
16636
16906
|
|
|
16637
16907
|
// src/index.ts
|
|
16638
|
-
var DEFAULT_RATE_LIMIT_CAPACITY = 10;
|
|
16639
16908
|
var DEFAULT_RATE_LIMIT_REFILL = 2;
|
|
16640
16909
|
var DEFAULT_RATE_LIMIT_INTERVAL_MS = 1000;
|
|
16641
|
-
var DEFAULT_WATCHDOG_TTL_MS = 30 * 60 * 1000;
|
|
16642
16910
|
var DEFAULT_WATCHDOG_CHECK_MS = 60 * 1000;
|
|
16643
16911
|
var plugin = async (input) => {
|
|
16644
16912
|
const dbPath = getDbPath();
|
|
16645
|
-
mkdirSync(
|
|
16913
|
+
mkdirSync(path3.dirname(dbPath), { recursive: true });
|
|
16646
16914
|
const db = createDb(dbPath);
|
|
16915
|
+
const config2 = loadConfig(input.directory);
|
|
16647
16916
|
const registry2 = new MemberRegistry;
|
|
16648
16917
|
const tracker = new DescendantTracker;
|
|
16649
16918
|
const nudgedMembers = new Set;
|
|
16919
|
+
const progressTracker = new ProgressTracker;
|
|
16650
16920
|
const pluginTransport = input.client._client;
|
|
16651
16921
|
const rawClient = new OpencodeClient({ client: pluginTransport });
|
|
16652
16922
|
initLog(rawClient);
|
|
@@ -16672,21 +16942,21 @@ var plugin = async (input) => {
|
|
|
16672
16942
|
} else {
|
|
16673
16943
|
log(`init:skip-recovery (worktree instance: ${input.directory})`);
|
|
16674
16944
|
}
|
|
16675
|
-
const rateLimitEnv = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT;
|
|
16676
|
-
const rateLimitCapacity = rateLimitEnv === "0" ? 0 : parseInt(rateLimitEnv ?? "", 10) || DEFAULT_RATE_LIMIT_CAPACITY;
|
|
16677
16945
|
const rateLimiter = new TokenBucket({
|
|
16678
|
-
capacity: rateLimitCapacity,
|
|
16946
|
+
capacity: config2.rateLimitCapacity,
|
|
16679
16947
|
refillRate: DEFAULT_RATE_LIMIT_REFILL,
|
|
16680
16948
|
refillIntervalMs: DEFAULT_RATE_LIMIT_INTERVAL_MS
|
|
16681
16949
|
});
|
|
16682
|
-
const timeoutEnv = process.env.OPENCODE_ENSEMBLE_TIMEOUT;
|
|
16683
|
-
const watchdogTtl = timeoutEnv === "0" ? 0 : parseInt(timeoutEnv ?? "", 10) || DEFAULT_WATCHDOG_TTL_MS;
|
|
16684
16950
|
const watchdog = new Watchdog({
|
|
16685
16951
|
db,
|
|
16686
16952
|
client: client3,
|
|
16687
16953
|
registry: registry2,
|
|
16688
|
-
ttlMs:
|
|
16689
|
-
checkIntervalMs: DEFAULT_WATCHDOG_CHECK_MS
|
|
16954
|
+
ttlMs: config2.timeoutMs,
|
|
16955
|
+
checkIntervalMs: DEFAULT_WATCHDOG_CHECK_MS,
|
|
16956
|
+
progressTracker,
|
|
16957
|
+
stallThresholdMs: config2.stallThresholdMs,
|
|
16958
|
+
stallMinSteps: config2.stallMinSteps,
|
|
16959
|
+
stallTokenThreshold: config2.stallTokenThreshold
|
|
16690
16960
|
});
|
|
16691
16961
|
watchdog.start();
|
|
16692
16962
|
return {
|
|
@@ -16752,6 +17022,14 @@ var plugin = async (input) => {
|
|
|
16752
17022
|
handleSessionCreatedEvent(tracker, info.id, info.parentID);
|
|
16753
17023
|
}
|
|
16754
17024
|
}
|
|
17025
|
+
if (event.type === "message.part.updated") {
|
|
17026
|
+
const part = event.properties.part;
|
|
17027
|
+
if (part?.type === "step-finish" && part.sessionID && part.tokens?.output !== undefined) {
|
|
17028
|
+
if (registry2.getBySession(part.sessionID)) {
|
|
17029
|
+
progressTracker.recordStep(part.sessionID, part.tokens.output);
|
|
17030
|
+
}
|
|
17031
|
+
}
|
|
17032
|
+
}
|
|
16755
17033
|
},
|
|
16756
17034
|
"tool.execute.before": async (input2, _output) => {
|
|
16757
17035
|
checkToolIsolation(registry2, tracker, input2.tool, input2.sessionID);
|
|
@@ -16837,6 +17115,7 @@ var plugin = async (input) => {
|
|
|
16837
17115
|
},
|
|
16838
17116
|
async execute(args, ctx) {
|
|
16839
17117
|
const result = await executeTeamMessage(deps, args, ctx.sessionID);
|
|
17118
|
+
progressTracker.recordMessage(ctx.sessionID);
|
|
16840
17119
|
ctx.metadata({ title: `Message \u2192 ${args.to}` });
|
|
16841
17120
|
return result;
|
|
16842
17121
|
}
|
|
@@ -16848,6 +17127,7 @@ var plugin = async (input) => {
|
|
|
16848
17127
|
},
|
|
16849
17128
|
async execute(args, ctx) {
|
|
16850
17129
|
const result = await executeTeamBroadcast(deps, args, ctx.sessionID);
|
|
17130
|
+
progressTracker.recordMessage(ctx.sessionID);
|
|
16851
17131
|
ctx.metadata({ title: "Broadcast to team" });
|
|
16852
17132
|
return result;
|
|
16853
17133
|
}
|
|
@@ -16885,6 +17165,7 @@ var plugin = async (input) => {
|
|
|
16885
17165
|
},
|
|
16886
17166
|
async execute(args, ctx) {
|
|
16887
17167
|
const result = await executeTeamTasksComplete(deps, args, ctx.sessionID);
|
|
17168
|
+
progressTracker.recordTaskComplete(ctx.sessionID);
|
|
16888
17169
|
ctx.metadata({ title: `Completed task` });
|
|
16889
17170
|
return result;
|
|
16890
17171
|
}
|
|
@@ -16919,6 +17200,9 @@ var plugin = async (input) => {
|
|
|
16919
17200
|
},
|
|
16920
17201
|
async execute(args, ctx) {
|
|
16921
17202
|
const result = await executeTeamShutdown(deps, args, ctx.sessionID);
|
|
17203
|
+
const member = deps.db.query("SELECT session_id FROM team_member WHERE name = ? AND status IN ('shutdown', 'shutdown_requested')").get(args.member);
|
|
17204
|
+
if (member)
|
|
17205
|
+
progressTracker.remove(member.session_id);
|
|
16922
17206
|
const hasWarning = result.includes("uncommitted");
|
|
16923
17207
|
ctx.metadata({ title: hasWarning ? `${args.member} shut down \u2014 uncommitted changes` : `${args.member} shut down` });
|
|
16924
17208
|
return result;
|
|
@@ -16931,7 +17215,7 @@ var plugin = async (input) => {
|
|
|
16931
17215
|
acknowledge_uncommitted: tool.schema.boolean().default(false)
|
|
16932
17216
|
},
|
|
16933
17217
|
async execute(args, ctx) {
|
|
16934
|
-
const result = await executeTeamCleanup(deps, args, ctx.sessionID);
|
|
17218
|
+
const result = await executeTeamCleanup(deps, args, ctx.sessionID, undefined, undefined, config2.mergeOnCleanup);
|
|
16935
17219
|
const blocked = result.includes("uncommitted");
|
|
16936
17220
|
ctx.metadata({ title: blocked ? "Cleanup blocked \u2014 uncommitted changes" : "Team cleaned up" });
|
|
16937
17221
|
return result;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hueyexe/opencode-ensemble",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|