@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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # OpenCode Ensemble
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@hueyexe/opencode-ensemble.svg)](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@hueyexe/opencode-ensemble.svg)](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
5
+ [![tests](https://img.shields.io/badge/tests-423%20passing-brightgreen.svg)]()
6
+ [![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue.svg)]()
7
+ [![zero deps](https://img.shields.io/badge/dependencies-0-brightgreen.svg)]()
8
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./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 session: ses_abc
54
- bob [idle] agent: build session: ses_def
55
- carol [working] agent: build session: ses_ghi
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 teammate timeout (default: 1800000ms = 30 minutes)
209
- OPENCODE_ENSEMBLE_TIMEOUT=3600000
263
+ # Adjust stall detection threshold (default: 180000ms = 3 minutes)
264
+ STALL_THRESHOLD_MS=300000
210
265
 
211
- # Disable timeout watchdog
212
- OPENCODE_ENSEMBLE_TIMEOUT=0
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 # 377 tests
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 path2 from "path";
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 executeTeamCleanup(deps, args, sessionId, isDirty = checkWorktreeDirty) {
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
- lines.push(` ${m.name} [${statusIcon}${plan}] agent: ${m.agent}${branch} session: ${m.session_id}`);
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(path2.dirname(dbPath), { recursive: true });
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: watchdogTtl,
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.7.1",
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",