@hueyexe/opencode-ensemble 0.7.0 → 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/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
@@ -15675,6 +15675,13 @@ function checkToolIsolation(registry2, tracker, toolName, sessionId) {
15675
15675
  throw new Error("Team tools are not available to sub-agents. Report findings to your parent teammate via your normal output.");
15676
15676
  }
15677
15677
  }
15678
+ function shouldNudgeIdleMember(db, teamId, memberName) {
15679
+ const member = db.query("SELECT status FROM team_member WHERE team_id = ? AND name = ?").get(teamId, memberName);
15680
+ if (!member || member.status !== "ready")
15681
+ return false;
15682
+ const msg = db.query("SELECT id FROM team_message WHERE team_id = ? AND from_name = ? AND (to_name = 'lead' OR to_name IS NULL) LIMIT 1").get(teamId, memberName);
15683
+ return !msg;
15684
+ }
15678
15685
 
15679
15686
  // src/notify.ts
15680
15687
  var TOAST_CONFIG = {
@@ -15735,6 +15742,9 @@ async function notifyWorkingProgress(client3, db, teamId) {
15735
15742
  }
15736
15743
 
15737
15744
  // src/system-prompt.ts
15745
+ function truncate(s, maxLen) {
15746
+ return s.length > maxLen ? `${s.slice(0, maxLen)}...` : s;
15747
+ }
15738
15748
  var STATUS_DISPLAY = {
15739
15749
  busy: "working",
15740
15750
  ready: "idle",
@@ -15763,6 +15773,20 @@ function buildLeadSystemPrompt(db, teamId) {
15763
15773
  teammateLine,
15764
15774
  `Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`
15765
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
+ }
15766
15790
  if (pendingMessages.length > 0) {
15767
15791
  lines.push("", "--- Team Messages ---");
15768
15792
  const MAX_MSG = 500;
@@ -15784,7 +15808,7 @@ function buildTeammateSystemPrompt(db, teamId, memberName) {
15784
15808
  const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId);
15785
15809
  if (!team)
15786
15810
  return "";
15787
- return `You are "${memberName}", a teammate in team "${team.name}". Use team_message to communicate.`;
15811
+ return `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.`;
15788
15812
  }
15789
15813
  function buildTeamCompactionContext(db, teamId, role, memberName) {
15790
15814
  const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId);
@@ -15802,11 +15826,37 @@ function buildTeamCompactionContext(db, teamId, role, memberName) {
15802
15826
  const roleLine = role === "lead" ? `[Team Context] You are the lead of team "${team.name}".` : `[Team Context] You are a teammate named "${memberName}" in team "${team.name}".`;
15803
15827
  const memberList = members.map((m) => `${m.name} (${STATUS_DISPLAY[m.status] ?? m.status})`).join(", ");
15804
15828
  const membersLine = members.length > 0 ? `Members: ${memberList}` : "Members: none";
15805
- return [
15829
+ const lines = [
15806
15830
  roleLine,
15807
15831
  membersLine,
15808
15832
  `Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`
15809
- ].join(`
15833
+ ];
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") {
15848
+ lines.push("IMPORTANT: You MUST send your results to the lead via team_message before stopping.");
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
+ }
15859
+ return lines.join(`
15810
15860
  `);
15811
15861
  }
15812
15862
 
@@ -15843,6 +15893,120 @@ function resolveRecipientSession(db, registry2, teamId, recipientName) {
15843
15893
  return entry?.sessionId;
15844
15894
  }
15845
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
+
15846
16010
  // src/tools/team-create.ts
15847
16011
  async function executeTeamCreate(deps, args, sessionId) {
15848
16012
  const nameError = validateTeamName(args.name);
@@ -15889,6 +16053,7 @@ function requireTeamMember(deps, sessionId) {
15889
16053
  }
15890
16054
 
15891
16055
  // src/tools/team-spawn.ts
16056
+ var spawnFailures = new Map;
15892
16057
  function getSpawnTimeout() {
15893
16058
  return Number(process.env.SPAWN_TIMEOUT_MS) || 120000;
15894
16059
  }
@@ -15909,6 +16074,10 @@ async function executeTeamSpawn(deps, args, sessionId) {
15909
16074
  if (nameError)
15910
16075
  throw new Error(nameError);
15911
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
+ }
15912
16081
  const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.name);
15913
16082
  if (existing)
15914
16083
  throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
@@ -15978,6 +16147,9 @@ async function executeTeamSpawn(deps, args, sessionId) {
15978
16147
  log(`spawn:session:done name=${args.name} sessionId=${childSessionId}`);
15979
16148
  } catch (err) {
15980
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 });
15981
16153
  if (workspaceId) {
15982
16154
  try {
15983
16155
  await deps.client.workspace.remove({ id: workspaceId });
@@ -16084,6 +16256,7 @@ async function executeTeamSpawn(deps, args, sessionId) {
16084
16256
  });
16085
16257
  const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : "";
16086
16258
  const planInfo = usePlanApproval ? " [plan mode \u2014 will send plan for approval]" : "";
16259
+ spawnFailures.delete(teamInfo.teamId);
16087
16260
  log(`spawn:done name=${args.name} sessionId=${childSessionId}`);
16088
16261
  return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`;
16089
16262
  }
@@ -16240,7 +16413,40 @@ Warning: ${memberName} has uncommitted changes in their worktree. Commit or merg
16240
16413
  }
16241
16414
 
16242
16415
  // src/tools/team-cleanup.ts
16243
- 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) {
16244
16450
  const teamInfo = requireLead(deps, sessionId);
16245
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);
16246
16452
  const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
@@ -16277,7 +16483,20 @@ Commit or merge their work first, then call team_cleanup with acknowledge_uncomm
16277
16483
  } catch {}
16278
16484
  }
