@hueyexe/opencode-ensemble 0.6.5 → 0.7.1
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 +176 -65
- package/package.json +1 -1
- package/src/hooks.ts +14 -0
- package/src/index.ts +17 -3
- package/src/system-prompt.ts +13 -3
- 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 # 377 tests
|
|
225
247
|
bun run build
|
|
226
248
|
```
|
|
227
249
|
|
package/dist/index.js
CHANGED
|
@@ -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 = {
|
|
@@ -15776,7 +15783,7 @@ function buildLeadSystemPrompt(db, teamId) {
|
|
|
15776
15783
|
}
|
|
15777
15784
|
lines.push("--- End Messages ---");
|
|
15778
15785
|
}
|
|
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.");
|
|
15786
|
+
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
15787
|
return lines.join(`
|
|
15781
15788
|
`);
|
|
15782
15789
|
}
|
|
@@ -15784,7 +15791,7 @@ function buildTeammateSystemPrompt(db, teamId, memberName) {
|
|
|
15784
15791
|
const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId);
|
|
15785
15792
|
if (!team)
|
|
15786
15793
|
return "";
|
|
15787
|
-
return `You are "${memberName}", a teammate in team "${team.name}". Use team_message to communicate.`;
|
|
15794
|
+
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
15795
|
}
|
|
15789
15796
|
function buildTeamCompactionContext(db, teamId, role, memberName) {
|
|
15790
15797
|
const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId);
|
|
@@ -15802,11 +15809,15 @@ function buildTeamCompactionContext(db, teamId, role, memberName) {
|
|
|
15802
15809
|
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
15810
|
const memberList = members.map((m) => `${m.name} (${STATUS_DISPLAY[m.status] ?? m.status})`).join(", ");
|
|
15804
15811
|
const membersLine = members.length > 0 ? `Members: ${memberList}` : "Members: none";
|
|
15805
|
-
|
|
15812
|
+
const lines = [
|
|
15806
15813
|
roleLine,
|
|
15807
15814
|
membersLine,
|
|
15808
15815
|
`Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`
|
|
15809
|
-
]
|
|
15816
|
+
];
|
|
15817
|
+
if (role === "member") {
|
|
15818
|
+
lines.push("IMPORTANT: You MUST send your results to the lead via team_message before stopping.");
|
|
15819
|
+
}
|
|
15820
|
+
return lines.join(`
|
|
15810
15821
|
`);
|
|
15811
15822
|
}
|
|
15812
15823
|
|
|
@@ -15860,10 +15871,41 @@ async function executeTeamCreate(deps, args, sessionId) {
|
|
|
15860
15871
|
return `Team "${args.name}" created. You are the lead. Use team_spawn to add teammates.`;
|
|
15861
15872
|
}
|
|
15862
15873
|
|
|
15874
|
+
// src/tools/shared.ts
|
|
15875
|
+
async function checkWorktreeDirty(dir) {
|
|
15876
|
+
try {
|
|
15877
|
+
const proc = Bun.spawn(["git", "-C", dir, "status", "--porcelain"], { stdout: "pipe", stderr: "pipe" });
|
|
15878
|
+
const output = await new Response(proc.stdout).text();
|
|
15879
|
+
const exitCode = await proc.exited;
|
|
15880
|
+
if (exitCode !== 0)
|
|
15881
|
+
return false;
|
|
15882
|
+
return output.trim().length > 0;
|
|
15883
|
+
} catch {
|
|
15884
|
+
return false;
|
|
15885
|
+
}
|
|
15886
|
+
}
|
|
15887
|
+
function requireLead(deps, sessionId) {
|
|
15888
|
+
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
|
|
15889
|
+
if (!teamInfo)
|
|
15890
|
+
throw new Error("This session is not in a team. Use team_create first.");
|
|
15891
|
+
if (teamInfo.role !== "lead")
|
|
15892
|
+
throw new Error("Only the team lead can use this tool.");
|
|
15893
|
+
return { teamId: teamInfo.teamId, teamName: teamInfo.teamName };
|
|
15894
|
+
}
|
|
15895
|
+
function requireTeamMember(deps, sessionId) {
|
|
15896
|
+
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
|
|
15897
|
+
if (!teamInfo)
|
|
15898
|
+
throw new Error("This session is not in a team.");
|
|
15899
|
+
return teamInfo;
|
|
15900
|
+
}
|
|
15901
|
+
|
|
15863
15902
|
// src/tools/team-spawn.ts
|
|
15864
15903
|
function getSpawnTimeout() {
|
|
15865
15904
|
return Number(process.env.SPAWN_TIMEOUT_MS) || 120000;
|
|
15866
15905
|
}
|
|
15906
|
+
function isWorktreeDirectory(dir) {
|
|
15907
|
+
return dir.includes("/opencode/worktree/");
|
|
15908
|
+
}
|
|
15867
15909
|
function withTimeout(promise2, ms, label) {
|
|
15868
15910
|
let timer;
|
|
15869
15911
|
return Promise.race([
|
|
@@ -15877,15 +15919,11 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15877
15919
|
const nameError = validateMemberName(args.name);
|
|
15878
15920
|
if (nameError)
|
|
15879
15921
|
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.");
|
|
15922
|
+
const teamInfo = requireLead(deps, sessionId);
|
|
15885
15923
|
const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.name);
|
|
15886
15924
|
if (existing)
|
|
15887
15925
|
throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
|
|
15888
|
-
const useWorktree = args.worktree !== false;
|
|
15926
|
+
const useWorktree = args.worktree !== false && !isWorktreeDirectory(deps.directory);
|
|
15889
15927
|
const usePlanApproval = args.plan_approval === true;
|
|
15890
15928
|
log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`);
|
|
15891
15929
|
let worktreeDir = null;
|
|
@@ -15927,18 +15965,24 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15927
15965
|
}
|
|
15928
15966
|
const isReadOnly = args.agent === "plan" || args.agent === "explore";
|
|
15929
15967
|
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
|
-
|
|
15968
|
+
const permission = [];
|
|
15969
|
+
if (worktreeDir) {
|
|
15970
|
+
permission.push({ permission: "edit", pattern: `${worktreeDir}/**`, action: "allow" });
|
|
15971
|
+
if (!isReadOnly) {
|
|
15972
|
+
permission.push({ permission: "bash", pattern: "*", action: "allow" });
|
|
15973
|
+
}
|
|
15974
|
+
}
|
|
15975
|
+
if (isReadOnly) {
|
|
15976
|
+
permission.push({ permission: "edit", pattern: "*", action: "deny" }, { permission: "bash", pattern: "*", action: "deny" });
|
|
15977
|
+
}
|
|
15978
|
+
permission.push(...TEAM_TOOLS.map((t) => ({ permission: t, pattern: "*", action: "allow" })));
|
|
15935
15979
|
let childSessionId;
|
|
15936
15980
|
try {
|
|
15937
15981
|
log(`spawn:session:start name=${args.name}`);
|
|
15938
15982
|
const createResult = await withTimeout(deps.client.session.create({
|
|
15939
15983
|
parentID: sessionId,
|
|
15940
15984
|
title: `${args.name} (@${args.agent} teammate)`,
|
|
15941
|
-
|
|
15985
|
+
permission,
|
|
15942
15986
|
...workspaceId ? { workspaceID: workspaceId } : {}
|
|
15943
15987
|
}), getSpawnTimeout(), `session.create for "${args.name}"`);
|
|
15944
15988
|
childSessionId = createResult.data?.id;
|
|
@@ -15989,7 +16033,29 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15989
16033
|
if (usePlanApproval) {
|
|
15990
16034
|
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
16035
|
}
|
|
15992
|
-
|
|
16036
|
+
if (isReadOnly) {
|
|
16037
|
+
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");
|
|
16038
|
+
} else {
|
|
16039
|
+
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");
|
|
16040
|
+
}
|
|
16041
|
+
context.push("", "When you finish your task:");
|
|
16042
|
+
if (!isReadOnly && worktreeBranch) {
|
|
16043
|
+
context.push(`1. Commit your changes: git add -A && git commit -m "your summary"`);
|
|
16044
|
+
context.push("2. If you claimed a task, mark it complete using team_tasks_complete.");
|
|
16045
|
+
context.push("3. Send ONE message to the lead using team_message with this format:");
|
|
16046
|
+
} else if (!isReadOnly) {
|
|
16047
|
+
context.push("1. If you claimed a task, mark it complete using team_tasks_complete.");
|
|
16048
|
+
context.push("2. Send ONE message to the lead using team_message with this format:");
|
|
16049
|
+
} else {
|
|
16050
|
+
context.push("1. Send ONE message to the lead using team_message with this format:");
|
|
16051
|
+
}
|
|
16052
|
+
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>");
|
|
16053
|
+
if (worktreeBranch) {
|
|
16054
|
+
context.push(`<branch>${worktreeBranch}</branch>`);
|
|
16055
|
+
}
|
|
16056
|
+
context.push("</task-result>");
|
|
16057
|
+
const lastStep = !isReadOnly && worktreeBranch ? "4" : !isReadOnly ? "3" : "2";
|
|
16058
|
+
context.push(`${lastStep}. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.`, "", "If you are blocked, send ONE message to the lead describing the specific blocker.", "", "Your plain text output is NOT visible to the team. You MUST use team_message to communicate.");
|
|
15993
16059
|
context.push("", "Your task:", args.prompt);
|
|
15994
16060
|
if (args.claim_task) {
|
|
15995
16061
|
context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`);
|
|
@@ -16035,9 +16101,7 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
16035
16101
|
|
|
16036
16102
|
// src/tools/team-message.ts
|
|
16037
16103
|
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.");
|
|
16104
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16041
16105
|
const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
|
|
16042
16106
|
const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to);
|
|
16043
16107
|
if (!recipientSessionId)
|
|
@@ -16093,9 +16157,7 @@ async function executeTeamMessage(deps, args, sessionId) {
|
|
|
16093
16157
|
|
|
16094
16158
|
// src/tools/team-broadcast.ts
|
|
16095
16159
|
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.");
|
|
16160
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16099
16161
|
const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
|
|
16100
16162
|
const msgId = broadcastMessage(deps.db, {
|
|
16101
16163
|
teamId: teamInfo.teamId,
|
|
@@ -16131,13 +16193,9 @@ async function executeTeamBroadcast(deps, args, sessionId) {
|
|
|
16131
16193
|
}
|
|
16132
16194
|
|
|
16133
16195
|
// 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);
|
|
16196
|
+
async function executeTeamShutdown(deps, args, sessionId, isDirty = checkWorktreeDirty) {
|
|
16197
|
+
const teamInfo = requireLead(deps, sessionId);
|
|
16198
|
+
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
16199
|
if (!member)
|
|
16142
16200
|
throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
|
|
16143
16201
|
if (member.status === "shutdown")
|
|
@@ -16146,7 +16204,8 @@ async function executeTeamShutdown(deps, args, sessionId) {
|
|
|
16146
16204
|
if (member.status === "shutdown_requested") {
|
|
16147
16205
|
await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id);
|
|
16148
16206
|
const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : "";
|
|
16149
|
-
|
|
16207
|
+
const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty);
|
|
16208
|
+
return `Force shut down "${args.member}".${branchInfo}${dirtyWarning}`;
|
|
16150
16209
|
}
|
|
16151
16210
|
let isIdle = false;
|
|
16152
16211
|
try {
|
|
@@ -16157,7 +16216,8 @@ async function executeTeamShutdown(deps, args, sessionId) {
|
|
|
16157
16216
|
if (isIdle || force) {
|
|
16158
16217
|
await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id);
|
|
16159
16218
|
const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : "";
|
|
16160
|
-
|
|
16219
|
+
const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty);
|
|
16220
|
+
return `Teammate "${args.member}" has been shut down.${branchInfo}${dirtyWarning}`;
|
|
16161
16221
|
}
|
|
16162
16222
|
try {
|
|
16163
16223
|
await deps.client.session.promptAsync({
|
|
@@ -16177,20 +16237,50 @@ async function abortAndShutdown(deps, teamId, memberName, sessionId) {
|
|
|
16177
16237
|
} catch {}
|
|
16178
16238
|
deps.db.run("UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamId, memberName]);
|
|
16179
16239
|
}
|
|
16240
|
+
async function getDirtyWarning(worktreeDir, memberName, isDirty) {
|
|
16241
|
+
if (!worktreeDir)
|
|
16242
|
+
return "";
|
|
16243
|
+
try {
|
|
16244
|
+
if (await isDirty(worktreeDir)) {
|
|
16245
|
+
return `
|
|
16246
|
+
|
|
16247
|
+
Warning: ${memberName} has uncommitted changes in their worktree. Commit or merge before calling team_cleanup.`;
|
|
16248
|
+
}
|
|
16249
|
+
} catch {}
|
|
16250
|
+
return "";
|
|
16251
|
+
}
|
|
16180
16252
|
|
|
16181
16253
|
// 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.");
|
|
16254
|
+
async function executeTeamCleanup(deps, args, sessionId, isDirty = checkWorktreeDirty) {
|
|
16255
|
+
const teamInfo = requireLead(deps, sessionId);
|
|
16188
16256
|
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
16257
|
const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
|
|
16190
16258
|
if (active.length > 0 && !args.force) {
|
|
16191
16259
|
const names = active.map((m) => m.name).join(", ");
|
|
16192
16260
|
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
16261
|
}
|
|
16262
|
+
if (!args.acknowledge_uncommitted) {
|
|
16263
|
+
const dirty = [];
|
|
16264
|
+
for (const member of members) {
|
|
16265
|
+
if (member.worktree_dir) {
|
|
16266
|
+
try {
|
|
16267
|
+
if (await isDirty(member.worktree_dir)) {
|
|
16268
|
+
dirty.push({ name: member.name, branch: member.worktree_branch ?? "unknown" });
|
|
16269
|
+
}
|
|
16270
|
+
} catch {
|
|
16271
|
+
log(`cleanup:dirty-check:failed name=${member.name}`);
|
|
16272
|
+
}
|
|
16273
|
+
}
|
|
16274
|
+
}
|
|
16275
|
+
if (dirty.length > 0) {
|
|
16276
|
+
const warnings = dirty.map((d) => ` - ${d.name} (branch: ${d.branch})`).join(`
|
|
16277
|
+
`);
|
|
16278
|
+
return `Warning: ${dirty.length} teammate(s) have uncommitted changes in their worktrees:
|
|
16279
|
+
${warnings}
|
|
16280
|
+
|
|
16281
|
+
Commit or merge their work first, then call team_cleanup with acknowledge_uncommitted: true to proceed.`;
|
|
16282
|
+
}
|
|
16283
|
+
}
|
|
16194
16284
|
if (args.force) {
|
|
16195
16285
|
for (const member of active) {
|
|
16196
16286
|
try {
|
|
@@ -16231,9 +16321,7 @@ ${mergeCommands}`;
|
|
|
16231
16321
|
|
|
16232
16322
|
// src/tools/team-tasks-list.ts
|
|
16233
16323
|
async function executeTeamTasksList(deps, sessionId) {
|
|
16234
|
-
const teamInfo =
|
|
16235
|
-
if (!teamInfo)
|
|
16236
|
-
throw new Error("This session is not in a team.");
|
|
16324
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16237
16325
|
const tasks = deps.db.query("SELECT * FROM team_task WHERE team_id = ? ORDER BY time_created ASC").all(teamInfo.teamId);
|
|
16238
16326
|
if (tasks.length === 0)
|
|
16239
16327
|
return "No tasks on the board.";
|
|
@@ -16243,9 +16331,7 @@ async function executeTeamTasksList(deps, sessionId) {
|
|
|
16243
16331
|
|
|
16244
16332
|
// src/tools/team-tasks-add.ts
|
|
16245
16333
|
async function executeTeamTasksAdd(deps, args, sessionId) {
|
|
16246
|
-
const teamInfo =
|
|
16247
|
-
if (!teamInfo)
|
|
16248
|
-
throw new Error("This session is not in a team.");
|
|
16334
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16249
16335
|
const ids = [];
|
|
16250
16336
|
const now = Date.now();
|
|
16251
16337
|
for (const task of args.tasks) {
|
|
@@ -16268,9 +16354,7 @@ async function executeTeamTasksAdd(deps, args, sessionId) {
|
|
|
16268
16354
|
|
|
16269
16355
|
// src/tools/team-tasks-complete.ts
|
|
16270
16356
|
async function executeTeamTasksComplete(deps, args, sessionId) {
|
|
16271
|
-
const teamInfo =
|
|
16272
|
-
if (!teamInfo)
|
|
16273
|
-
throw new Error("This session is not in a team.");
|
|
16357
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16274
16358
|
const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?").get(args.task_id, teamInfo.teamId);
|
|
16275
16359
|
if (!task)
|
|
16276
16360
|
throw new Error(`Task "${args.task_id}" not found`);
|
|
@@ -16301,9 +16385,7 @@ async function executeTeamTasksComplete(deps, args, sessionId) {
|
|
|
16301
16385
|
|
|
16302
16386
|
// src/tools/team-claim.ts
|
|
16303
16387
|
async function executeTeamClaim(deps, args, sessionId) {
|
|
16304
|
-
const teamInfo =
|
|
16305
|
-
if (!teamInfo)
|
|
16306
|
-
throw new Error("This session is not in a team.");
|
|
16388
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16307
16389
|
const claimerName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
|
|
16308
16390
|
const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?").get(args.task_id, teamInfo.teamId);
|
|
16309
16391
|
if (!task)
|
|
@@ -16323,9 +16405,7 @@ async function executeTeamClaim(deps, args, sessionId) {
|
|
|
16323
16405
|
|
|
16324
16406
|
// src/tools/team-results.ts
|
|
16325
16407
|
async function executeTeamResults(deps, args, sessionId) {
|
|
16326
|
-
const team =
|
|
16327
|
-
if (!team)
|
|
16328
|
-
throw new Error("not in a team");
|
|
16408
|
+
const team = requireTeamMember(deps, sessionId);
|
|
16329
16409
|
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
16410
|
if (rows.length === 0)
|
|
16331
16411
|
return "No unread messages.";
|
|
@@ -16342,16 +16422,14 @@ ${r.content}
|
|
|
16342
16422
|
var lastCallTime = new Map;
|
|
16343
16423
|
var RATE_LIMIT_MS = 30000;
|
|
16344
16424
|
async function executeTeamStatus(deps, sessionId) {
|
|
16345
|
-
const teamInfo =
|
|
16346
|
-
if (!teamInfo)
|
|
16347
|
-
throw new Error("This session is not in a team.");
|
|
16425
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16348
16426
|
const now = Date.now();
|
|
16349
16427
|
const last = lastCallTime.get(teamInfo.teamId);
|
|
16350
16428
|
if (last !== undefined && now - last < RATE_LIMIT_MS) {
|
|
16351
16429
|
return "No changes since last check.";
|
|
16352
16430
|
}
|
|
16353
16431
|
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);
|
|
16432
|
+
const members = deps.db.query("SELECT name, session_id, agent, status, execution_status, worktree_branch, worktree_dir, plan_approval FROM team_member WHERE team_id = ? ORDER BY time_created ASC").all(teamInfo.teamId);
|
|
16355
16433
|
const tasks = deps.db.query("SELECT status FROM team_task WHERE team_id = ?").all(teamInfo.teamId);
|
|
16356
16434
|
const lines = [];
|
|
16357
16435
|
lines.push(`Team: ${teamInfo.teamName} (you are the ${teamInfo.role})`);
|
|
@@ -16365,6 +16443,9 @@ async function executeTeamStatus(deps, sessionId) {
|
|
|
16365
16443
|
const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : "";
|
|
16366
16444
|
const plan = m.plan_approval !== "none" ? `, plan: ${m.plan_approval}` : "";
|
|
16367
16445
|
lines.push(` ${m.name} [${statusIcon}${plan}] agent: ${m.agent}${branch} session: ${m.session_id}`);
|
|
16446
|
+
if (m.worktree_dir) {
|
|
16447
|
+
lines.push(` worktree: ${m.worktree_dir}`);
|
|
16448
|
+
}
|
|
16368
16449
|
}
|
|
16369
16450
|
}
|
|
16370
16451
|
if (tasks.length > 0) {
|
|
@@ -16401,9 +16482,7 @@ async function executeTeamStatus(deps, sessionId) {
|
|
|
16401
16482
|
|
|
16402
16483
|
// src/tools/team-view.ts
|
|
16403
16484
|
async function executeTeamView(deps, args, sessionId) {
|
|
16404
|
-
const teamInfo =
|
|
16405
|
-
if (!teamInfo)
|
|
16406
|
-
throw new Error("This session is not in a team.");
|
|
16485
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16407
16486
|
const member = deps.db.query("SELECT session_id, status, agent FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
|
|
16408
16487
|
if (!member)
|
|
16409
16488
|
throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
|
|
@@ -16493,7 +16572,28 @@ class Watchdog {
|
|
|
16493
16572
|
this.ttlMs = opts.ttlMs;
|
|
16494
16573
|
this.checkIntervalMs = opts.checkIntervalMs ?? 60000;
|
|
16495
16574
|
}
|
|
16575
|
+
static STALE_THRESHOLD_MS = Number(process.env.STALE_WORKTREE_THRESHOLD_MS) || 300000;
|
|
16576
|
+
async cleanupStaleWorktrees() {
|
|
16577
|
+
const cutoff = Date.now() - Watchdog.STALE_THRESHOLD_MS;
|
|
16578
|
+
const stale = this.db.query(`SELECT tm.team_id, tm.name, tm.worktree_dir, tm.workspace_id
|
|
16579
|
+
FROM team_member tm
|
|
16580
|
+
JOIN team t ON tm.team_id = t.id
|
|
16581
|
+
WHERE t.status = 'active'
|
|
16582
|
+
AND tm.status IN ('shutdown', 'error')
|
|
16583
|
+
AND tm.worktree_dir IS NOT NULL
|
|
16584
|
+
AND tm.time_updated < ?`).all(cutoff);
|
|
16585
|
+
for (const m of stale) {
|
|
16586
|
+
try {
|
|
16587
|
+
if (m.workspace_id) {
|
|
16588
|
+
await this.client.workspace.remove({ id: m.workspace_id });
|
|
16589
|
+
}
|
|
16590
|
+
await this.client.worktree.remove({ worktreeRemoveInput: { directory: m.worktree_dir } });
|
|
16591
|
+
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]);
|
|
16592
|
+
} catch {}
|
|
16593
|
+
}
|
|
16594
|
+
}
|
|
16496
16595
|
async check() {
|
|
16596
|
+
await this.cleanupStaleWorktrees();
|
|
16497
16597
|
if (this.ttlMs === 0)
|
|
16498
16598
|
return;
|
|
16499
16599
|
const cutoff = Date.now() - this.ttlMs;
|
|
@@ -16521,8 +16621,6 @@ class Watchdog {
|
|
|
16521
16621
|
start() {
|
|
16522
16622
|
if (this.timer)
|
|
16523
16623
|
return;
|
|
16524
|
-
if (this.ttlMs === 0)
|
|
16525
|
-
return;
|
|
16526
16624
|
this.timer = setInterval(() => this.check(), this.checkIntervalMs);
|
|
16527
16625
|
}
|
|
16528
16626
|
stop() {
|
|
@@ -16548,6 +16646,7 @@ var plugin = async (input) => {
|
|
|
16548
16646
|
const db = createDb(dbPath);
|
|
16549
16647
|
const registry2 = new MemberRegistry;
|
|
16550
16648
|
const tracker = new DescendantTracker;
|
|
16649
|
+
const nudgedMembers = new Set;
|
|
16551
16650
|
const pluginTransport = input.client._client;
|
|
16552
16651
|
const rawClient = new OpencodeClient({ client: pluginTransport });
|
|
16553
16652
|
initLog(rawClient);
|
|
@@ -16601,6 +16700,15 @@ var plugin = async (input) => {
|
|
|
16601
16700
|
notifyTeamEvent(client3, "shutdown", { memberName: transition.memberName });
|
|
16602
16701
|
} else if (transition.to === "ready" && transition.from === "busy") {
|
|
16603
16702
|
notifyTeamEvent(client3, "completed", { memberName: transition.memberName });
|
|
16703
|
+
const nudgeKey = `${transition.teamId}:${transition.memberName}`;
|
|
16704
|
+
if (!nudgedMembers.has(nudgeKey) && shouldNudgeIdleMember(db, transition.teamId, transition.memberName)) {
|
|
16705
|
+
nudgedMembers.add(nudgeKey);
|
|
16706
|
+
log(`nudge:idle-without-report name=${transition.memberName}`);
|
|
16707
|
+
client3.session.promptAsync({
|
|
16708
|
+
sessionID,
|
|
16709
|
+
parts: [{ type: "text", text: "[System]: You completed your work but did not report results. Send your findings to the lead via team_message now." }]
|
|
16710
|
+
}).catch(() => {});
|
|
16711
|
+
}
|
|
16604
16712
|
} else if (transition.to === "error") {
|
|
16605
16713
|
notifyTeamEvent(client3, "error", { memberName: transition.memberName });
|
|
16606
16714
|
} else if (transition.to === "retry") {
|
|
@@ -16811,18 +16919,21 @@ var plugin = async (input) => {
|
|
|
16811
16919
|
},
|
|
16812
16920
|
async execute(args, ctx) {
|
|
16813
16921
|
const result = await executeTeamShutdown(deps, args, ctx.sessionID);
|
|
16814
|
-
|
|
16922
|
+
const hasWarning = result.includes("uncommitted");
|
|
16923
|
+
ctx.metadata({ title: hasWarning ? `${args.member} shut down \u2014 uncommitted changes` : `${args.member} shut down` });
|
|
16815
16924
|
return result;
|
|
16816
16925
|
}
|
|
16817
16926
|
}),
|
|
16818
16927
|
team_cleanup: tool({
|
|
16819
16928
|
description: "Clean up the team. All teammates must be shut down first. Removes team data and frees resources.",
|
|
16820
16929
|
args: {
|
|
16821
|
-
force: tool.schema.boolean().default(false).describe("Force cleanup even if members are active (will abort them)")
|
|
16930
|
+
force: tool.schema.boolean().default(false).describe("Force cleanup even if members are active (will abort them)"),
|
|
16931
|
+
acknowledge_uncommitted: tool.schema.boolean().default(false)
|
|
16822
16932
|
},
|
|
16823
16933
|
async execute(args, ctx) {
|
|
16824
16934
|
const result = await executeTeamCleanup(deps, args, ctx.sessionID);
|
|
16825
|
-
|
|
16935
|
+
const blocked = result.includes("uncommitted");
|
|
16936
|
+
ctx.metadata({ title: blocked ? "Cleanup blocked \u2014 uncommitted changes" : "Team cleaned up" });
|
|
16826
16937
|
return result;
|
|
16827
16938
|
}
|
|
16828
16939
|
}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hueyexe/opencode-ensemble",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
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/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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { wrapThrowingClient } from "./client"
|
|
|
8
8
|
import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktrees } from "./recovery"
|
|
9
9
|
import { MemberRegistry, DescendantTracker } from "./state"
|
|
10
10
|
import { isWorktreeInstance } from "./util"
|
|
11
|
-
import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation } from "./hooks"
|
|
11
|
+
import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation, shouldNudgeIdleMember } from "./hooks"
|
|
12
12
|
import { notifyTeamEvent, notifyWorkingProgress } from "./notify"
|
|
13
13
|
import { buildLeadSystemPrompt, buildTeammateSystemPrompt, buildTeamCompactionContext } from "./system-prompt"
|
|
14
14
|
import { log, initLog } from "./log"
|
|
@@ -50,6 +50,7 @@ const plugin: Plugin = async (input) => {
|
|
|
50
50
|
// Initialize in-memory state
|
|
51
51
|
const registry = new MemberRegistry()
|
|
52
52
|
const tracker = new DescendantTracker()
|
|
53
|
+
const nudgedMembers = new Set<string>()
|
|
53
54
|
|
|
54
55
|
// Extract the working HeyAPI transport from the plugin-provided v1 client and pass it
|
|
55
56
|
// to the v2 OpencodeClient. The plugin framework provides a v1 client which stores its
|
|
@@ -123,6 +124,16 @@ const plugin: Plugin = async (input) => {
|
|
|
123
124
|
notifyTeamEvent(client, "shutdown", { memberName: transition.memberName })
|
|
124
125
|
} else if (transition.to === "ready" && transition.from === "busy") {
|
|
125
126
|
notifyTeamEvent(client, "completed", { memberName: transition.memberName })
|
|
127
|
+
// Nudge teammate if they went idle without reporting to the lead (once only)
|
|
128
|
+
const nudgeKey = `${transition.teamId}:${transition.memberName}`
|
|
129
|
+
if (!nudgedMembers.has(nudgeKey) && shouldNudgeIdleMember(db, transition.teamId, transition.memberName)) {
|
|
130
|
+
nudgedMembers.add(nudgeKey)
|
|
131
|
+
log(`nudge:idle-without-report name=${transition.memberName}`)
|
|
132
|
+
client.session.promptAsync({
|
|
133
|
+
sessionID,
|
|
134
|
+
parts: [{ type: "text", text: "[System]: You completed your work but did not report results. Send your findings to the lead via team_message now." }],
|
|
135
|
+
}).catch(() => { /* best effort */ })
|
|
136
|
+
}
|
|
126
137
|
} else if (transition.to === "error") {
|
|
127
138
|
notifyTeamEvent(client, "error", { memberName: transition.memberName })
|
|
128
139
|
} else if (transition.to === "retry") {
|
|
@@ -361,7 +372,8 @@ const plugin: Plugin = async (input) => {
|
|
|
361
372
|
},
|
|
362
373
|
async execute(args, ctx) {
|
|
363
374
|
const result = await executeTeamShutdown(deps, args, ctx.sessionID)
|
|
364
|
-
|
|
375
|
+
const hasWarning = result.includes("uncommitted")
|
|
376
|
+
ctx.metadata({ title: hasWarning ? `${args.member} shut down — uncommitted changes` : `${args.member} shut down` })
|
|
365
377
|
return result
|
|
366
378
|
},
|
|
367
379
|
}),
|
|
@@ -370,10 +382,12 @@ const plugin: Plugin = async (input) => {
|
|
|
370
382
|
description: "Clean up the team. All teammates must be shut down first. Removes team data and frees resources.",
|
|
371
383
|
args: {
|
|
372
384
|
force: tool.schema.boolean().default(false).describe("Force cleanup even if members are active (will abort them)"),
|
|
385
|
+
acknowledge_uncommitted: tool.schema.boolean().default(false),
|
|
373
386
|
},
|
|
374
387
|
async execute(args, ctx) {
|
|
375
388
|
const result = await executeTeamCleanup(deps, args, ctx.sessionID)
|
|
376
|
-
|
|
389
|
+
const blocked = result.includes("uncommitted")
|
|
390
|
+
ctx.metadata({ title: blocked ? "Cleanup blocked — uncommitted changes" : "Team cleaned up" })
|
|
377
391
|
return result
|
|
378
392
|
},
|
|
379
393
|
}),
|
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")
|
|
@@ -88,7 +92,7 @@ export function buildTeammateSystemPrompt(db: Database, teamId: string, memberNa
|
|
|
88
92
|
const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId) as { name: string } | null
|
|
89
93
|
if (!team) return ""
|
|
90
94
|
|
|
91
|
-
return `You are "${memberName}", a teammate in team "${team.name}". Use team_message to communicate.`
|
|
95
|
+
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.`
|
|
92
96
|
}
|
|
93
97
|
|
|
94
98
|
/**
|
|
@@ -131,9 +135,15 @@ export function buildTeamCompactionContext(
|
|
|
131
135
|
? `Members: ${memberList}`
|
|
132
136
|
: "Members: none"
|
|
133
137
|
|
|
134
|
-
|
|
138
|
+
const lines = [
|
|
135
139
|
roleLine,
|
|
136
140
|
membersLine,
|
|
137
141
|
`Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`,
|
|
138
|
-
]
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
if (role === "member") {
|
|
145
|
+
lines.push("IMPORTANT: You MUST send your results to the lead via team_message before stopping.")
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return lines.join("\n")
|
|
139
149
|
}
|
|
@@ -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
|
|