@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/README.md +80 -15
- package/dist/index.js +349 -25
- package/package.json +1 -1
- package/src/config.ts +75 -0
- package/src/index.ts +34 -13
- package/src/progress.ts +85 -0
- package/src/result-parser.ts +40 -0
- package/src/system-prompt.ts +68 -2
- package/src/tools/team-cleanup.ts +83 -6
- package/src/tools/team-results.ts +7 -2
- package/src/tools/team-spawn.ts +26 -1
- package/src/tools/team-status.ts +25 -3
- package/src/watchdog.ts +67 -0
package/dist/index.js
CHANGED
|
@@ -15287,7 +15287,7 @@ class OpencodeClient extends HeyApiClient {
|
|
|
15287
15287
|
}
|
|
15288
15288
|
}
|
|
15289
15289
|
// src/index.ts
|
|
15290
|
-
import
|
|
15290
|
+
import path3 from "path";
|
|
15291
15291
|
import { mkdirSync } from "fs";
|
|
15292
15292
|
|
|
15293
15293
|
// src/db.ts
|
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
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) =>
|
|
16416
|
-
|
|
16417
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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.
|
|
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",
|