16279
16485
  }
16280
- 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
+ }
16281
16500
  for (const member of members) {
16282
16501
  if (member.workspace_id) {
16283
16502
  try {
@@ -16291,12 +16510,14 @@ Commit or merge their work first, then call team_cleanup with acknowledge_uncomm
16291
16510
  deps.db.run("UPDATE team_member SET worktree_dir = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name]);
16292
16511
  } catch {}
16293
16512
  }
16294
- if (member.worktree_branch) {
16295
- branches.push(member.worktree_branch);
16296
- }
16297
16513
  }
16298
16514
  deps.db.run("UPDATE team SET status = 'archived', time_updated = ? WHERE id = ?", [Date.now(), teamInfo.teamId]);
16299
16515
  deps.registry.unregisterTeam(teamInfo.teamId);
16516
+ spawnFailures.delete(teamInfo.teamId);
16517
+ if (mergeResult) {
16518
+ return `Team "${teamInfo.teamName}" cleaned up.
16519
+ ${mergeResult}`;
16520
+ }
16300
16521
  if (branches.length > 0) {
16301
16522
  const mergeCommands = branches.map((b) => ` git merge ${b}`).join(`
16302
16523
  `);
@@ -16410,6 +16631,13 @@ ${r.content}
16410
16631
  // src/tools/team-status.ts
16411
16632
  var lastCallTime = new Map;
16412
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
+ }
16413
16641
  async function executeTeamStatus(deps, sessionId) {
16414
16642
  const teamInfo = requireTeamMember(deps, sessionId);
16415
16643
  const now = Date.now();
@@ -16418,7 +16646,7 @@ async function executeTeamStatus(deps, sessionId) {
16418
16646
  return "No changes since last check.";
16419
16647
  }
16420
16648
  lastCallTime.set(teamInfo.teamId, now);
16421
- 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);
16422
16650
  const tasks = deps.db.query("SELECT status FROM team_task WHERE team_id = ?").all(teamInfo.teamId);
16423
16651
  const lines = [];
16424
16652
  lines.push(`Team: ${teamInfo.teamName} (you are the ${teamInfo.role})`);
@@ -16429,9 +16657,17 @@ async function executeTeamStatus(deps, sessionId) {
16429
16657
  lines.push("Members:");
16430
16658
  for (const m of members) {
16431
16659
  const statusIcon = m.status === "busy" ? "working" : m.status === "ready" ? "idle" : m.status;
16660
+ const duration3 = formatDuration(now - m.time_updated);
16432
16661
  const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : "";
16433
16662
  const plan = m.plan_approval !== "none" ? `, plan: ${m.plan_approval}` : "";
16434
- 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
+ }
16435
16671
  if (m.worktree_dir) {
16436
16672
  lines.push(` worktree: ${m.worktree_dir}`);
16437
16673
  }
@@ -16553,6 +16789,10 @@ class Watchdog {
16553
16789
  registry;
16554
16790
  ttlMs;
16555
16791
  checkIntervalMs;
16792
+ progressTracker;
16793
+ stallThresholdMs;
16794
+ stallMinSteps;
16795
+ stallTokenThreshold;
16556
16796
  timer;
16557
16797
  constructor(opts) {
16558
16798
  this.db = opts.db;
@@ -16560,6 +16800,10 @@ class Watchdog {
16560
16800
  this.registry = opts.registry;
16561
16801
  this.ttlMs = opts.ttlMs;
16562
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;
16563
16807
  }
16564
16808
  static STALE_THRESHOLD_MS = Number(process.env.STALE_WORKTREE_THRESHOLD_MS) || 300000;
16565
16809
  async cleanupStaleWorktrees() {
@@ -16581,8 +16825,45 @@ class Watchdog {
16581
16825
  } catch {}
16582
16826
  }
16583
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
+ }
16584
16864
  async check() {
16585
16865
  await this.cleanupStaleWorktrees();
16866
+ await this.checkStalled();
16586
16867
  if (this.ttlMs === 0)
16587
16868
  return;
16588
16869
  const cutoff = Date.now() - this.ttlMs;
@@ -16624,17 +16905,18 @@ class Watchdog {
16624
16905
  }
16625
16906
 
16626
16907
  // src/index.ts
16627
- var DEFAULT_RATE_LIMIT_CAPACITY = 10;
16628
16908
  var DEFAULT_RATE_LIMIT_REFILL = 2;
16629
16909
  var DEFAULT_RATE_LIMIT_INTERVAL_MS = 1000;
16630
- var DEFAULT_WATCHDOG_TTL_MS = 30 * 60 * 1000;
16631
16910
  var DEFAULT_WATCHDOG_CHECK_MS = 60 * 1000;
16632
16911
  var plugin = async (input) => {
16633
16912
  const dbPath = getDbPath();
16634
- mkdirSync(path2.dirname(dbPath), { recursive: true });
16913
+ mkdirSync(path3.dirname(dbPath), { recursive: true });
16635
16914
  const db = createDb(dbPath);
16915
+ const config2 = loadConfig(input.directory);
16636
16916
  const registry2 = new MemberRegistry;
16637
16917
  const tracker = new DescendantTracker;
16918
+ const nudgedMembers = new Set;
16919
+ const progressTracker = new ProgressTracker;
16638
16920
  const pluginTransport = input.client._client;
16639
16921
  const rawClient = new OpencodeClient({ client: pluginTransport });
16640
16922
  initLog(rawClient);
@@ -16660,21 +16942,21 @@ var plugin = async (input) => {
16660
16942
  } else {
16661
16943
  log(`init:skip-recovery (worktree instance: ${input.directory})`);
16662
16944
  }
16663
- const rateLimitEnv = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT;
16664
- const rateLimitCapacity = rateLimitEnv === "0" ? 0 : parseInt(rateLimitEnv ?? "", 10) || DEFAULT_RATE_LIMIT_CAPACITY;
16665
16945
  const rateLimiter = new TokenBucket({
16666
- capacity: rateLimitCapacity,
16946
+ capacity: config2.rateLimitCapacity,
16667
16947
  refillRate: DEFAULT_RATE_LIMIT_REFILL,
16668
16948
  refillIntervalMs: DEFAULT_RATE_LIMIT_INTERVAL_MS
16669
16949
  });
16670
- const timeoutEnv = process.env.OPENCODE_ENSEMBLE_TIMEOUT;
16671
- const watchdogTtl = timeoutEnv === "0" ? 0 : parseInt(timeoutEnv ?? "", 10) || DEFAULT_WATCHDOG_TTL_MS;
16672
16950
  const watchdog = new Watchdog({
16673
16951
  db,
16674
16952
  client: client3,
16675
16953
  registry: registry2,
16676
- ttlMs: watchdogTtl,
16677
- 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
16678
16960
  });
16679
16961
  watchdog.start();
16680
16962
  return {
@@ -16688,6 +16970,15 @@ var plugin = async (input) => {
16688
16970
  notifyTeamEvent(client3, "shutdown", { memberName: transition.memberName });
16689
16971
  } else if (transition.to === "ready" && transition.from === "busy") {
16690
16972
  notifyTeamEvent(client3, "completed", { memberName: transition.memberName });
16973
+ const nudgeKey = `${transition.teamId}:${transition.memberName}`;
16974
+ if (!nudgedMembers.has(nudgeKey) && shouldNudgeIdleMember(db, transition.teamId, transition.memberName)) {
16975
+ nudgedMembers.add(nudgeKey);
16976
+ log(`nudge:idle-without-report name=${transition.memberName}`);
16977
+ client3.session.promptAsync({
16978
+ sessionID,
16979
+ parts: [{ type: "text", text: "[System]: You completed your work but did not report results. Send your findings to the lead via team_message now." }]
16980
+ }).catch(() => {});
16981
+ }
16691
16982
  } else if (transition.to === "error") {
16692
16983
  notifyTeamEvent(client3, "error", { memberName: transition.memberName });
16693
16984
  } else if (transition.to === "retry") {
@@ -16731,6 +17022,14 @@ var plugin = async (input) => {
16731
17022
  handleSessionCreatedEvent(tracker, info.id, info.parentID);
16732
17023
  }
16733
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
+ }
16734
17033
  },
16735
17034
  "tool.execute.before": async (input2, _output) => {
16736
17035
  checkToolIsolation(registry2, tracker, input2.tool, input2.sessionID);
@@ -16816,6 +17115,7 @@ var plugin = async (input) => {
16816
17115
  },
16817
17116
  async execute(args, ctx) {
16818
17117
  const result = await executeTeamMessage(deps, args, ctx.sessionID);
17118
+ progressTracker.recordMessage(ctx.sessionID);
16819
17119
  ctx.metadata({ title: `Message \u2192 ${args.to}` });
16820
17120
  return result;
16821
17121
  }
@@ -16827,6 +17127,7 @@ var plugin = async (input) => {
16827
17127
  },
16828
17128
  async execute(args, ctx) {
16829
17129
  const result = await executeTeamBroadcast(deps, args, ctx.sessionID);
17130
+ progressTracker.recordMessage(ctx.sessionID);
16830
17131
  ctx.metadata({ title: "Broadcast to team" });
16831
17132
  return result;
16832
17133
  }
@@ -16864,6 +17165,7 @@ var plugin = async (input) => {
16864
17165
  },
16865
17166
  async execute(args, ctx) {
16866
17167
  const result = await executeTeamTasksComplete(deps, args, ctx.sessionID);
17168
+ progressTracker.recordTaskComplete(ctx.sessionID);
16867
17169
  ctx.metadata({ title: `Completed task` });
16868
17170
  return result;
16869
17171
  }
@@ -16898,6 +17200,9 @@ var plugin = async (input) => {
16898
17200
  },
16899
17201
  async execute(args, ctx) {
16900
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);
16901
17206
  const hasWarning = result.includes("uncommitted");
16902
17207
  ctx.metadata({ title: hasWarning ? `${args.member} shut down \u2014 uncommitted changes` : `${args.member} shut down` });
16903
17208
  return result;
@@ -16910,7 +17215,7 @@ var plugin = async (input) => {
16910
17215
  acknowledge_uncommitted: tool.schema.boolean().default(false)
16911
17216
  },
16912
17217
  async execute(args, ctx) {
16913
- const result = await executeTeamCleanup(deps, args, ctx.sessionID);
17218
+ const result = await executeTeamCleanup(deps, args, ctx.sessionID, undefined, undefined, config2.mergeOnCleanup);
16914
17219
  const blocked = result.includes("uncommitted");
16915
17220
  ctx.metadata({ title: blocked ? "Cleanup blocked \u2014 uncommitted changes" : "Team cleaned up" });
16916
17221
  return result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.7.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",
package/src/config.ts ADDED
@@ -0,0 +1,75 @@
1
+ import { readFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ /** Plugin configuration shape. All fields optional — defaults applied. */
5
+ export interface EnsembleConfig {
6
+ /** Auto-merge worktree branches on cleanup (default: true) */
7
+ mergeOnCleanup?: boolean
8
+ /** Stall detection threshold in ms (default: 180000 = 3 min, 0 to disable) */
9
+ stallThresholdMs?: number
10
+ /** Min steps before token-based stall check (default: 3) */
11
+ stallMinSteps?: number
12
+ /** Output token threshold for stall detection (default: 500) */
13
+ stallTokenThreshold?: number
14
+ /** Hard timeout for busy members in ms (default: 1800000 = 30 min, 0 to disable) */
15
+ timeoutMs?: number
16
+ /** Rate limit capacity (default: 10, 0 to disable) */
17
+ rateLimitCapacity?: number
18
+ }
19
+
20
+ /** Default configuration values. */
21
+ export const DEFAULT_CONFIG: Required<EnsembleConfig> = {
22
+ mergeOnCleanup: true,
23
+ stallThresholdMs: 180_000,
24
+ stallMinSteps: 3,
25
+ stallTokenThreshold: 500,
26
+ timeoutMs: 30 * 60 * 1000,
27
+ rateLimitCapacity: 10,
28
+ }
29
+
30
+ /** Read a JSON config file, returning an empty object on missing/invalid. */
31
+ function readConfigFile(filePath: string): Partial<EnsembleConfig> {
32
+ try {
33
+ const text = readFileSync(filePath, "utf-8")
34
+ const raw = JSON.parse(text) as Record<string, unknown>
35
+ // Validate types — only accept numbers for numeric fields, booleans for boolean fields
36
+ const result: Partial<EnsembleConfig> = {}
37
+ if (typeof raw.mergeOnCleanup === "boolean") result.mergeOnCleanup = raw.mergeOnCleanup
38
+ if (typeof raw.stallThresholdMs === "number") result.stallThresholdMs = raw.stallThresholdMs
39
+ if (typeof raw.stallMinSteps === "number") result.stallMinSteps = raw.stallMinSteps
40
+ if (typeof raw.stallTokenThreshold === "number") result.stallTokenThreshold = raw.stallTokenThreshold
41
+ if (typeof raw.timeoutMs === "number") result.timeoutMs = raw.timeoutMs
42
+ if (typeof raw.rateLimitCapacity === "number") result.rateLimitCapacity = raw.rateLimitCapacity
43
+ return result
44
+ } catch (err) {
45
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT") return {}
46
+ console.warn(`[ensemble] Invalid config at ${filePath}, using defaults`)
47
+ return {}
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Load plugin configuration. Merges global → project → env vars.
53
+ * Missing files are silently skipped. Invalid JSON logs a warning.
54
+ */
55
+ export function loadConfig(projectDir: string): Required<EnsembleConfig> {
56
+ const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? ""
57
+ const globalPath = path.join(homeDir, ".config", "opencode", "ensemble.json")
58
+ const projectPath = path.join(projectDir, ".opencode", "ensemble.json")
59
+
60
+ const global = readConfigFile(globalPath)
61
+ const project = readConfigFile(projectPath)
62
+ const merged = { ...DEFAULT_CONFIG, ...global, ...project }
63
+
64
+ // Env vars override everything
65
+ const timeout = process.env.OPENCODE_ENSEMBLE_TIMEOUT
66
+ if (timeout !== undefined) merged.timeoutMs = timeout === "0" ? 0 : (parseInt(timeout, 10) || merged.timeoutMs)
67
+
68
+ const rateLimit = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT
69
+ if (rateLimit !== undefined) merged.rateLimitCapacity = rateLimit === "0" ? 0 : (parseInt(rateLimit, 10) || merged.rateLimitCapacity)
70
+
71
+ const stall = process.env.STALL_THRESHOLD_MS
72
+ if (stall !== undefined) merged.stallThresholdMs = stall === "0" ? 0 : (parseInt(stall, 10) || merged.stallThresholdMs)
73
+
74
+ return merged
75
+ }
package/src/hooks.ts CHANGED
@@ -98,3 +98,17 @@ export function checkToolIsolation(
98
98
  throw new Error("Team tools are not available to sub-agents. Report findings to your parent teammate via your normal output.")
99
99
  }
100
100
  }
101
+
102
+ /**
103
+ * Check if a member went idle without ever sending a message to the lead.
104
+ * Returns true if the member is idle/ready and has no outbound messages.
105
+ */
106
+ export function shouldNudgeIdleMember(db: Database, teamId: string, memberName: string): boolean {
107
+ const member = db.query("SELECT status FROM team_member WHERE team_id = ? AND name = ?")
108
+ .get(teamId, memberName) as { status: string } | null
109
+ if (!member || member.status !== "ready") return false
110
+
111
+ const msg = db.query("SELECT id FROM team_message WHERE team_id = ? AND from_name = ? AND (to_name = 'lead' OR to_name IS NULL) LIMIT 1")
112
+ .get(teamId, memberName) as { id: string } | null
113
+ return !msg
114
+ }