@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/README.md +66 -11
- package/dist/index.js +327 -22
- package/package.json +1 -1
- package/src/config.ts +75 -0
- package/src/hooks.ts +14 -0
- package/src/index.ts +46 -14
- package/src/progress.ts +85 -0
- package/src/system-prompt.ts +69 -3
- package/src/tools/team-cleanup.ts +83 -6
- package/src/tools/team-spawn.ts +15 -0
- package/src/tools/team-status.ts +25 -3
- package/src/watchdog.ts +67 -0
package/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
|
|
@@ -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
|
-
|
|
15829
|
+
const lines = [
|
|
15806
15830
|
roleLine,
|
|
15807
15831
|
membersLine,
|
|
15808
15832
|
`Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`
|
|
15809
|
-
]
|
|
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
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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.
|
|
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
|
+
}
|