@hueyexe/opencode-ensemble 0.7.1 → 0.9.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
@@ -15741,7 +15741,37 @@ async function notifyWorkingProgress(client3, db, teamId) {
15741
15741
  } catch {}
15742
15742
  }
15743
15743
 
15744
+ // src/result-parser.ts
15745
+ function parseTaskResult(content) {
15746
+ const match = content.match(/<task-result>([\s\S]*?)<\/task-result>/);
15747
+ if (!match)
15748
+ return null;
15749
+ const inner = match[1];
15750
+ const status = inner.match(/<status>([\s\S]*?)<\/status>/)?.[1]?.trim();
15751
+ const summary = inner.match(/<summary>([\s\S]*?)<\/summary>/)?.[1]?.trim();
15752
+ const details = inner.match(/<details>([\s\S]*?)<\/details>/)?.[1]?.trim();
15753
+ const branch = inner.match(/<branch>([\s\S]*?)<\/branch>/)?.[1]?.trim();
15754
+ if (!status || !summary || !details)
15755
+ return null;
15756
+ return { status, summary, details, branch: branch || undefined };
15757
+ }
15758
+ function formatTaskResult(from, result) {
15759
+ const lines = [
15760
+ `[Result from ${from}]:`,
15761
+ ` Status: ${result.status}`,
15762
+ ` Summary: ${result.summary}`,
15763
+ ` Details: ${result.details}`
15764
+ ];
15765
+ if (result.branch)
15766
+ lines.push(` Branch: ${result.branch}`);
15767
+ return lines.join(`
15768
+ `);
15769
+ }
15770
+
15744
15771
  // src/system-prompt.ts
