@hueyexe/opencode-ensemble 0.6.5 → 0.7.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 +24 -2
- package/dist/index.js +152 -62
- package/package.json +1 -1
- package/src/index.ts +5 -2
- package/src/system-prompt.ts +4 -0
- package/src/tools/shared.ts +39 -0
- package/src/tools/team-broadcast.ts +2 -3
- package/src/tools/team-claim.ts +2 -3
- package/src/tools/team-cleanup.ts +31 -8
- package/src/tools/team-message.ts +3 -3
- package/src/tools/team-results.ts +2 -3
- package/src/tools/team-shutdown.ts +23 -8
- package/src/tools/team-spawn.ts +80 -28
- package/src/tools/team-status.ts +7 -5
- package/src/tools/team-tasks-add.ts +2 -3
- package/src/tools/team-tasks-complete.ts +2 -3
- package/src/tools/team-tasks-list.ts +2 -3
- package/src/tools/team-view.ts +2 -3
- package/src/watchdog.ts +31 -2
package/README.md
CHANGED
|
@@ -79,7 +79,11 @@ Lead: "All validation and tests are complete. 5 endpoints validated,
|
|
|
79
79
|
|
|
80
80
|
## Install
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
Two steps: add the plugin, then allowlist worktree paths.
|
|
83
|
+
|
|
84
|
+
### 1. Add the plugin
|
|
85
|
+
|
|
86
|
+
Add to your OpenCode config. Project-level or global.
|
|
83
87
|
|
|
84
88
|
**Project-level** (`opencode.json` in your project root):
|
|
85
89
|
|
|
@@ -99,6 +103,24 @@ Add the plugin to your OpenCode config. Project-level or global.
|
|
|
99
103
|
|
|
100
104
|
OpenCode auto-installs npm plugins at startup (cached in `~/.cache/opencode/node_modules/`). No `npm install` needed.
|
|
101
105
|
|
|
106
|
+
### 2. Allow worktree directory access
|
|
107
|
+
|
|
108
|
+
Teammates work in git worktrees outside your project directory. Without this permission, OpenCode will prompt you to approve every file operation in a teammate's worktree.
|
|
109
|
+
|
|
110
|
+
Add to your OpenCode config (`~/.config/opencode/opencode.json`):
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"permission": {
|
|
115
|
+
"external_directory": {
|
|
116
|
+
"~/.local/share/opencode/worktree/**": "allow"
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
This is required. Without it, you'll see "Permission required — Access external directory" prompts constantly.
|
|
123
|
+
|
|
102
124
|
### Local development
|
|
103
125
|
|
|
104
126
|
For working on the plugin itself, create `.opencode/plugins/ensemble.ts` in your project:
|
|
@@ -221,7 +243,7 @@ Same coordination model (shared tasks, peer messaging, lead coordination) with s
|
|
|
221
243
|
```bash
|
|
222
244
|
bun install
|
|
223
245
|
bun run typecheck
|
|
224
|
-
bun test #
|
|
246
|
+
bun test # 370 tests
|
|
225
247
|
bun run build
|
|
226
248
|
```
|
|
227
249
|
|
package/dist/index.js
CHANGED
|
@@ -15776,7 +15776,7 @@ function buildLeadSystemPrompt(db, teamId) {
|
|
|
15776
15776
|
}
|
|
15777
15777
|
lines.push("--- End Messages ---");
|
|
15778
15778
|
}
|
|
15779
|
-
lines.push("", "CRITICAL: Spawn teammates ONE AT A TIME. Send only ONE team_spawn call per response.", "Wait for the tool result before spawning the next teammate.", "Multiple team_spawn calls in a single response will cause timeouts.", "", "Teammates work asynchronously and message you when done.", "Do NOT poll team_status or team_tasks_list repeatedly \u2014 wait for messages.", "After spawning all teammates, tell the user what you've set up and wait.", "When all teammates finish, summarize results and suggest next steps.");
|
|
15779
|
+
lines.push("", "CRITICAL: Spawn teammates ONE AT A TIME. Send only ONE team_spawn call per response.", "Wait for the tool result before spawning the next teammate.", "Multiple team_spawn calls in a single response will cause timeouts.", "", "Teammates work asynchronously and message you when done.", "Do NOT poll team_status or team_tasks_list repeatedly \u2014 wait for messages.", "After spawning all teammates, tell the user what you've set up and wait.", "When all teammates finish, summarize results and suggest next steps.", "", "Before calling team_cleanup, verify teammates have committed their work.", "team_shutdown will warn you if a teammate has uncommitted changes.", "team_cleanup will block if any worktree has uncommitted changes \u2014 merge or commit first.");
|
|
15780
15780
|
return lines.join(`
|
|
15781
15781
|
`);
|
|
15782
15782
|
}
|
|
@@ -15860,10 +15860,41 @@ async function executeTeamCreate(deps, args, sessionId) {
|
|
|
15860
15860
|
return `Team "${args.name}" created. You are the lead. Use team_spawn to add teammates.`;
|
|
15861
15861
|
}
|
|
15862
15862
|
|
|
15863
|
+
// src/tools/shared.ts
|
|
15864
|
+
async function checkWorktreeDirty(dir) {
|
|
15865
|
+
try {
|
|
15866
|
+
const proc = Bun.spawn(["git", "-C", dir, "status", "--porcelain"], { stdout: "pipe", stderr: "pipe" });
|
|
15867
|
+
const output = await new Response(proc.stdout).text();
|
|
15868
|
+
const exitCode = await proc.exited;
|
|
15869
|
+
if (exitCode !== 0)
|
|
15870
|
+
return false;
|
|
15871
|
+
return output.trim().length > 0;
|
|
15872
|
+
} catch {
|
|
15873
|
+
return false;
|
|
15874
|
+
}
|
|
15875
|
+
}
|
|
15876
|
+
function requireLead(deps, sessionId) {
|
|
15877
|
+
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
|
|
15878
|
+
if (!teamInfo)
|
|
15879
|
+
throw new Error("This session is not in a team. Use team_create first.");
|
|
15880
|
+
if (teamInfo.role !== "lead")
|
|
15881
|
+
throw new Error("Only the team lead can use this tool.");
|
|
15882
|
+
return { teamId: teamInfo.teamId, teamName: teamInfo.teamName };
|
|
15883
|
+
}
|
|
15884
|
+
function requireTeamMember(deps, sessionId) {
|
|
15885
|
+
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
|
|
15886
|
+
if (!teamInfo)
|
|
15887
|
+
throw new Error("This session is not in a team.");
|
|
15888
|
+
return teamInfo;
|
|
15889
|
+
}
|
|
15890
|
+
|
|
15863
15891
|
// src/tools/team-spawn.ts
|
|
15864
15892
|
function getSpawnTimeout() {
|
|
15865
15893
|
return Number(process.env.SPAWN_TIMEOUT_MS) || 120000;
|
|
15866
15894
|
}
|
|
15895
|
+
function isWorktreeDirectory(dir) {
|
|
15896
|
+
return dir.includes("/opencode/worktree/");
|
|
15897
|
+
}
|
|
15867
15898
|
function withTimeout(promise2, ms, label) {
|
|
15868
15899
|
let timer;
|
|
15869
15900
|
return Promise.race([
|
|
@@ -15877,15 +15908,11 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15877
15908
|
const nameError = validateMemberName(args.name);
|
|
15878
15909
|
if (nameError)
|
|
15879
15910
|
throw new Error(nameError);
|
|
15880
|
-
const teamInfo =
|
|
15881
|
-
if (!teamInfo)
|
|
15882
|
-
throw new Error("This session is not in a team. Use team_create first.");
|
|
15883
|
-
if (teamInfo.role !== "lead")
|
|
15884
|
-
throw new Error("Only the team lead can spawn teammates.");
|
|
15911
|
+
const teamInfo = requireLead(deps, sessionId);
|
|
15885
15912
|
const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.name);
|
|
15886
15913
|
if (existing)
|
|
15887
15914
|
throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
|
|
15888
|
-
const useWorktree = args.worktree !== false;
|
|
15915
|
+
const useWorktree = args.worktree !== false && !isWorktreeDirectory(deps.directory);
|
|
15889
15916
|
const usePlanApproval = args.plan_approval === true;
|
|
15890
15917
|
log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`);
|
|
15891
15918
|
let worktreeDir = null;
|
|
@@ -15927,18 +15954,24 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15927
15954
|
}
|
|
15928
15955
|
const isReadOnly = args.agent === "plan" || args.agent === "explore";
|
|
15929
15956
|
const TEAM_TOOLS = ["team_message", "team_broadcast", "team_tasks_list", "team_tasks_add", "team_tasks_complete", "team_claim"];
|
|
15930
|
-
const permission =
|
|
15931
|
-
|
|
15932
|
-
{ permission: "
|
|
15933
|
-
|
|
15934
|
-
|
|
15957
|
+
const permission = [];
|
|
15958
|
+
if (worktreeDir) {
|
|
15959
|
+
permission.push({ permission: "edit", pattern: `${worktreeDir}/**`, action: "allow" });
|
|
15960
|
+
if (!isReadOnly) {
|
|
15961
|
+
permission.push({ permission: "bash", pattern: "*", action: "allow" });
|
|
15962
|
+
}
|
|
15963
|
+
}
|
|
15964
|
+
if (isReadOnly) {
|
|
15965
|
+
permission.push({ permission: "edit", pattern: "*", action: "deny" }, { permission: "bash", pattern: "*", action: "deny" });
|
|
15966
|
+
}
|
|
15967
|
+
permission.push(...TEAM_TOOLS.map((t) => ({ permission: t, pattern: "*", action: "allow" })));
|
|
15935
15968
|
let childSessionId;
|
|
15936
15969
|
try {
|
|
15937
15970
|
log(`spawn:session:start name=${args.name}`);
|
|
15938
15971
|
const createResult = await withTimeout(deps.client.session.create({
|
|
15939
15972
|
parentID: sessionId,
|
|
15940
15973
|
title: `${args.name} (@${args.agent} teammate)`,
|
|
15941
|
-
|
|
15974
|
+
permission,
|
|
15942
15975
|
...workspaceId ? { workspaceID: workspaceId } : {}
|
|
15943
15976
|
}), getSpawnTimeout(), `session.create for "${args.name}"`);
|
|
15944
15977
|
childSessionId = createResult.data?.id;
|
|
@@ -15989,7 +16022,29 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15989
16022
|
if (usePlanApproval) {
|
|
15990
16023
|
context.push("", "IMPORTANT: You are in PLAN MODE.", "Read and explore the codebase, then send your implementation plan to the lead via team_message.", "Do NOT write or modify any files until the lead approves your plan.", "Wait for the lead's approval message before proceeding with implementation.");
|
|
15991
16024
|
}
|
|
15992
|
-
|
|
16025
|
+
if (isReadOnly) {
|
|
16026
|
+
context.push("", "Tools available to you:", "- team_message: send a message to the lead or another teammate", "- team_broadcast: send a message to all team members", "- team_tasks_list: view the shared team task board");
|
|
16027
|
+
} else {
|
|
16028
|
+
context.push("", "Tools available to you:", "- team_message: send a message to the lead or another teammate", "- team_broadcast: send a message to all team members", "- team_tasks_list: view the shared team task board", "- team_tasks_add: add tasks to the shared board", "- team_tasks_complete: mark a task complete on the shared board", "- team_claim: claim a pending task from the shared board");
|
|
16029
|
+
}
|
|
16030
|
+
context.push("", "When you finish your task:");
|
|
16031
|
+
if (!isReadOnly && worktreeBranch) {
|
|
16032
|
+
context.push(`1. Commit your changes: git add -A && git commit -m "your summary"`);
|
|
16033
|
+
context.push("2. If you claimed a task, mark it complete using team_tasks_complete.");
|
|
16034
|
+
context.push("3. Send ONE message to the lead using team_message with this format:");
|
|
16035
|
+
} else if (!isReadOnly) {
|
|
16036
|
+
context.push("1. If you claimed a task, mark it complete using team_tasks_complete.");
|
|
16037
|
+
context.push("2. Send ONE message to the lead using team_message with this format:");
|
|
16038
|
+
} else {
|
|
16039
|
+
context.push("1. Send ONE message to the lead using team_message with this format:");
|
|
16040
|
+
}
|
|
16041
|
+
context.push("<task-result>", "<status>completed or failed</status>", "<summary>One-line summary of what you did</summary>", "<details>Full findings or changes made</details>");
|
|
16042
|
+
if (worktreeBranch) {
|
|
16043
|
+
context.push(`<branch>${worktreeBranch}</branch>`);
|
|
16044
|
+
}
|
|
16045
|
+
context.push("</task-result>");
|
|
16046
|
+
const lastStep = !isReadOnly && worktreeBranch ? "4" : !isReadOnly ? "3" : "2";
|
|
16047
|
+
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.");
|
|
15993
16048
|
context.push("", "Your task:", args.prompt);
|
|
15994
16049
|
if (args.claim_task) {
|
|
15995
16050
|
context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`);
|
|
@@ -16035,9 +16090,7 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
16035
16090
|
|
|
16036
16091
|
// src/tools/team-message.ts
|
|
16037
16092
|
async function executeTeamMessage(deps, args, sessionId) {
|
|
16038
|
-
const teamInfo =
|
|
16039
|
-
if (!teamInfo)
|
|
16040
|
-
throw new Error("This session is not in a team. Use team_create first.");
|
|
16093
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16041
16094
|
const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
|
|
16042
16095
|
const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to);
|
|
16043
16096
|
if (!recipientSessionId)
|
|
@@ -16093,9 +16146,7 @@ async function executeTeamMessage(deps, args, sessionId) {
|
|
|
16093
16146
|
|
|
16094
16147
|
// src/tools/team-broadcast.ts
|
|
16095
16148
|
async function executeTeamBroadcast(deps, args, sessionId) {
|
|
16096
|
-
const teamInfo =
|
|
16097
|
-
if (!teamInfo)
|
|
16098
|
-
throw new Error("This session is not in a team. Use team_create first.");
|
|
16149
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16099
16150
|
const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
|
|
16100
16151
|
const msgId = broadcastMessage(deps.db, {
|
|
16101
16152
|
teamId: teamInfo.teamId,
|
|
@@ -16131,13 +16182,9 @@ async function executeTeamBroadcast(deps, args, sessionId) {
|
|
|
16131
16182
|
}
|
|
16132
16183
|
|
|
16133
16184
|
// src/tools/team-shutdown.ts
|
|
16134
|
-
async function executeTeamShutdown(deps, args, sessionId) {
|
|
16135
|
-
const teamInfo =
|
|
16136
|
-
|
|
16137
|
-
throw new Error("This session is not in a team.");
|
|
16138
|
-
if (teamInfo.role !== "lead")
|
|
16139
|
-
throw new Error("Only the team lead can shut down teammates.");
|
|
16140
|
-
const member = deps.db.query("SELECT session_id, status, worktree_branch FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
|
|
16185
|
+
async function executeTeamShutdown(deps, args, sessionId, isDirty = checkWorktreeDirty) {
|
|
16186
|
+
const teamInfo = requireLead(deps, sessionId);
|
|
16187
|
+
const member = deps.db.query("SELECT session_id, status, worktree_branch, worktree_dir FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
|
|
16141
16188
|
if (!member)
|
|
16142
16189
|
throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
|
|
16143
16190
|
if (member.status === "shutdown")
|
|
@@ -16146,7 +16193,8 @@ async function executeTeamShutdown(deps, args, sessionId) {
|
|
|
16146
16193
|
if (member.status === "shutdown_requested") {
|
|
16147
16194
|
await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id);
|
|
16148
16195
|
const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : "";
|
|
16149
|
-
|
|
16196
|
+
const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty);
|
|
16197
|
+
return `Force shut down "${args.member}".${branchInfo}${dirtyWarning}`;
|
|
16150
16198
|
}
|
|
16151
16199
|
let isIdle = false;
|
|
16152
16200
|
try {
|
|
@@ -16157,7 +16205,8 @@ async function executeTeamShutdown(deps, args, sessionId) {
|
|
|
16157
16205
|
if (isIdle || force) {
|
|
16158
16206
|
await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id);
|
|
16159
16207
|
const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : "";
|
|
16160
|
-
|
|
16208
|
+
const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty);
|
|
16209
|
+
return `Teammate "${args.member}" has been shut down.${branchInfo}${dirtyWarning}`;
|
|
16161
16210
|
}
|
|
16162
16211
|
try {
|
|
16163
16212
|
await deps.client.session.promptAsync({
|
|
@@ -16177,20 +16226,50 @@ async function abortAndShutdown(deps, teamId, memberName, sessionId) {
|
|
|
16177
16226
|
} catch {}
|
|
16178
16227
|
deps.db.run("UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamId, memberName]);
|
|
16179
16228
|
}
|
|
16229
|
+
async function getDirtyWarning(worktreeDir, memberName, isDirty) {
|
|
16230
|
+
if (!worktreeDir)
|
|
16231
|
+
return "";
|
|
16232
|
+
try {
|
|
16233
|
+
if (await isDirty(worktreeDir)) {
|
|
16234
|
+
return `
|
|
16235
|
+
|
|
16236
|
+
Warning: ${memberName} has uncommitted changes in their worktree. Commit or merge before calling team_cleanup.`;
|
|
16237
|
+
}
|
|
16238
|
+
} catch {}
|
|
16239
|
+
return "";
|
|
16240
|
+
}
|
|
16180
16241
|
|
|
16181
16242
|
// src/tools/team-cleanup.ts
|
|
16182
|
-
async function executeTeamCleanup(deps, args, sessionId) {
|
|
16183
|
-
const teamInfo =
|
|
16184
|
-
if (!teamInfo)
|
|
16185
|
-
throw new Error("This session is not in a team.");
|
|
16186
|
-
if (teamInfo.role !== "lead")
|
|
16187
|
-
throw new Error("Only the team lead can clean up the team.");
|
|
16243
|
+
async function executeTeamCleanup(deps, args, sessionId, isDirty = checkWorktreeDirty) {
|
|
16244
|
+
const teamInfo = requireLead(deps, sessionId);
|
|
16188
16245
|
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);
|
|
16189
16246
|
const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
|
|
16190
16247
|
if (active.length > 0 && !args.force) {
|
|
16191
16248
|
const names = active.map((m) => m.name).join(", ");
|
|
16192
16249
|
throw new Error(`Cannot clean up team "${teamInfo.teamName}": ${active.length} member(s) still active: ${names}. Use team_shutdown on each member first, or call team_cleanup with force: true to abort them immediately.`);
|
|
16193
16250
|
}
|
|
16251
|
+
if (!args.acknowledge_uncommitted) {
|
|
16252
|
+
const dirty = [];
|
|
16253
|
+
for (const member of members) {
|
|
16254
|
+
if (member.worktree_dir) {
|
|
16255
|
+
try {
|
|
16256
|
+
if (await isDirty(member.worktree_dir)) {
|
|
16257
|
+
dirty.push({ name: member.name, branch: member.worktree_branch ?? "unknown" });
|
|
16258
|
+
}
|
|
16259
|
+
} catch {
|
|
16260
|
+
log(`cleanup:dirty-check:failed name=${member.name}`);
|
|
16261
|
+
}
|
|
16262
|
+
}
|
|
16263
|
+
}
|
|
16264
|
+
if (dirty.length > 0) {
|
|
16265
|
+
const warnings = dirty.map((d) => ` - ${d.name} (branch: ${d.branch})`).join(`
|
|
16266
|
+
`);
|
|
16267
|
+
return `Warning: ${dirty.length} teammate(s) have uncommitted changes in their worktrees:
|
|
16268
|
+
${warnings}
|
|
16269
|
+
|
|
16270
|
+
Commit or merge their work first, then call team_cleanup with acknowledge_uncommitted: true to proceed.`;
|
|
16271
|
+
}
|
|
16272
|
+
}
|
|
16194
16273
|
if (args.force) {
|
|
16195
16274
|
for (const member of active) {
|
|
16196
16275
|
try {
|
|
@@ -16231,9 +16310,7 @@ ${mergeCommands}`;
|
|
|
16231
16310
|
|
|
16232
16311
|
// src/tools/team-tasks-list.ts
|
|
16233
16312
|
async function executeTeamTasksList(deps, sessionId) {
|
|
16234
|
-
const teamInfo =
|
|
16235
|
-
if (!teamInfo)
|
|
16236
|
-
throw new Error("This session is not in a team.");
|
|
16313
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16237
16314
|
const tasks = deps.db.query("SELECT * FROM team_task WHERE team_id = ? ORDER BY time_created ASC").all(teamInfo.teamId);
|
|
16238
16315
|
if (tasks.length === 0)
|
|
16239
16316
|
return "No tasks on the board.";
|
|
@@ -16243,9 +16320,7 @@ async function executeTeamTasksList(deps, sessionId) {
|
|
|
16243
16320
|
|
|
16244
16321
|
// src/tools/team-tasks-add.ts
|
|
16245
16322
|
async function executeTeamTasksAdd(deps, args, sessionId) {
|
|
16246
|
-
const teamInfo =
|
|
16247
|
-
if (!teamInfo)
|
|
16248
|
-
throw new Error("This session is not in a team.");
|
|
16323
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16249
16324
|
const ids = [];
|
|
16250
16325
|
const now = Date.now();
|
|
16251
16326
|
for (const task of args.tasks) {
|
|
@@ -16268,9 +16343,7 @@ async function executeTeamTasksAdd(deps, args, sessionId) {
|
|
|
16268
16343
|
|
|
16269
16344
|
// src/tools/team-tasks-complete.ts
|
|
16270
16345
|
async function executeTeamTasksComplete(deps, args, sessionId) {
|
|
16271
|
-
const teamInfo =
|
|
16272
|
-
if (!teamInfo)
|
|
16273
|
-
throw new Error("This session is not in a team.");
|
|
16346
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16274
16347
|
const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?").get(args.task_id, teamInfo.teamId);
|
|
16275
16348
|
if (!task)
|
|
16276
16349
|
throw new Error(`Task "${args.task_id}" not found`);
|
|
@@ -16301,9 +16374,7 @@ async function executeTeamTasksComplete(deps, args, sessionId) {
|
|
|
16301
16374
|
|
|
16302
16375
|
// src/tools/team-claim.ts
|
|
16303
16376
|
async function executeTeamClaim(deps, args, sessionId) {
|
|
16304
|
-
const teamInfo =
|
|
16305
|
-
if (!teamInfo)
|
|
16306
|
-
throw new Error("This session is not in a team.");
|
|
16377
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16307
16378
|
const claimerName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
|
|
16308
16379
|
const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?").get(args.task_id, teamInfo.teamId);
|
|
16309
16380
|
if (!task)
|
|
@@ -16323,9 +16394,7 @@ async function executeTeamClaim(deps, args, sessionId) {
|
|
|
16323
16394
|
|
|
16324
16395
|
// src/tools/team-results.ts
|
|
16325
16396
|
async function executeTeamResults(deps, args, sessionId) {
|
|
16326
|
-
const team =
|
|
16327
|
-
if (!team)
|
|
16328
|
-
throw new Error("not in a team");
|
|
16397
|
+
const team = requireTeamMember(deps, sessionId);
|
|
16329
16398
|
const rows = args.from ? deps.db.query("SELECT id, from_name, content, time_created FROM team_message WHERE team_id = ? AND read = 0 AND from_name = ? ORDER BY time_created ASC").all(team.teamId, args.from) : deps.db.query("SELECT id, from_name, content, time_created FROM team_message WHERE team_id = ? AND read = 0 ORDER BY time_created ASC").all(team.teamId);
|
|
16330
16399
|
if (rows.length === 0)
|
|
16331
16400
|
return "No unread messages.";
|
|
@@ -16342,16 +16411,14 @@ ${r.content}
|
|
|
16342
16411
|
var lastCallTime = new Map;
|
|
16343
16412
|
var RATE_LIMIT_MS = 30000;
|
|
16344
16413
|
async function executeTeamStatus(deps, sessionId) {
|
|
16345
|
-
const teamInfo =
|
|
16346
|
-
if (!teamInfo)
|
|
16347
|
-
throw new Error("This session is not in a team.");
|
|
16414
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16348
16415
|
const now = Date.now();
|
|
16349
16416
|
const last = lastCallTime.get(teamInfo.teamId);
|
|
16350
16417
|
if (last !== undefined && now - last < RATE_LIMIT_MS) {
|
|
16351
16418
|
return "No changes since last check.";
|
|
16352
16419
|
}
|
|
16353
16420
|
lastCallTime.set(teamInfo.teamId, now);
|
|
16354
|
-
const members = deps.db.query("SELECT name, session_id, agent, status, execution_status, worktree_branch, plan_approval FROM team_member WHERE team_id = ? ORDER BY time_created ASC").all(teamInfo.teamId);
|
|
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);
|
|
16355
16422
|
const tasks = deps.db.query("SELECT status FROM team_task WHERE team_id = ?").all(teamInfo.teamId);
|
|
16356
16423
|
const lines = [];
|
|
16357
16424
|
lines.push(`Team: ${teamInfo.teamName} (you are the ${teamInfo.role})`);
|
|
@@ -16365,6 +16432,9 @@ async function executeTeamStatus(deps, sessionId) {
|
|
|
16365
16432
|
const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : "";
|
|
16366
16433
|
const plan = m.plan_approval !== "none" ? `, plan: ${m.plan_approval}` : "";
|
|
16367
16434
|
lines.push(` ${m.name} [${statusIcon}${plan}] agent: ${m.agent}${branch} session: ${m.session_id}`);
|
|
16435
|
+
if (m.worktree_dir) {
|
|
16436
|
+
lines.push(` worktree: ${m.worktree_dir}`);
|
|
16437
|
+
}
|
|
16368
16438
|
}
|
|
16369
16439
|
}
|
|
16370
16440
|
if (tasks.length > 0) {
|
|
@@ -16401,9 +16471,7 @@ async function executeTeamStatus(deps, sessionId) {
|
|
|
16401
16471
|
|
|
16402
16472
|
// src/tools/team-view.ts
|
|
16403
16473
|
async function executeTeamView(deps, args, sessionId) {
|
|
16404
|
-
const teamInfo =
|
|
16405
|
-
if (!teamInfo)
|
|
16406
|
-
throw new Error("This session is not in a team.");
|
|
16474
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16407
16475
|
const member = deps.db.query("SELECT session_id, status, agent FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
|
|
16408
16476
|
if (!member)
|
|
16409
16477
|
throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
|
|
@@ -16493,7 +16561,28 @@ class Watchdog {
|
|
|
16493
16561
|
this.ttlMs = opts.ttlMs;
|
|
16494
16562
|
this.checkIntervalMs = opts.checkIntervalMs ?? 60000;
|
|
16495
16563
|
}
|
|
16564
|
+
static STALE_THRESHOLD_MS = Number(process.env.STALE_WORKTREE_THRESHOLD_MS) || 300000;
|
|
16565
|
+
async cleanupStaleWorktrees() {
|
|
16566
|
+
const cutoff = Date.now() - Watchdog.STALE_THRESHOLD_MS;
|
|
16567
|
+
const stale = this.db.query(`SELECT tm.team_id, tm.name, tm.worktree_dir, tm.workspace_id
|
|
16568
|
+
FROM team_member tm
|
|
16569
|
+
JOIN team t ON tm.team_id = t.id
|
|
16570
|
+
WHERE t.status = 'active'
|
|
16571
|
+
AND tm.status IN ('shutdown', 'error')
|
|
16572
|
+
AND tm.worktree_dir IS NOT NULL
|
|
16573
|
+
AND tm.time_updated < ?`).all(cutoff);
|
|
16574
|
+
for (const m of stale) {
|
|
16575
|
+
try {
|
|
16576
|
+
if (m.workspace_id) {
|
|
16577
|
+
await this.client.workspace.remove({ id: m.workspace_id });
|
|
16578
|
+
}
|
|
16579
|
+
await this.client.worktree.remove({ worktreeRemoveInput: { directory: m.worktree_dir } });
|
|
16580
|
+
this.db.run("UPDATE team_member SET worktree_dir = NULL, worktree_branch = NULL, workspace_id = NULL WHERE team_id = ? AND name = ?", [m.team_id, m.name]);
|
|
16581
|
+
} catch {}
|
|
16582
|
+
}
|
|
16583
|
+
}
|
|
16496
16584
|
async check() {
|
|
16585
|
+
await this.cleanupStaleWorktrees();
|
|
16497
16586
|
if (this.ttlMs === 0)
|
|
16498
16587
|
return;
|
|
16499
16588
|
const cutoff = Date.now() - this.ttlMs;
|
|
@@ -16521,8 +16610,6 @@ class Watchdog {
|
|
|
16521
16610
|
start() {
|
|
16522
16611
|
if (this.timer)
|
|
16523
16612
|
return;
|
|
16524
|
-
if (this.ttlMs === 0)
|
|
16525
|
-
return;
|
|
16526
16613
|
this.timer = setInterval(() => this.check(), this.checkIntervalMs);
|
|
16527
16614
|
}
|
|
16528
16615
|
stop() {
|
|
@@ -16811,18 +16898,21 @@ var plugin = async (input) => {
|
|
|
16811
16898
|
},
|
|
16812
16899
|
async execute(args, ctx) {
|
|
16813
16900
|
const result = await executeTeamShutdown(deps, args, ctx.sessionID);
|
|
16814
|
-
|
|
16901
|
+
const hasWarning = result.includes("uncommitted");
|
|
16902
|
+
ctx.metadata({ title: hasWarning ? `${args.member} shut down \u2014 uncommitted changes` : `${args.member} shut down` });
|
|
16815
16903
|
return result;
|
|
16816
16904
|
}
|
|
16817
16905
|
}),
|
|
16818
16906
|
team_cleanup: tool({
|
|
16819
16907
|
description: "Clean up the team. All teammates must be shut down first. Removes team data and frees resources.",
|
|
16820
16908
|
args: {
|
|
16821
|
-
force: tool.schema.boolean().default(false).describe("Force cleanup even if members are active (will abort them)")
|
|
16909
|
+
force: tool.schema.boolean().default(false).describe("Force cleanup even if members are active (will abort them)"),
|
|
16910
|
+
acknowledge_uncommitted: tool.schema.boolean().default(false)
|
|
16822
16911
|
},
|
|
16823
16912
|
async execute(args, ctx) {
|
|
16824
16913
|
const result = await executeTeamCleanup(deps, args, ctx.sessionID);
|
|
16825
|
-
|
|
16914
|
+
const blocked = result.includes("uncommitted");
|
|
16915
|
+
ctx.metadata({ title: blocked ? "Cleanup blocked \u2014 uncommitted changes" : "Team cleaned up" });
|
|
16826
16916
|
return result;
|
|
16827
16917
|
}
|
|
16828
16918
|
}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hueyexe/opencode-ensemble",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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/index.ts
CHANGED
|
@@ -361,7 +361,8 @@ const plugin: Plugin = async (input) => {
|
|
|
361
361
|
},
|
|
362
362
|
async execute(args, ctx) {
|
|
363
363
|
const result = await executeTeamShutdown(deps, args, ctx.sessionID)
|
|
364
|
-
|
|
364
|
+
const hasWarning = result.includes("uncommitted")
|
|
365
|
+
ctx.metadata({ title: hasWarning ? `${args.member} shut down — uncommitted changes` : `${args.member} shut down` })
|
|
365
366
|
return result
|
|
366
367
|
},
|
|
367
368
|
}),
|
|
@@ -370,10 +371,12 @@ const plugin: Plugin = async (input) => {
|
|
|
370
371
|
description: "Clean up the team. All teammates must be shut down first. Removes team data and frees resources.",
|
|
371
372
|
args: {
|
|
372
373
|
force: tool.schema.boolean().default(false).describe("Force cleanup even if members are active (will abort them)"),
|
|
374
|
+
acknowledge_uncommitted: tool.schema.boolean().default(false),
|
|
373
375
|
},
|
|
374
376
|
async execute(args, ctx) {
|
|
375
377
|
const result = await executeTeamCleanup(deps, args, ctx.sessionID)
|
|
376
|
-
|
|
378
|
+
const blocked = result.includes("uncommitted")
|
|
379
|
+
ctx.metadata({ title: blocked ? "Cleanup blocked — uncommitted changes" : "Team cleaned up" })
|
|
377
380
|
return result
|
|
378
381
|
},
|
|
379
382
|
}),
|
package/src/system-prompt.ts
CHANGED
|
@@ -75,6 +75,10 @@ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
|
|
|
75
75
|
"Do NOT poll team_status or team_tasks_list repeatedly — wait for messages.",
|
|
76
76
|
"After spawning all teammates, tell the user what you've set up and wait.",
|
|
77
77
|
"When all teammates finish, summarize results and suggest next steps.",
|
|
78
|
+
"",
|
|
79
|
+
"Before calling team_cleanup, verify teammates have committed their work.",
|
|
80
|
+
"team_shutdown will warn you if a teammate has uncommitted changes.",
|
|
81
|
+
"team_cleanup will block if any worktree has uncommitted changes — merge or commit first.",
|
|
78
82
|
)
|
|
79
83
|
|
|
80
84
|
return lines.join("\n")
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ToolDeps } from "../types"
|
|
2
|
+
import { findTeamBySession } from "../types"
|
|
3
|
+
|
|
4
|
+
/** Function type for dirty worktree check — injectable for testing. */
|
|
5
|
+
export type IsDirtyFn = (dir: string) => Promise<boolean>
|
|
6
|
+
|
|
7
|
+
/** Check if a worktree directory has uncommitted changes via git status. */
|
|
8
|
+
export async function checkWorktreeDirty(dir: string): Promise<boolean> {
|
|
9
|
+
try {
|
|
10
|
+
const proc = Bun.spawn(["git", "-C", dir, "status", "--porcelain"], { stdout: "pipe", stderr: "pipe" })
|
|
11
|
+
const output = await new Response(proc.stdout).text()
|
|
12
|
+
const exitCode = await proc.exited
|
|
13
|
+
if (exitCode !== 0) return false // git failed — assume clean
|
|
14
|
+
return output.trim().length > 0
|
|
15
|
+
} catch {
|
|
16
|
+
return false // can't check — assume clean
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Validate that the session belongs to the team lead. Throws if not. */
|
|
21
|
+
export function requireLead(
|
|
22
|
+
deps: Pick<ToolDeps, "db" | "registry">,
|
|
23
|
+
sessionId: string,
|
|
24
|
+
): { teamId: string; teamName: string } {
|
|
25
|
+
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
26
|
+
if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
|
|
27
|
+
if (teamInfo.role !== "lead") throw new Error("Only the team lead can use this tool.")
|
|
28
|
+
return { teamId: teamInfo.teamId, teamName: teamInfo.teamName }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Validate that the session belongs to any team member (lead or teammate). Throws if not. */
|
|
32
|
+
export function requireTeamMember(
|
|
33
|
+
deps: Pick<ToolDeps, "db" | "registry">,
|
|
34
|
+
sessionId: string,
|
|
35
|
+
): { teamId: string; teamName: string; role: "lead" | "member"; memberName?: string } {
|
|
36
|
+
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
37
|
+
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
38
|
+
return teamInfo
|
|
39
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { requireTeamMember } from "./shared"
|
|
3
3
|
import { broadcastMessage, markDelivered } from "../messaging"
|
|
4
4
|
import { log } from "../log"
|
|
5
5
|
|
|
@@ -11,8 +11,7 @@ export async function executeTeamBroadcast(
|
|
|
11
11
|
args: { text: string },
|
|
12
12
|
sessionId: string,
|
|
13
13
|
): Promise<string> {
|
|
14
|
-
const teamInfo =
|
|
15
|
-
if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
|
|
14
|
+
const teamInfo = requireTeamMember(deps, sessionId)
|
|
16
15
|
|
|
17
16
|
const senderName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
|
|
18
17
|
|
package/src/tools/team-claim.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { requireTeamMember } from "./shared"
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Execute the team_claim tool. Atomically claims a pending task.
|
|
@@ -10,8 +10,7 @@ export async function executeTeamClaim(
|
|
|
10
10
|
args: { task_id: string },
|
|
11
11
|
sessionId: string,
|
|
12
12
|
): Promise<string> {
|
|
13
|
-
const teamInfo =
|
|
14
|
-
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
13
|
+
const teamInfo = requireTeamMember(deps, sessionId)
|
|
15
14
|
|
|
16
15
|
const claimerName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
|
|
17
16
|
|
|
@@ -1,19 +1,21 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { requireLead, checkWorktreeDirty } from "./shared"
|
|
3
|
+
import type { IsDirtyFn } from "./shared"
|
|
4
|
+
import { log } from "../log"
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* Execute the team_cleanup tool. Archives the team and cleans up resources.
|
|
6
|
-
*
|
|
7
|
-
* If force=true, aborts
|
|
8
|
+
* Checks for uncommitted changes BEFORE aborting sessions or removing worktrees.
|
|
9
|
+
* If force=true, aborts active sessions but still blocks on dirty worktrees.
|
|
10
|
+
* Pass acknowledge_uncommitted=true to remove dirty worktrees.
|
|
8
11
|
*/
|
|
9
12
|
export async function executeTeamCleanup(
|
|
10
13
|
deps: ToolDeps,
|
|
11
|
-
args: { force: boolean },
|
|
14
|
+
args: { force: boolean; acknowledge_uncommitted?: boolean },
|
|
12
15
|
sessionId: string,
|
|
16
|
+
isDirty: IsDirtyFn = checkWorktreeDirty,
|
|
13
17
|
): Promise<string> {
|
|
14
|
-
const teamInfo =
|
|
15
|
-
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
16
|
-
if (teamInfo.role !== "lead") throw new Error("Only the team lead can clean up the team.")
|
|
18
|
+
const teamInfo = requireLead(deps, sessionId)
|
|
17
19
|
|
|
18
20
|
const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch, workspace_id FROM team_member WHERE team_id = ?")
|
|
19
21
|
.all(teamInfo.teamId) as Array<{ name: string; session_id: string; status: string; worktree_dir: string | null; worktree_branch: string | null; workspace_id: string | null }>
|
|
@@ -25,7 +27,28 @@ export async function executeTeamCleanup(
|
|
|
25
27
|
throw new Error(`Cannot clean up team "${teamInfo.teamName}": ${active.length} member(s) still active: ${names}. Use team_shutdown on each member first, or call team_cleanup with force: true to abort them immediately.`)
|
|
26
28
|
}
|
|
27
29
|
|
|
28
|
-
//
|
|
30
|
+
// Check for uncommitted changes BEFORE aborting sessions — agents can still commit if warned
|
|
31
|
+
if (!args.acknowledge_uncommitted) {
|
|
32
|
+
const dirty: Array<{ name: string; branch: string }> = []
|
|
33
|
+
for (const member of members) {
|
|
34
|
+
if (member.worktree_dir) {
|
|
35
|
+
try {
|
|
36
|
+
if (await isDirty(member.worktree_dir)) {
|
|
37
|
+
dirty.push({ name: member.name, branch: member.worktree_branch ?? "unknown" })
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
log(`cleanup:dirty-check:failed name=${member.name}`)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (dirty.length > 0) {
|
|
46
|
+
const warnings = dirty.map(d => ` - ${d.name} (branch: ${d.branch})`).join("\n")
|
|
47
|
+
return `Warning: ${dirty.length} teammate(s) have uncommitted changes in their worktrees:\n${warnings}\n\nCommit or merge their work first, then call team_cleanup with acknowledge_uncommitted: true to proceed.`
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Force-abort active members (only reached if worktrees are clean or acknowledged)
|
|
29
52
|
if (args.force) {
|
|
30
53
|
for (const member of active) {
|
|
31
54
|
try {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { resolveRecipientSession } from "../types"
|
|
3
|
+
import { requireTeamMember } from "./shared"
|
|
3
4
|
import { sendMessage, markDelivered } from "../messaging"
|
|
4
5
|
import { log } from "../log"
|
|
5
6
|
|
|
@@ -12,8 +13,7 @@ export async function executeTeamMessage(
|
|
|
12
13
|
args: { to: string; text: string; approve?: boolean; reject?: string },
|
|
13
14
|
sessionId: string,
|
|
14
15
|
): Promise<string> {
|
|
15
|
-
const teamInfo =
|
|
16
|
-
if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
|
|
16
|
+
const teamInfo = requireTeamMember(deps, sessionId)
|
|
17
17
|
|
|
18
18
|
const senderName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
|
|
19
19
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { requireTeamMember } from "./shared"
|
|
3
3
|
|
|
4
4
|
/** Row shape for unread messages query. */
|
|
5
5
|
interface UnreadMessageRow {
|
|
@@ -18,8 +18,7 @@ export async function executeTeamResults(
|
|
|
18
18
|
args: { from?: string },
|
|
19
19
|
sessionId: string,
|
|
20
20
|
): Promise<string> {
|
|
21
|
-
const team =
|
|
22
|
-
if (!team) throw new Error("not in a team")
|
|
21
|
+
const team = requireTeamMember(deps, sessionId)
|
|
23
22
|
|
|
24
23
|
const rows = args.from
|
|
25
24
|
? (deps.db
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { requireLead, checkWorktreeDirty } from "./shared"
|
|
3
|
+
import type { IsDirtyFn } from "./shared"
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Execute the team_shutdown tool. Requests a teammate to shut down.
|
|
@@ -9,18 +10,19 @@ import { findTeamBySession } from "../types"
|
|
|
9
10
|
* - If member is idle or force=true, abort immediately and set status='shutdown'.
|
|
10
11
|
* - If member is busy and force=false, send a shutdown message via promptAsync
|
|
11
12
|
* and set status='shutdown_requested'. The member finishes work and reports back.
|
|
13
|
+
*
|
|
14
|
+
* Warns if the member's worktree has uncommitted changes.
|
|
12
15
|
*/
|
|
13
16
|
export async function executeTeamShutdown(
|
|
14
17
|
deps: ToolDeps,
|
|
15
18
|
args: { member: string; force?: boolean },
|
|
16
19
|
sessionId: string,
|
|
20
|
+
isDirty: IsDirtyFn = checkWorktreeDirty,
|
|
17
21
|
): Promise<string> {
|
|
18
|
-
const teamInfo =
|
|
19
|
-
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
20
|
-
if (teamInfo.role !== "lead") throw new Error("Only the team lead can shut down teammates.")
|
|
22
|
+
const teamInfo = requireLead(deps, sessionId)
|
|
21
23
|
|
|
22
|
-
const member = deps.db.query("SELECT session_id, status, worktree_branch FROM team_member WHERE team_id = ? AND name = ?")
|
|
23
|
-
.get(teamInfo.teamId, args.member) as { session_id: string; status: string; worktree_branch: string | null } | null
|
|
24
|
+
const member = deps.db.query("SELECT session_id, status, worktree_branch, worktree_dir FROM team_member WHERE team_id = ? AND name = ?")
|
|
25
|
+
.get(teamInfo.teamId, args.member) as { session_id: string; status: string; worktree_branch: string | null; worktree_dir: string | null } | null
|
|
24
26
|
if (!member) throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`)
|
|
25
27
|
if (member.status === "shutdown") throw new Error(`Teammate "${args.member}" is already shut down`)
|
|
26
28
|
|
|
@@ -30,7 +32,8 @@ export async function executeTeamShutdown(
|
|
|
30
32
|
if (member.status === "shutdown_requested") {
|
|
31
33
|
await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id)
|
|
32
34
|
const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : ""
|
|
33
|
-
|
|
35
|
+
const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty)
|
|
36
|
+
return `Force shut down "${args.member}".${branchInfo}${dirtyWarning}`
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
// Determine if member is idle or busy
|
|
@@ -46,7 +49,8 @@ export async function executeTeamShutdown(
|
|
|
46
49
|
if (isIdle || force) {
|
|
47
50
|
await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id)
|
|
48
51
|
const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : ""
|
|
49
|
-
|
|
52
|
+
const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty)
|
|
53
|
+
return `Teammate "${args.member}" has been shut down.${branchInfo}${dirtyWarning}`
|
|
50
54
|
}
|
|
51
55
|
|
|
52
56
|
// Busy + not force → graceful: send shutdown message, set shutdown_requested
|
|
@@ -91,3 +95,14 @@ async function abortAndShutdown(
|
|
|
91
95
|
[Date.now(), teamId, memberName],
|
|
92
96
|
)
|
|
93
97
|
}
|
|
98
|
+
|
|
99
|
+
/** Build a warning suffix if the member's worktree has uncommitted changes. */
|
|
100
|
+
async function getDirtyWarning(worktreeDir: string | null, memberName: string, isDirty: IsDirtyFn): Promise<string> {
|
|
101
|
+
if (!worktreeDir) return ""
|
|
102
|
+
try {
|
|
103
|
+
if (await isDirty(worktreeDir)) {
|
|
104
|
+
return `\n\nWarning: ${memberName} has uncommitted changes in their worktree. Commit or merge before calling team_cleanup.`
|
|
105
|
+
}
|
|
106
|
+
} catch { /* best effort */ }
|
|
107
|
+
return ""
|
|
108
|
+
}
|
package/src/tools/team-spawn.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ToolDeps, PermissionRule } from "../types"
|
|
2
2
|
import { validateMemberName } from "../util"
|
|
3
|
-
import {
|
|
3
|
+
import { requireLead } from "./shared"
|
|
4
4
|
import { sendMessage } from "../messaging"
|
|
5
5
|
import { log } from "../log"
|
|
6
6
|
|
|
@@ -9,6 +9,11 @@ function getSpawnTimeout(): number {
|
|
|
9
9
|
return Number(process.env.SPAWN_TIMEOUT_MS) || 120_000
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/** Returns true if the directory is already inside an OpenCode worktree. */
|
|
13
|
+
function isWorktreeDirectory(dir: string): boolean {
|
|
14
|
+
return dir.includes("/opencode/worktree/")
|
|
15
|
+
}
|
|
16
|
+
|
|
12
17
|
/** Race a promise against a timeout. Throws if the timeout fires first. Cleans up timer on resolution. */
|
|
13
18
|
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
14
19
|
let timer: ReturnType<typeof setTimeout>
|
|
@@ -34,16 +39,14 @@ export async function executeTeamSpawn(
|
|
|
34
39
|
const nameError = validateMemberName(args.name)
|
|
35
40
|
if (nameError) throw new Error(nameError)
|
|
36
41
|
|
|
37
|
-
const teamInfo =
|
|
38
|
-
if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
|
|
39
|
-
if (teamInfo.role !== "lead") throw new Error("Only the team lead can spawn teammates.")
|
|
42
|
+
const teamInfo = requireLead(deps, sessionId)
|
|
40
43
|
|
|
41
44
|
// Check duplicate name
|
|
42
45
|
const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?")
|
|
43
46
|
.get(teamInfo.teamId, args.name)
|
|
44
47
|
if (existing) throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`)
|
|
45
48
|
|
|
46
|
-
const useWorktree = args.worktree !== false
|
|
49
|
+
const useWorktree = args.worktree !== false && !isWorktreeDirectory(deps.directory)
|
|
47
50
|
const usePlanApproval = args.plan_approval === true
|
|
48
51
|
|
|
49
52
|
log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`)
|
|
@@ -99,17 +102,31 @@ export async function executeTeamSpawn(
|
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
// Permission rules on session.create are the hard gate (server-enforced).
|
|
102
|
-
// For read-only agents, deny write tools and explicitly allow team tools
|
|
103
|
-
//
|
|
105
|
+
// For read-only agents, deny write tools and explicitly allow team tools.
|
|
106
|
+
// For all agents with worktrees, allowlist the worktree path for edit/bash.
|
|
104
107
|
const isReadOnly = args.agent === "plan" || args.agent === "explore"
|
|
105
108
|
const TEAM_TOOLS = ["team_message", "team_broadcast", "team_tasks_list", "team_tasks_add", "team_tasks_complete", "team_claim"] as const
|
|
106
|
-
const permission: PermissionRule[]
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
109
|
+
const permission: PermissionRule[] = []
|
|
110
|
+
|
|
111
|
+
if (worktreeDir) {
|
|
112
|
+
permission.push(
|
|
113
|
+
{ permission: "edit", pattern: `${worktreeDir}/**`, action: "allow" },
|
|
114
|
+
)
|
|
115
|
+
if (!isReadOnly) {
|
|
116
|
+
permission.push({ permission: "bash", pattern: "*", action: "allow" })
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (isReadOnly) {
|
|
121
|
+
permission.push(
|
|
122
|
+
{ permission: "edit", pattern: "*", action: "deny" },
|
|
123
|
+
{ permission: "bash", pattern: "*", action: "deny" },
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
permission.push(
|
|
128
|
+
...TEAM_TOOLS.map(t => ({ permission: t, pattern: "*", action: "allow" as const })),
|
|
129
|
+
)
|
|
113
130
|
|
|
114
131
|
// Create child session — bind to workspace if available (server-enforced CWD isolation).
|
|
115
132
|
// Falls back to no workspace binding if workspace.create failed.
|
|
@@ -120,7 +137,7 @@ export async function executeTeamSpawn(
|
|
|
120
137
|
deps.client.session.create({
|
|
121
138
|
parentID: sessionId,
|
|
122
139
|
title: `${args.name} (@${args.agent} teammate)`,
|
|
123
|
-
|
|
140
|
+
permission,
|
|
124
141
|
...(workspaceId ? { workspaceID: workspaceId } : {}),
|
|
125
142
|
}),
|
|
126
143
|
getSpawnTimeout(), `session.create for "${args.name}"`
|
|
@@ -196,20 +213,55 @@ export async function executeTeamSpawn(
|
|
|
196
213
|
)
|
|
197
214
|
}
|
|
198
215
|
|
|
216
|
+
if (isReadOnly) {
|
|
217
|
+
context.push(
|
|
218
|
+
"", "Tools available to you:",
|
|
219
|
+
"- team_message: send a message to the lead or another teammate",
|
|
220
|
+
"- team_broadcast: send a message to all team members",
|
|
221
|
+
"- team_tasks_list: view the shared team task board",
|
|
222
|
+
)
|
|
223
|
+
} else {
|
|
224
|
+
context.push(
|
|
225
|
+
"", "Tools available to you:",
|
|
226
|
+
"- team_message: send a message to the lead or another teammate",
|
|
227
|
+
"- team_broadcast: send a message to all team members",
|
|
228
|
+
"- team_tasks_list: view the shared team task board",
|
|
229
|
+
"- team_tasks_add: add tasks to the shared board",
|
|
230
|
+
"- team_tasks_complete: mark a task complete on the shared board",
|
|
231
|
+
"- team_claim: claim a pending task from the shared board",
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
context.push("", "When you finish your task:")
|
|
236
|
+
if (!isReadOnly && worktreeBranch) {
|
|
237
|
+
context.push(`1. Commit your changes: git add -A && git commit -m "your summary"`)
|
|
238
|
+
context.push("2. If you claimed a task, mark it complete using team_tasks_complete.")
|
|
239
|
+
context.push(
|
|
240
|
+
"3. Send ONE message to the lead using team_message with this format:",
|
|
241
|
+
)
|
|
242
|
+
} else if (!isReadOnly) {
|
|
243
|
+
context.push("1. If you claimed a task, mark it complete using team_tasks_complete.")
|
|
244
|
+
context.push(
|
|
245
|
+
"2. Send ONE message to the lead using team_message with this format:",
|
|
246
|
+
)
|
|
247
|
+
} else {
|
|
248
|
+
context.push(
|
|
249
|
+
"1. Send ONE message to the lead using team_message with this format:",
|
|
250
|
+
)
|
|
251
|
+
}
|
|
199
252
|
context.push(
|
|
200
|
-
"",
|
|
201
|
-
"
|
|
202
|
-
"-
|
|
203
|
-
"
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
"3. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.",
|
|
253
|
+
"<task-result>",
|
|
254
|
+
"<status>completed or failed</status>",
|
|
255
|
+
"<summary>One-line summary of what you did</summary>",
|
|
256
|
+
"<details>Full findings or changes made</details>",
|
|
257
|
+
)
|
|
258
|
+
if (worktreeBranch) {
|
|
259
|
+
context.push(`<branch>${worktreeBranch}</branch>`)
|
|
260
|
+
}
|
|
261
|
+
context.push("</task-result>")
|
|
262
|
+
const lastStep = !isReadOnly && worktreeBranch ? "4" : !isReadOnly ? "3" : "2"
|
|
263
|
+
context.push(
|
|
264
|
+
`${lastStep}. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.`,
|
|
213
265
|
"",
|
|
214
266
|
"If you are blocked, send ONE message to the lead describing the specific blocker.",
|
|
215
267
|
"",
|
package/src/tools/team-status.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { requireTeamMember } from "./shared"
|
|
3
3
|
|
|
4
4
|
/** Tracks the last time team_status was called per team ID (epoch ms). */
|
|
5
5
|
export const lastCallTime = new Map<string, number>()
|
|
@@ -13,8 +13,7 @@ export async function executeTeamStatus(
|
|
|
13
13
|
deps: ToolDeps,
|
|
14
14
|
sessionId: string,
|
|
15
15
|
): Promise<string> {
|
|
16
|
-
const teamInfo =
|
|
17
|
-
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
16
|
+
const teamInfo = requireTeamMember(deps, sessionId)
|
|
18
17
|
|
|
19
18
|
const now = Date.now()
|
|
20
19
|
const last = lastCallTime.get(teamInfo.teamId)
|
|
@@ -24,9 +23,9 @@ export async function executeTeamStatus(
|
|
|
24
23
|
lastCallTime.set(teamInfo.teamId, now)
|
|
25
24
|
|
|
26
25
|
const members = deps.db.query(
|
|
27
|
-
"SELECT name, session_id, agent, status, execution_status, worktree_branch, plan_approval FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
|
|
26
|
+
"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"
|
|
28
27
|
).all(teamInfo.teamId) as Array<{
|
|
29
|
-
name: string; session_id: string; agent: string; status: string; execution_status: string; worktree_branch: string | null; plan_approval: string
|
|
28
|
+
name: string; session_id: string; agent: string; status: string; execution_status: string; worktree_branch: string | null; worktree_dir: string | null; plan_approval: string
|
|
30
29
|
}>
|
|
31
30
|
|
|
32
31
|
const tasks = deps.db.query(
|
|
@@ -46,6 +45,9 @@ export async function executeTeamStatus(
|
|
|
46
45
|
const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : ""
|
|
47
46
|
const plan = m.plan_approval !== "none" ? `, plan: ${m.plan_approval}` : ""
|
|
48
47
|
lines.push(` ${m.name} [${statusIcon}${plan}] agent: ${m.agent}${branch} session: ${m.session_id}`)
|
|
48
|
+
if (m.worktree_dir) {
|
|
49
|
+
lines.push(` worktree: ${m.worktree_dir}`)
|
|
50
|
+
}
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { requireTeamMember } from "./shared"
|
|
3
3
|
import { generateId } from "../util"
|
|
4
4
|
|
|
5
5
|
interface TaskInput {
|
|
@@ -17,8 +17,7 @@ export async function executeTeamTasksAdd(
|
|
|
17
17
|
args: { tasks: TaskInput[] },
|
|
18
18
|
sessionId: string,
|
|
19
19
|
): Promise<string> {
|
|
20
|
-
const teamInfo =
|
|
21
|
-
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
20
|
+
const teamInfo = requireTeamMember(deps, sessionId)
|
|
22
21
|
|
|
23
22
|
const ids: string[] = []
|
|
24
23
|
const now = Date.now()
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { requireTeamMember } from "./shared"
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Execute the team_tasks_complete tool. Marks a task as completed
|
|
@@ -10,8 +10,7 @@ export async function executeTeamTasksComplete(
|
|
|
10
10
|
args: { task_id: string },
|
|
11
11
|
sessionId: string,
|
|
12
12
|
): Promise<string> {
|
|
13
|
-
const teamInfo =
|
|
14
|
-
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
13
|
+
const teamInfo = requireTeamMember(deps, sessionId)
|
|
15
14
|
|
|
16
15
|
const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?")
|
|
17
16
|
.get(args.task_id, teamInfo.teamId) as Record<string, unknown> | null
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { requireTeamMember } from "./shared"
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Execute the team_tasks_list tool. Shows all tasks on the shared board.
|
|
@@ -8,8 +8,7 @@ export async function executeTeamTasksList(
|
|
|
8
8
|
deps: ToolDeps,
|
|
9
9
|
sessionId: string,
|
|
10
10
|
): Promise<string> {
|
|
11
|
-
const teamInfo =
|
|
12
|
-
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
11
|
+
const teamInfo = requireTeamMember(deps, sessionId)
|
|
13
12
|
|
|
14
13
|
const tasks = deps.db.query(
|
|
15
14
|
"SELECT * FROM team_task WHERE team_id = ? ORDER BY time_created ASC"
|
package/src/tools/team-view.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { requireTeamMember } from "./shared"
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Execute the team_view tool. Navigates the TUI to a teammate's session
|
|
@@ -10,8 +10,7 @@ export async function executeTeamView(
|
|
|
10
10
|
args: { member: string },
|
|
11
11
|
sessionId: string,
|
|
12
12
|
): Promise<string> {
|
|
13
|
-
const teamInfo =
|
|
14
|
-
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
13
|
+
const teamInfo = requireTeamMember(deps, sessionId)
|
|
15
14
|
|
|
16
15
|
const member = deps.db.query("SELECT session_id, status, agent FROM team_member WHERE team_id = ? AND name = ?")
|
|
17
16
|
.get(teamInfo.teamId, args.member) as { session_id: string; status: string; agent: string } | null
|
package/src/watchdog.ts
CHANGED
|
@@ -32,8 +32,38 @@ export class Watchdog {
|
|
|
32
32
|
this.checkIntervalMs = opts.checkIntervalMs ?? 60_000
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
private static STALE_THRESHOLD_MS = Number(process.env.STALE_WORKTREE_THRESHOLD_MS) || 300_000
|
|
36
|
+
|
|
37
|
+
/** Clean up worktrees and workspaces for shutdown/error members past the stale threshold. */
|
|
38
|
+
async cleanupStaleWorktrees(): Promise<void> {
|
|
39
|
+
const cutoff = Date.now() - Watchdog.STALE_THRESHOLD_MS
|
|
40
|
+
const stale = this.db.query(
|
|
41
|
+
`SELECT tm.team_id, tm.name, tm.worktree_dir, tm.workspace_id
|
|
42
|
+
FROM team_member tm
|
|
43
|
+
JOIN team t ON tm.team_id = t.id
|
|
44
|
+
WHERE t.status = 'active'
|
|
45
|
+
AND tm.status IN ('shutdown', 'error')
|
|
46
|
+
AND tm.worktree_dir IS NOT NULL
|
|
47
|
+
AND tm.time_updated < ?`
|
|
48
|
+
).all(cutoff) as Array<{ team_id: string; name: string; worktree_dir: string; workspace_id: string | null }>
|
|
49
|
+
|
|
50
|
+
for (const m of stale) {
|
|
51
|
+
try {
|
|
52
|
+
if (m.workspace_id) {
|
|
53
|
+
await this.client.workspace.remove({ id: m.workspace_id })
|
|
54
|
+
}
|
|
55
|
+
await this.client.worktree.remove({ worktreeRemoveInput: { directory: m.worktree_dir } })
|
|
56
|
+
this.db.run(
|
|
57
|
+
"UPDATE team_member SET worktree_dir = NULL, worktree_branch = NULL, workspace_id = NULL WHERE team_id = ? AND name = ?",
|
|
58
|
+
[m.team_id, m.name]
|
|
59
|
+
)
|
|
60
|
+
} catch { /* best effort */ }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
35
64
|
/** Run a single check for stale busy members. */
|
|
36
65
|
async check(): Promise<void> {
|
|
66
|
+
await this.cleanupStaleWorktrees()
|
|
37
67
|
if (this.ttlMs === 0) return
|
|
38
68
|
|
|
39
69
|
const cutoff = Date.now() - this.ttlMs
|
|
@@ -70,10 +100,9 @@ export class Watchdog {
|
|
|
70
100
|
}
|
|
71
101
|
}
|
|
72
102
|
|
|
73
|
-
/** Start the periodic check. */
|
|
103
|
+
/** Start the periodic check. Runs stale worktree GC regardless of TTL setting. */
|
|
74
104
|
start(): void {
|
|
75
105
|
if (this.timer) return
|
|
76
|
-
if (this.ttlMs === 0) return
|
|
77
106
|
this.timer = setInterval(() => this.check(), this.checkIntervalMs)
|
|
78
107
|
}
|
|
79
108
|
|