15772
+ function truncate(s, maxLen) {
15773
+ return s.length > maxLen ? `${s.slice(0, maxLen)}...` : s;
15774
+ }
15745
15775
  var STATUS_DISPLAY = {
15746
15776
  busy: "working",
15747
15777
  ready: "idle",
@@ -15770,11 +15800,29 @@ function buildLeadSystemPrompt(db, teamId) {
15770
15800
  teammateLine,
15771
15801
  `Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`
15772
15802
  ];
15803
+ 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);
15804
+ 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);
15805
+ if (activeTasks.length > 0) {
15806
+ lines.push("Active tasks:");
15807
+ for (const t of activeTasks) {
15808
+ lines.push(` [in_progress] ${truncate(t.content, 120)}${t.assignee ? ` \u2192 ${t.assignee}` : ""}`);
15809
+ }
15810
+ }
15811
+ if (recentCompleted.length > 0) {
15812
+ lines.push("Recently completed:");
15813
+ for (const t of recentCompleted) {
15814
+ lines.push(` [completed] ${truncate(t.content, 120)}${t.assignee ? ` \u2192 ${t.assignee}` : ""}`);
15815
+ }
15816
+ }
15773
15817
  if (pendingMessages.length > 0) {
15774
15818
  lines.push("", "--- Team Messages ---");
15775
15819
  const MAX_MSG = 500;
15776
15820
  for (const msg of pendingMessages) {
15777
- if (msg.content.length > MAX_MSG) {
15821
+ const parsed = parseTaskResult(msg.content);
15822
+ if (parsed) {
15823
+ const truncatedResult = { ...parsed, details: truncate(parsed.details, 500) };
15824
+ lines.push(formatTaskResult(msg.from_name, truncatedResult));
15825
+ } else if (msg.content.length > MAX_MSG) {
15778
15826
  lines.push(`[From ${msg.from_name}]: ${msg.content.slice(0, MAX_MSG)}... (use team_results to read full message)`);
15779
15827
  } else {
15780
15828
  lines.push(`[From ${msg.from_name}]: ${msg.content}`);
@@ -15814,9 +15862,31 @@ function buildTeamCompactionContext(db, teamId, role, memberName) {
15814
15862
  membersLine,
15815
15863
  `Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`
15816
15864
  ];
15817
- if (role === "member") {
15865
+ if (role === "member" && memberName) {
15866
+ lines.push("IMPORTANT: You MUST send your results to the lead via team_message before stopping.");
15867
+ const member = db.query("SELECT prompt FROM team_member WHERE team_id = ? AND name = ?").get(teamId, memberName);
15868
+ if (member?.prompt) {
15869
+ lines.push(`Your original task: ${truncate(member.prompt, 300)}`);
15870
+ }
15871
+ 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);
15872
+ if (recentMsgs.length > 0) {
15873
+ lines.push("Recent context:");
15874
+ for (const msg of recentMsgs) {
15875
+ lines.push(` [${msg.from_name}]: ${truncate(msg.content, 200)}`);
15876
+ }
15877
+ }
15878
+ } else if (role === "member") {
15818
15879
  lines.push("IMPORTANT: You MUST send your results to the lead via team_message before stopping.");
15819
15880
  }
15881
+ if (role === "lead") {
15882
+ 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);
15883
+ if (completedTasks.length > 0) {
15884
+ lines.push("Recently completed:");
15885
+ for (const t of completedTasks) {
15886
+ lines.push(` [completed] ${truncate(t.content, 120)}${t.assignee ? ` (by ${t.assignee})` : ""}`);
15887
+ }
15888
+ }
15889
+ }
15820
15890
  return lines.join(`
15821
15891
  `);
15822
15892
  }
@@ -15854,6 +15924,120 @@ function resolveRecipientSession(db, registry2, teamId, recipientName) {
15854
15924
  return entry?.sessionId;
15855
15925
  }
15856
15926
 
15927
+ // src/config.ts
15928
+ import { readFileSync } from "fs";
15929
+ import path2 from "path";
15930
+ var DEFAULT_CONFIG = {
15931
+ mergeOnCleanup: true,
15932
+ stallThresholdMs: 180000,
15933
+ stallMinSteps: 3,
15934
+ stallTokenThreshold: 500,
15935
+ timeoutMs: 30 * 60 * 1000,
15936
+ rateLimitCapacity: 10
15937
+ };
15938
+ function readConfigFile(filePath) {
15939
+ try {
15940
+ const text = readFileSync(filePath, "utf-8");
15941
+ const raw = JSON.parse(text);
15942
+ const result = {};
15943
+ if (typeof raw.mergeOnCleanup === "boolean")
15944
+ result.mergeOnCleanup = raw.mergeOnCleanup;
15945
+ if (typeof raw.stallThresholdMs === "number")
15946
+ result.stallThresholdMs = raw.stallThresholdMs;
15947
+ if (typeof raw.stallMinSteps === "number")
15948
+ result.stallMinSteps = raw.stallMinSteps;
15949
+ if (typeof raw.stallTokenThreshold === "number")
15950
+ result.stallTokenThreshold = raw.stallTokenThreshold;
15951
+ if (typeof raw.timeoutMs === "number")
15952
+ result.timeoutMs = raw.timeoutMs;
15953
+ if (typeof raw.rateLimitCapacity === "number")
15954
+ result.rateLimitCapacity = raw.rateLimitCapacity;
15955
+ return result;
15956
+ } catch (err) {
15957
+ if (err && typeof err === "object" && "code" in err && err.code === "ENOENT")
15958
+ return {};
15959
+ console.warn(`[ensemble] Invalid config at ${filePath}, using defaults`);
15960
+ return {};
15961
+ }
15962
+ }
15963
+ function loadConfig(projectDir) {
15964
+ const homeDir = process.env.HOME ?? process.env.USERPROFILE ?? "";
15965
+ const globalPath = path2.join(homeDir, ".config", "opencode", "ensemble.json");
15966
+ const projectPath = path2.join(projectDir, ".opencode", "ensemble.json");
15967
+ const global = readConfigFile(globalPath);
15968
+ const project = readConfigFile(projectPath);
15969
+ const merged = { ...DEFAULT_CONFIG, ...global, ...project };
15970
+ const timeout = process.env.OPENCODE_ENSEMBLE_TIMEOUT;
15971
+ if (timeout !== undefined)
15972
+ merged.timeoutMs = timeout === "0" ? 0 : parseInt(timeout, 10) || merged.timeoutMs;
15973
+ const rateLimit = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT;
15974
+ if (rateLimit !== undefined)
15975
+ merged.rateLimitCapacity = rateLimit === "0" ? 0 : parseInt(rateLimit, 10) || merged.rateLimitCapacity;
15976
+ const stall = process.env.STALL_THRESHOLD_MS;
15977
+ if (stall !== undefined)
15978
+ merged.stallThresholdMs = stall === "0" ? 0 : parseInt(stall, 10) || merged.stallThresholdMs;
15979
+ return merged;
15980
+ }
15981
+
15982
+ // src/progress.ts
15983
+ class ProgressTracker {
15984
+ steps = new Map;
15985
+ lastMessageAt = new Map;
15986
+ lastTaskAt = new Map;
15987
+ reported = new Set;
15988
+ maxSteps;
15989
+ constructor(maxSteps = 10) {
15990
+ this.maxSteps = maxSteps;
15991
+ }
15992
+ recordStep(sessionId, outputTokens) {
15993
+ const records = this.steps.get(sessionId) ?? [];
15994
+ records.push({ outputTokens, timestamp: Date.now() });
15995
+ if (records.length > this.maxSteps)
15996
+ records.shift();
15997
+ this.steps.set(sessionId, records);
15998
+ }
15999
+ recordMessage(sessionId) {
16000
+ this.lastMessageAt.set(sessionId, Date.now());
16001
+ this.clearReport(sessionId);
16002
+ }
16003
+ recordTaskComplete(sessionId) {
16004
+ this.lastTaskAt.set(sessionId, Date.now());
16005
+ this.clearReport(sessionId);
16006
+ }
16007
+ isTokenStalled(sessionId, minSteps, threshold) {
16008
+ const records = this.steps.get(sessionId);
16009
+ if (!records || records.length < minSteps)
16010
+ return false;
16011
+ const recent = records.slice(-minSteps);
16012
+ return recent.every((r) => r.outputTokens < threshold);
16013
+ }
16014
+ isTimeStalled(sessionId, thresholdMs) {
16015
+ const records = this.steps.get(sessionId);
16016
+ if (!records || records.length === 0)
16017
+ return false;
16018
+ const msgAt = this.lastMessageAt.get(sessionId) ?? 0;
16019
+ const taskAt = this.lastTaskAt.get(sessionId) ?? 0;
16020
+ const lastStep = records[records.length - 1]?.timestamp ?? 0;
16021
+ const baseline = Math.max(msgAt, taskAt, lastStep);
16022
+ return Date.now() - baseline >= thresholdMs;
16023
+ }
16024
+ markReported(sessionId) {
16025
+ this.reported.add(sessionId);
16026
+ }
16027
+ isReported(sessionId) {
16028
+ return this.reported.has(sessionId);
16029
+ }
16030
+ clearReport(sessionId) {
16031
+ this.reported.delete(sessionId);
16032
+ }
16033
+ remove(sessionId) {
16034
+ this.steps.delete(sessionId);
16035
+ this.lastMessageAt.delete(sessionId);
16036
+ this.lastTaskAt.delete(sessionId);
16037
+ this.reported.delete(sessionId);
16038
+ }
16039
+ }
16040
+
15857
16041
  // src/tools/team-create.ts
15858
16042
  async function executeTeamCreate(deps, args, sessionId) {
15859
16043
  const nameError = validateTeamName(args.name);
@@ -15900,6 +16084,7 @@ function requireTeamMember(deps, sessionId) {
15900
16084
  }
15901
16085
 
15902
16086
  // src/tools/team-spawn.ts
16087
+ var spawnFailures = new Map;
15903
16088
  function getSpawnTimeout() {
15904
16089
  return Number(process.env.SPAWN_TIMEOUT_MS) || 120000;
15905
16090
  }
@@ -15920,6 +16105,10 @@ async function executeTeamSpawn(deps, args, sessionId) {
15920
16105
  if (nameError)
15921
16106
  throw new Error(nameError);
15922
16107
  const teamInfo = requireLead(deps, sessionId);
16108
+ const failures = spawnFailures.get(teamInfo.teamId);
16109
+ if (failures && failures.count >= 3) {
16110
+ 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.`);
16111
+ }
15923
16112
  const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.name);
15924
16113
  if (existing)
15925
16114
  throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
@@ -15989,6 +16178,9 @@ async function executeTeamSpawn(deps, args, sessionId) {
15989
16178
  log(`spawn:session:done name=${args.name} sessionId=${childSessionId}`);
15990
16179
  } catch (err) {
15991
16180
  log(`spawn:session:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`);
16181
+ const errMsg = err instanceof Error ? err.message : String(err);
16182
+ const prev = spawnFailures.get(teamInfo.teamId);
16183
+ spawnFailures.set(teamInfo.teamId, { count: (prev?.count ?? 0) + 1, lastError: errMsg });
15992
16184
  if (workspaceId) {
15993
16185
  try {
15994
16186
  await deps.client.workspace.remove({ id: workspaceId });
@@ -16023,6 +16215,10 @@ async function executeTeamSpawn(deps, args, sessionId) {
16023
16215
  `You are "${args.name}", a teammate in team "${teamInfo.teamName}".`,
16024
16216
  `Your agent type is "${args.agent}".`
16025
16217
  ];
16218
+ const otherMembers = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name != ? AND status NOT IN ('shutdown', 'error')").all(teamInfo.teamId, args.name);
16219
+ if (otherMembers.length > 0) {
16220
+ context.push(`Other teammates: ${otherMembers.map((m) => m.name).join(", ")}`);
16221
+ }
16026
16222
  if (worktreeBranch && worktreeDir && !workspaceId) {
16027
16223
  context.push(`You are working on branch "${worktreeBranch}" in your own worktree at: ${worktreeDir}`, `Your changes are isolated from other teammates.`, `IMPORTANT: All file operations and shell commands MUST target your worktree directory.`, `Before running shell commands, cd to: ${worktreeDir}`);
16028
16224
  } else if (worktreeBranch && worktreeDir) {
@@ -16055,7 +16251,7 @@ async function executeTeamSpawn(deps, args, sessionId) {
16055
16251
  }
16056
16252
  context.push("</task-result>");
16057
16253
  const lastStep = !isReadOnly && worktreeBranch ? "4" : !isReadOnly ? "3" : "2";
16058
- context.push(`${lastStep}. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.`, "", "If you are blocked, send ONE message to the lead describing the specific blocker.", "", "Your plain text output is NOT visible to the team. You MUST use team_message to communicate.");
16254
+ context.push(`${lastStep}. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.`, "", "If you are blocked:", "- Send ONE message to the lead via team_message describing the specific blocker.", "- Do NOT attempt workarounds or make assumptions. Wait for the lead's response.", "", "Your plain text output is NOT visible to the team. You MUST use team_message to communicate.");
16059
16255
  context.push("", "Your task:", args.prompt);
16060
16256
  if (args.claim_task) {
16061
16257
  context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`);
@@ -16095,6 +16291,7 @@ async function executeTeamSpawn(deps, args, sessionId) {
16095
16291
  });
16096
16292
  const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : "";
16097
16293
  const planInfo = usePlanApproval ? " [plan mode \u2014 will send plan for approval]" : "";
16294
+ spawnFailures.delete(teamInfo.teamId);
16098
16295
  log(`spawn:done name=${args.name} sessionId=${childSessionId}`);
16099
16296
  return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`;
16100
16297
  }
@@ -16251,7 +16448,40 @@ Warning: ${memberName} has uncommitted changes in their worktree. Commit or merg
16251
16448
  }
16252
16449
 
16253
16450
  // src/tools/team-cleanup.ts
16254
- async function executeTeamCleanup(deps, args, sessionId, isDirty = checkWorktreeDirty) {
16451
+ async function mergeAllBranches(branches, cwd) {
16452
+ const merged = [];
16453
+ const conflicted = [];
16454
+ const stash = Bun.spawn(["git", "stash", "--include-untracked"], { cwd, stdout: "pipe", stderr: "pipe" });
16455
+ const stashOut = await new Response(stash.stdout).text();
16456
+ const stashExit = await stash.exited;
16457
+ const didStash = stashExit === 0 && !stashOut.includes("No local changes");
16458
+ for (const branch of branches) {
16459
+ const merge2 = Bun.spawn(["git", "merge", "--squash", branch], { cwd, stdout: "pipe", stderr: "pipe" });
16460
+ const stderrPromise = new Response(merge2.stderr).text();
16461
+ const exit = await merge2.exited;
16462
+ if (exit !== 0) {
16463
+ const stderr = await stderrPromise;
16464
+ log(`cleanup:merge:conflict branch=${branch} err=${stderr.trim()}`);
16465
+ const abort = Bun.spawn(["git", "merge", "--abort"], { cwd, stdout: "pipe", stderr: "pipe" });
16466
+ await abort.exited;
16467
+ conflicted.push(branch);
16468
+ } else {
16469
+ const del = Bun.spawn(["git", "branch", "-D", branch], { cwd, stdout: "pipe", stderr: "pipe" });
16470
+ await del.exited;
16471
+ merged.push(branch);
16472
+ }
16473
+ }
16474
+ if (merged.length > 0) {
16475
+ const reset = Bun.spawn(["git", "reset", "HEAD"], { cwd, stdout: "pipe", stderr: "pipe" });
16476
+ await reset.exited;
16477
+ }
16478
+ if (didStash) {
16479
+ const pop = Bun.spawn(["git", "stash", "pop", "--index"], { cwd, stdout: "pipe", stderr: "pipe" });
16480
+ await pop.exited;
16481
+ }
16482
+ return { merged, conflicted };
16483
+ }
16484
+ async function executeTeamCleanup(deps, args, sessionId, isDirty = checkWorktreeDirty, mergeAll = mergeAllBranches, mergeOnCleanup = true) {
16255
16485
  const teamInfo = requireLead(deps, sessionId);
16256
16486
  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
16487
  const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
@@ -16288,7 +16518,20 @@ Commit or merge their work first, then call team_cleanup with acknowledge_uncomm
16288
16518
  } catch {}
16289
16519
  }
16290
16520
  }
16291
- const branches = [];
16521
+ const branches = members.map((m) => m.worktree_branch).filter((b) => b !== null);
16522
+ let mergeResult = "";
16523
+ if (branches.length > 0 && mergeOnCleanup) {
16524
+ const { merged, conflicted } = await mergeAll(branches, deps.directory);
16525
+ const parts = [];
16526
+ if (merged.length > 0) {
16527
+ parts.push(`Merged ${merged.length} branch(es) into working directory (unstaged). Review changes with: git diff`);
16528
+ }
16529
+ if (conflicted.length > 0) {
16530
+ parts.push(`Could not auto-merge: ${conflicted.join(", ")}. Merge manually with: git merge <branch>`);
16531
+ }
16532
+ mergeResult = parts.join(`
16533
+ `);
16534
+ }
16292
16535
  for (const member of members) {
16293
16536
  if (member.workspace_id) {
16294
16537
  try {
@@ -16302,12 +16545,14 @@ Commit or merge their work first, then call team_cleanup with acknowledge_uncomm
16302
16545
  deps.db.run("UPDATE team_member SET worktree_dir = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name]);
16303
16546
  } catch {}
16304
16547
  }
16305
- if (member.worktree_branch) {
16306
- branches.push(member.worktree_branch);
16307
- }
16308
16548
  }
16309
16549
  deps.db.run("UPDATE team SET status = 'archived', time_updated = ? WHERE id = ?", [Date.now(), teamInfo.teamId]);
16310
16550
  deps.registry.unregisterTeam(teamInfo.teamId);
16551
+ spawnFailures.delete(teamInfo.teamId);
16552
+ if (mergeResult) {
16553
+ return `Team "${teamInfo.teamName}" cleaned up.
16554
+ ${mergeResult}`;
16555
+ }
16311
16556
  if (branches.length > 0) {
16312
16557
  const mergeCommands = branches.map((b) => ` git merge ${b}`).join(`
16313
16558
  `);
@@ -16412,15 +16657,27 @@ async function executeTeamResults(deps, args, sessionId) {
16412
16657
  const ids = rows.map((r) => r.id);
16413
16658
  const placeholders = ids.map(() => "?").join(", ");
16414
16659
  deps.db.run(`UPDATE team_message SET read = 1 WHERE id IN (${placeholders})`, ids);
16415
- return rows.map((r) => `[Message from ${r.from_name}]:
16416
- ${r.content}
16417
- `).join(`
16660
+ return rows.map((r) => {
16661
+ const parsed = parseTaskResult(r.content);
16662
+ if (parsed)
16663
+ return formatTaskResult(r.from_name, parsed);
16664
+ return `[Message from ${r.from_name}]:
16665
+ ${r.content}`;
16666
+ }).join(`
16667
+
16418
16668
  `);
16419
16669
  }
16420
16670
 
16421
16671
  // src/tools/team-status.ts
16422
16672
  var lastCallTime = new Map;
16423
16673
  var RATE_LIMIT_MS = 30000;
16674
+ function formatDuration(ms) {
16675
+ if (ms < 60000)
16676
+ return `${Math.floor(ms / 1000)}s`;
16677
+ if (ms < 3600000)
16678
+ return `${Math.floor(ms / 60000)}m`;
16679
+ return `${Math.floor(ms / 3600000)}h`;
16680
+ }
16424
16681
  async function executeTeamStatus(deps, sessionId) {
16425
16682
  const teamInfo = requireTeamMember(deps, sessionId);
16426
16683
  const now = Date.now();
@@ -16429,7 +16686,7 @@ async function executeTeamStatus(deps, sessionId) {
16429
16686
  return "No changes since last check.";
16430
16687
  }
16431
16688
  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);
16689
+ 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
16690
  const tasks = deps.db.query("SELECT status FROM team_task WHERE team_id = ?").all(teamInfo.teamId);
16434
16691
  const lines = [];
16435
16692
  lines.push(`Team: ${teamInfo.teamName} (you are the ${teamInfo.role})`);
@@ -16440,9 +16697,17 @@ async function executeTeamStatus(deps, sessionId) {
16440
16697
  lines.push("Members:");
16441
16698
  for (const m of members) {
16442
16699
  const statusIcon = m.status === "busy" ? "working" : m.status === "ready" ? "idle" : m.status;
16700
+ const duration3 = formatDuration(now - m.time_updated);
16443
16701
  const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : "";
16444
16702
  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}`);
16703
+ 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);
16704
+ const msgInfo = lastMsg?.last_msg ? `last msg: ${formatDuration(now - lastMsg.last_msg)} ago` : "no messages yet";
16705
+ lines.push(` ${m.name} [${statusIcon} ${duration3}, ${msgInfo}${plan}] agent: ${m.agent}${branch}`);
16706
+ 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);
16707
+ if (task) {
16708
+ const truncated = task.content.length > 80 ? `${task.content.slice(0, 80)}...` : task.content;
16709
+ lines.push(` task: ${truncated}`);
16710
+ }
16446
16711
  if (m.worktree_dir) {
16447
16712
  lines.push(` worktree: ${m.worktree_dir}`);
16448
16713
  }
@@ -16564,6 +16829,10 @@ class Watchdog {
16564
16829
  registry;
16565
16830
  ttlMs;
16566
16831
  checkIntervalMs;
16832
+ progressTracker;
16833
+ stallThresholdMs;
16834
+ stallMinSteps;
16835
+ stallTokenThreshold;
16567
16836
  timer;
16568
16837
  constructor(opts) {
16569
16838
  this.db = opts.db;
@@ -16571,6 +16840,10 @@ class Watchdog {
16571
16840
  this.registry = opts.registry;
16572
16841
  this.ttlMs = opts.ttlMs;
16573
16842
  this.checkIntervalMs = opts.checkIntervalMs ?? 60000;
16843
+ this.progressTracker = opts.progressTracker;
16844
+ this.stallThresholdMs = opts.stallThresholdMs ?? 0;
16845
+ this.stallMinSteps = opts.stallMinSteps ?? 3;
16846
+ this.stallTokenThreshold = opts.stallTokenThreshold ?? 500;
16574
16847
  }
16575
16848
  static STALE_THRESHOLD_MS = Number(process.env.STALE_WORKTREE_THRESHOLD_MS) || 300000;
16576
16849
  async cleanupStaleWorktrees() {
@@ -16592,8 +16865,45 @@ class Watchdog {
16592
16865
  } catch {}
16593
16866
  }
16594
16867
  }
16868
+ async checkStalled() {
16869
+ if (!this.progressTracker || this.stallThresholdMs === 0)
16870
+ return;
16871
+ const busy = this.db.query(`SELECT tm.team_id, tm.name, tm.session_id
16872
+ FROM team_member tm
16873
+ JOIN team t ON tm.team_id = t.id
16874
+ WHERE t.status = 'active' AND tm.status = 'busy'`).all();
16875
+ for (const member of busy) {
16876
+ if (this.progressTracker.isReported(member.session_id))
16877
+ continue;
16878
+ const tokenStalled = this.progressTracker.isTokenStalled(member.session_id, this.stallMinSteps, this.stallTokenThreshold);
16879
+ const timeStalled = this.progressTracker.isTimeStalled(member.session_id, this.stallThresholdMs);
16880
+ if (!tokenStalled && !timeStalled)
16881
+ continue;
16882
+ this.progressTracker.markReported(member.session_id);
16883
+ const reason = tokenStalled ? "low output tokens" : "no communication";
16884
+ this.client.session.promptAsync({
16885
+ sessionID: member.session_id,
16886
+ 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." }]
16887
+ }).catch(() => {});
16888
+ sendMessage(this.db, {
16889
+ teamId: member.team_id,
16890
+ from: "system",
16891
+ to: "lead",
16892
+ content: `Teammate "${member.name}" appears stalled (${reason}). Consider checking on them via team_message or shutting them down.`
16893
+ });
16894
+ try {
16895
+ await this.client.tui.showToast({
16896
+ title: "Team",
16897
+ message: `${member.name} appears stalled`,
16898
+ variant: "warning",
16899
+ duration: 5000
16900
+ });
16901
+ } catch {}
16902
+ }
16903
+ }
16595
16904
  async check() {
16596
16905
  await this.cleanupStaleWorktrees();
16906
+ await this.checkStalled();
16597
16907
  if (this.ttlMs === 0)
16598
16908
  return;
16599
16909
  const cutoff = Date.now() - this.ttlMs;
@@ -16635,18 +16945,18 @@ class Watchdog {
16635
16945
  }
16636
16946
 
16637
16947
  // src/index.ts
16638
- var DEFAULT_RATE_LIMIT_CAPACITY = 10;
16639
16948
  var DEFAULT_RATE_LIMIT_REFILL = 2;
16640
16949
  var DEFAULT_RATE_LIMIT_INTERVAL_MS = 1000;
16641
- var DEFAULT_WATCHDOG_TTL_MS = 30 * 60 * 1000;
16642
16950
  var DEFAULT_WATCHDOG_CHECK_MS = 60 * 1000;
16643
16951
  var plugin = async (input) => {
16644
16952
  const dbPath = getDbPath();
16645
- mkdirSync(path2.dirname(dbPath), { recursive: true });
16953
+ mkdirSync(path3.dirname(dbPath), { recursive: true });
16646
16954
  const db = createDb(dbPath);
16955
+ const config2 = loadConfig(input.directory);
16647
16956
  const registry2 = new MemberRegistry;
16648
16957
  const tracker = new DescendantTracker;
16649
16958
  const nudgedMembers = new Set;
16959
+ const progressTracker = new ProgressTracker;
16650
16960
  const pluginTransport = input.client._client;
16651
16961
  const rawClient = new OpencodeClient({ client: pluginTransport });
16652
16962
  initLog(rawClient);
@@ -16672,21 +16982,21 @@ var plugin = async (input) => {
16672
16982
  } else {
16673
16983
  log(`init:skip-recovery (worktree instance: ${input.directory})`);
16674
16984
  }
16675
- const rateLimitEnv = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT;
16676
- const rateLimitCapacity = rateLimitEnv === "0" ? 0 : parseInt(rateLimitEnv ?? "", 10) || DEFAULT_RATE_LIMIT_CAPACITY;
16677
16985
  const rateLimiter = new TokenBucket({
16678
- capacity: rateLimitCapacity,
16986
+ capacity: config2.rateLimitCapacity,
16679
16987
  refillRate: DEFAULT_RATE_LIMIT_REFILL,
16680
16988
  refillIntervalMs: DEFAULT_RATE_LIMIT_INTERVAL_MS
16681
16989
  });
16682
- const timeoutEnv = process.env.OPENCODE_ENSEMBLE_TIMEOUT;
16683
- const watchdogTtl = timeoutEnv === "0" ? 0 : parseInt(timeoutEnv ?? "", 10) || DEFAULT_WATCHDOG_TTL_MS;
16684
16990
  const watchdog = new Watchdog({
16685
16991
  db,
16686
16992
  client: client3,
16687
16993
  registry: registry2,
16688
- ttlMs: watchdogTtl,
16689
- checkIntervalMs: DEFAULT_WATCHDOG_CHECK_MS
16994
+ ttlMs: config2.timeoutMs,
16995
+ checkIntervalMs: DEFAULT_WATCHDOG_CHECK_MS,
16996
+ progressTracker,
16997
+ stallThresholdMs: config2.stallThresholdMs,
16998
+ stallMinSteps: config2.stallMinSteps,
16999
+ stallTokenThreshold: config2.stallTokenThreshold
16690
17000
  });
16691
17001
  watchdog.start();
16692
17002
  return {
@@ -16752,6 +17062,14 @@ var plugin = async (input) => {
16752
17062
  handleSessionCreatedEvent(tracker, info.id, info.parentID);
16753
17063
  }
16754
17064
  }
17065
+ if (event.type === "message.part.updated") {
17066
+ const part = event.properties.part;
17067
+ if (part?.type === "step-finish" && part.sessionID && part.tokens?.output !== undefined) {
17068
+ if (registry2.getBySession(part.sessionID)) {
17069
+ progressTracker.recordStep(part.sessionID, part.tokens.output);
17070
+ }
17071
+ }
17072
+ }
16755
17073
  },
16756
17074
  "tool.execute.before": async (input2, _output) => {
16757
17075
  checkToolIsolation(registry2, tracker, input2.tool, input2.sessionID);
@@ -16837,6 +17155,7 @@ var plugin = async (input) => {
16837
17155
  },
16838
17156
  async execute(args, ctx) {
16839
17157
  const result = await executeTeamMessage(deps, args, ctx.sessionID);
17158
+ progressTracker.recordMessage(ctx.sessionID);
16840
17159
  ctx.metadata({ title: `Message \u2192 ${args.to}` });
16841
17160
  return result;
16842
17161
  }
@@ -16848,6 +17167,7 @@ var plugin = async (input) => {
16848
17167
  },
16849
17168
  async execute(args, ctx) {
16850
17169
  const result = await executeTeamBroadcast(deps, args, ctx.sessionID);
17170
+ progressTracker.recordMessage(ctx.sessionID);
16851
17171
  ctx.metadata({ title: "Broadcast to team" });
16852
17172
  return result;
16853
17173
  }
@@ -16885,6 +17205,7 @@ var plugin = async (input) => {
16885
17205
  },
16886
17206
  async execute(args, ctx) {
16887
17207
  const result = await executeTeamTasksComplete(deps, args, ctx.sessionID);
17208
+ progressTracker.recordTaskComplete(ctx.sessionID);
16888
17209
  ctx.metadata({ title: `Completed task` });
16889
17210
  return result;
16890
17211
  }
@@ -16919,6 +17240,9 @@ var plugin = async (input) => {
16919
17240
  },
16920
17241
  async execute(args, ctx) {
16921
17242
  const result = await executeTeamShutdown(deps, args, ctx.sessionID);
17243
+ const member = deps.db.query("SELECT session_id FROM team_member WHERE name = ? AND status IN ('shutdown', 'shutdown_requested')").get(args.member);
17244
+ if (member)
17245
+ progressTracker.remove(member.session_id);
16922
17246
  const hasWarning = result.includes("uncommitted");
16923
17247
  ctx.metadata({ title: hasWarning ? `${args.member} shut down \u2014 uncommitted changes` : `${args.member} shut down` });
16924
17248
  return result;
@@ -16931,7 +17255,7 @@ var plugin = async (input) => {
16931
17255
  acknowledge_uncommitted: tool.schema.boolean().default(false)
16932
17256
  },
16933
17257
  async execute(args, ctx) {
16934
- const result = await executeTeamCleanup(deps, args, ctx.sessionID);
17258
+ const result = await executeTeamCleanup(deps, args, ctx.sessionID, undefined, undefined, config2.mergeOnCleanup);
16935
17259
  const blocked = result.includes("uncommitted");
16936
17260
  ctx.metadata({ title: blocked ? "Cleanup blocked \u2014 uncommitted changes" : "Team cleaned up" });
16937
17261
  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.9.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",