@hueyexe/opencode-ensemble 0.6.4 → 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 +206 -70
- package/package.json +1 -1
- package/src/client.ts +6 -0
- package/src/index.ts +10 -4
- package/src/schema.ts +2 -0
- 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 +41 -11
- 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 +133 -37
- 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/types.ts +5 -0
- 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
|
@@ -15366,7 +15366,8 @@ var MIGRATIONS = [
|
|
|
15366
15366
|
ALTER TABLE team_member ADD COLUMN worktree_branch TEXT;`,
|
|
15367
15367
|
`ALTER TABLE team_member ADD COLUMN plan_approval TEXT NOT NULL DEFAULT 'none'
|
|
15368
15368
|
CHECK(plan_approval IN ('none', 'pending', 'approved', 'rejected'));`,
|
|
15369
|
-
`ALTER TABLE team ADD COLUMN lead_agent TEXT
|
|
15369
|
+
`ALTER TABLE team ADD COLUMN lead_agent TEXT;`,
|
|
15370
|
+
`ALTER TABLE team_member ADD COLUMN workspace_id TEXT;`
|
|
15370
15371
|
];
|
|
15371
15372
|
function applyMigrations(db) {
|
|
15372
15373
|
const { user_version: current } = db.query("PRAGMA user_version").get();
|
|
@@ -15426,6 +15427,11 @@ function wrapThrowingClient(raw) {
|
|
|
15426
15427
|
remove: throwing(r.worktree.remove.bind(r.worktree)),
|
|
15427
15428
|
list: throwing(r.worktree.list.bind(r.worktree)),
|
|
15428
15429
|
reset: throwing(r.worktree.reset.bind(r.worktree))
|
|
15430
|
+
},
|
|
15431
|
+
workspace: {
|
|
15432
|
+
create: throwing(r.experimental.workspace.create.bind(r.experimental.workspace)),
|
|
15433
|
+
remove: throwing(r.experimental.workspace.remove.bind(r.experimental.workspace)),
|
|
15434
|
+
list: throwing(r.experimental.workspace.list.bind(r.experimental.workspace))
|
|
15429
15435
|
}
|
|
15430
15436
|
};
|
|
15431
15437
|
}
|
|
@@ -15770,7 +15776,7 @@ function buildLeadSystemPrompt(db, teamId) {
|
|
|
15770
15776
|
}
|
|
15771
15777
|
lines.push("--- End Messages ---");
|
|
15772
15778
|
}
|
|
15773
|
-
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.");
|
|
15774
15780
|
return lines.join(`
|
|
15775
15781
|
`);
|
|
15776
15782
|
}
|
|
@@ -15854,10 +15860,41 @@ async function executeTeamCreate(deps, args, sessionId) {
|
|
|
15854
15860
|
return `Team "${args.name}" created. You are the lead. Use team_spawn to add teammates.`;
|
|
15855
15861
|
}
|
|
15856
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
|
+
|
|
15857
15891
|
// src/tools/team-spawn.ts
|
|
15858
15892
|
function getSpawnTimeout() {
|
|
15859
15893
|
return Number(process.env.SPAWN_TIMEOUT_MS) || 120000;
|
|
15860
15894
|
}
|
|
15895
|
+
function isWorktreeDirectory(dir) {
|
|
15896
|
+
return dir.includes("/opencode/worktree/");
|
|
15897
|
+
}
|
|
15861
15898
|
function withTimeout(promise2, ms, label) {
|
|
15862
15899
|
let timer;
|
|
15863
15900
|
return Promise.race([
|
|
@@ -15871,15 +15908,11 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15871
15908
|
const nameError = validateMemberName(args.name);
|
|
15872
15909
|
if (nameError)
|
|
15873
15910
|
throw new Error(nameError);
|
|
15874
|
-
const teamInfo =
|
|
15875
|
-
if (!teamInfo)
|
|
15876
|
-
throw new Error("This session is not in a team. Use team_create first.");
|
|
15877
|
-
if (teamInfo.role !== "lead")
|
|
15878
|
-
throw new Error("Only the team lead can spawn teammates.");
|
|
15911
|
+
const teamInfo = requireLead(deps, sessionId);
|
|
15879
15912
|
const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.name);
|
|
15880
15913
|
if (existing)
|
|
15881
15914
|
throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
|
|
15882
|
-
const useWorktree = args.worktree !== false;
|
|
15915
|
+
const useWorktree = args.worktree !== false && !isWorktreeDirectory(deps.directory);
|
|
15883
15916
|
const usePlanApproval = args.plan_approval === true;
|
|
15884
15917
|
log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`);
|
|
15885
15918
|
let worktreeDir = null;
|
|
@@ -15906,26 +15939,50 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15906
15939
|
} catch {}
|
|
15907
15940
|
}
|
|
15908
15941
|
}
|
|
15942
|
+
let workspaceId = null;
|
|
15943
|
+
if (worktreeDir && worktreeBranch) {
|
|
15944
|
+
try {
|
|
15945
|
+
log(`spawn:workspace:start name=${args.name}`);
|
|
15946
|
+
const wsResult = await withTimeout(deps.client.workspace.create({ branch: worktreeBranch }), getSpawnTimeout(), `workspace.create for "${args.name}"`);
|
|
15947
|
+
if (wsResult.data) {
|
|
15948
|
+
workspaceId = wsResult.data.id;
|
|
15949
|
+
}
|
|
15950
|
+
log(`spawn:workspace:done name=${args.name} id=${workspaceId}`);
|
|
15951
|
+
} catch (err) {
|
|
15952
|
+
log(`spawn:workspace:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`);
|
|
15953
|
+
}
|
|
15954
|
+
}
|
|
15909
15955
|
const isReadOnly = args.agent === "plan" || args.agent === "explore";
|
|
15910
15956
|
const TEAM_TOOLS = ["team_message", "team_broadcast", "team_tasks_list", "team_tasks_add", "team_tasks_complete", "team_claim"];
|
|
15911
|
-
const permission =
|
|
15912
|
-
|
|
15913
|
-
{ permission: "
|
|
15914
|
-
|
|
15915
|
-
|
|
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" })));
|
|
15916
15968
|
let childSessionId;
|
|
15917
15969
|
try {
|
|
15918
15970
|
log(`spawn:session:start name=${args.name}`);
|
|
15919
15971
|
const createResult = await withTimeout(deps.client.session.create({
|
|
15920
15972
|
parentID: sessionId,
|
|
15921
15973
|
title: `${args.name} (@${args.agent} teammate)`,
|
|
15922
|
-
|
|
15923
|
-
...
|
|
15974
|
+
permission,
|
|
15975
|
+
...workspaceId ? { workspaceID: workspaceId } : {}
|
|
15924
15976
|
}), getSpawnTimeout(), `session.create for "${args.name}"`);
|
|
15925
15977
|
childSessionId = createResult.data?.id;
|
|
15926
15978
|
log(`spawn:session:done name=${args.name} sessionId=${childSessionId}`);
|
|
15927
15979
|
} catch (err) {
|
|
15928
15980
|
log(`spawn:session:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`);
|
|
15981
|
+
if (workspaceId) {
|
|
15982
|
+
try {
|
|
15983
|
+
await deps.client.workspace.remove({ id: workspaceId });
|
|
15984
|
+
} catch {}
|
|
15985
|
+
}
|
|
15929
15986
|
if (worktreeDir) {
|
|
15930
15987
|
try {
|
|
15931
15988
|
await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
|
|
@@ -15934,6 +15991,11 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15934
15991
|
throw new Error(`Failed to create session for teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
15935
15992
|
}
|
|
15936
15993
|
if (!childSessionId) {
|
|
15994
|
+
if (workspaceId) {
|
|
15995
|
+
try {
|
|
15996
|
+
await deps.client.workspace.remove({ id: workspaceId });
|
|
15997
|
+
} catch {}
|
|
15998
|
+
}
|
|
15937
15999
|
if (worktreeDir) {
|
|
15938
16000
|
try {
|
|
15939
16001
|
await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
|
|
@@ -15943,20 +16005,46 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15943
16005
|
}
|
|
15944
16006
|
const planApproval = usePlanApproval ? "pending" : "none";
|
|
15945
16007
|
const now = Date.now();
|
|
15946
|
-
deps.db.run(`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, plan_approval, time_created, time_updated)
|
|
15947
|
-
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, planApproval, now, now]);
|
|
16008
|
+
deps.db.run(`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, workspace_id, plan_approval, time_created, time_updated)
|
|
16009
|
+
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]);
|
|
15948
16010
|
deps.registry.register(teamInfo.teamId, args.name, childSessionId);
|
|
15949
16011
|
const context = [
|
|
15950
16012
|
`You are "${args.name}", a teammate in team "${teamInfo.teamName}".`,
|
|
15951
16013
|
`Your agent type is "${args.agent}".`
|
|
15952
16014
|
];
|
|
15953
|
-
if (worktreeBranch) {
|
|
15954
|
-
context.push(`You are working on branch "${worktreeBranch}" in your own worktree
|
|
16015
|
+
if (worktreeBranch && worktreeDir && !workspaceId) {
|
|
16016
|
+
context.push(`You are working on branch "${worktreeBranch}" in your own worktree at: ${worktreeDir}`, `Your changes are isolated from other teammates.`, `IMPORTANT: All file operations and shell commands MUST target your worktree directory.`, `Before running shell commands, cd to: ${worktreeDir}`);
|
|
16017
|
+
} else if (worktreeBranch && worktreeDir) {
|
|
16018
|
+
context.push(`You are working on branch "${worktreeBranch}" in your own isolated worktree.`, `Your changes are isolated from other teammates.`);
|
|
16019
|
+
} else if (worktreeBranch) {
|
|
16020
|
+
context.push(`You are working on branch "${worktreeBranch}". Your changes are isolated from other teammates.`);
|
|
15955
16021
|
}
|
|
15956
16022
|
if (usePlanApproval) {
|
|
15957
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.");
|
|
15958
16024
|
}
|
|
15959
|
-
|
|
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.");
|
|
15960
16048
|
context.push("", "Your task:", args.prompt);
|
|
15961
16049
|
if (args.claim_task) {
|
|
15962
16050
|
context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`);
|
|
@@ -15974,6 +16062,9 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15974
16062
|
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId]);
|
|
15975
16063
|
deps.registry.unregister(childSessionId);
|
|
15976
16064
|
deps.client.session.abort({ sessionID: childSessionId }).catch(() => {});
|
|
16065
|
+
if (workspaceId) {
|
|
16066
|
+
deps.client.workspace.remove({ id: workspaceId }).catch(() => {});
|
|
16067
|
+
}
|
|
15977
16068
|
if (worktreeDir) {
|
|
15978
16069
|
deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => {});
|
|
15979
16070
|
}
|
|
@@ -15999,9 +16090,7 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15999
16090
|
|
|
16000
16091
|
// src/tools/team-message.ts
|
|
16001
16092
|
async function executeTeamMessage(deps, args, sessionId) {
|
|
16002
|
-
const teamInfo =
|
|
16003
|
-
if (!teamInfo)
|
|
16004
|
-
throw new Error("This session is not in a team. Use team_create first.");
|
|
16093
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16005
16094
|
const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
|
|
16006
16095
|
const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to);
|
|
16007
16096
|
if (!recipientSessionId)
|
|
@@ -16057,9 +16146,7 @@ async function executeTeamMessage(deps, args, sessionId) {
|
|
|
16057
16146
|
|
|
16058
16147
|
// src/tools/team-broadcast.ts
|
|
16059
16148
|
async function executeTeamBroadcast(deps, args, sessionId) {
|
|
16060
|
-
const teamInfo =
|
|
16061
|
-
if (!teamInfo)
|
|
16062
|
-
throw new Error("This session is not in a team. Use team_create first.");
|
|
16149
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16063
16150
|
const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
|
|
16064
16151
|
const msgId = broadcastMessage(deps.db, {
|
|
16065
16152
|
teamId: teamInfo.teamId,
|
|
@@ -16095,13 +16182,9 @@ async function executeTeamBroadcast(deps, args, sessionId) {
|
|
|
16095
16182
|
}
|
|
16096
16183
|
|
|
16097
16184
|
// src/tools/team-shutdown.ts
|
|
16098
|
-
async function executeTeamShutdown(deps, args, sessionId) {
|
|
16099
|
-
const teamInfo =
|
|
16100
|
-
|
|
16101
|
-
throw new Error("This session is not in a team.");
|
|
16102
|
-
if (teamInfo.role !== "lead")
|
|
16103
|
-
throw new Error("Only the team lead can shut down teammates.");
|
|
16104
|
-
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);
|
|
16105
16188
|
if (!member)
|
|
16106
16189
|
throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
|
|
16107
16190
|
if (member.status === "shutdown")
|
|
@@ -16110,7 +16193,8 @@ async function executeTeamShutdown(deps, args, sessionId) {
|
|
|
16110
16193
|
if (member.status === "shutdown_requested") {
|
|
16111
16194
|
await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id);
|
|
16112
16195
|
const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : "";
|
|
16113
|
-
|
|
16196
|
+
const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty);
|
|
16197
|
+
return `Force shut down "${args.member}".${branchInfo}${dirtyWarning}`;
|
|
16114
16198
|
}
|
|
16115
16199
|
let isIdle = false;
|
|
16116
16200
|
try {
|
|
@@ -16121,7 +16205,8 @@ async function executeTeamShutdown(deps, args, sessionId) {
|
|
|
16121
16205
|
if (isIdle || force) {
|
|
16122
16206
|
await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id);
|
|
16123
16207
|
const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : "";
|
|
16124
|
-
|
|
16208
|
+
const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty);
|
|
16209
|
+
return `Teammate "${args.member}" has been shut down.${branchInfo}${dirtyWarning}`;
|
|
16125
16210
|
}
|
|
16126
16211
|
try {
|
|
16127
16212
|
await deps.client.session.promptAsync({
|
|
@@ -16141,20 +16226,50 @@ async function abortAndShutdown(deps, teamId, memberName, sessionId) {
|
|
|
16141
16226
|
} catch {}
|
|
16142
16227
|
deps.db.run("UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamId, memberName]);
|
|
16143
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
|
+
}
|
|
16144
16241
|
|
|
16145
16242
|
// src/tools/team-cleanup.ts
|
|
16146
|
-
async function executeTeamCleanup(deps, args, sessionId) {
|
|
16147
|
-
const teamInfo =
|
|
16148
|
-
|
|
16149
|
-
throw new Error("This session is not in a team.");
|
|
16150
|
-
if (teamInfo.role !== "lead")
|
|
16151
|
-
throw new Error("Only the team lead can clean up the team.");
|
|
16152
|
-
const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch FROM team_member WHERE team_id = ?").all(teamInfo.teamId);
|
|
16243
|
+
async function executeTeamCleanup(deps, args, sessionId, isDirty = checkWorktreeDirty) {
|
|
16244
|
+
const teamInfo = requireLead(deps, sessionId);
|
|
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);
|
|
16153
16246
|
const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
|
|
16154
16247
|
if (active.length > 0 && !args.force) {
|
|
16155
16248
|
const names = active.map((m) => m.name).join(", ");
|
|
16156
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.`);
|
|
16157
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
|
+
}
|
|
16158
16273
|
if (args.force) {
|
|
16159
16274
|
for (const member of active) {
|
|
16160
16275
|
try {
|
|
@@ -16164,9 +16279,16 @@ async function executeTeamCleanup(deps, args, sessionId) {
|
|
|
16164
16279
|
}
|
|
16165
16280
|
const branches = [];
|
|
16166
16281
|
for (const member of members) {
|
|
16282
|
+
if (member.workspace_id) {
|
|
16283
|
+
try {
|
|
16284
|
+
await deps.client.workspace.remove({ id: member.workspace_id });
|
|
16285
|
+
deps.db.run("UPDATE team_member SET workspace_id = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name]);
|
|
16286
|
+
} catch {}
|
|
16287
|
+
}
|
|
16167
16288
|
if (member.worktree_dir) {
|
|
16168
16289
|
try {
|
|
16169
16290
|
await deps.client.worktree.remove({ worktreeRemoveInput: { directory: member.worktree_dir } });
|
|
16291
|
+
deps.db.run("UPDATE team_member SET worktree_dir = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name]);
|
|
16170
16292
|
} catch {}
|
|
16171
16293
|
}
|
|
16172
16294
|
if (member.worktree_branch) {
|
|
@@ -16188,9 +16310,7 @@ ${mergeCommands}`;
|
|
|
16188
16310
|
|
|
16189
16311
|
// src/tools/team-tasks-list.ts
|
|
16190
16312
|
async function executeTeamTasksList(deps, sessionId) {
|
|
16191
|
-
const teamInfo =
|
|
16192
|
-
if (!teamInfo)
|
|
16193
|
-
throw new Error("This session is not in a team.");
|
|
16313
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16194
16314
|
const tasks = deps.db.query("SELECT * FROM team_task WHERE team_id = ? ORDER BY time_created ASC").all(teamInfo.teamId);
|
|
16195
16315
|
if (tasks.length === 0)
|
|
16196
16316
|
return "No tasks on the board.";
|
|
@@ -16200,9 +16320,7 @@ async function executeTeamTasksList(deps, sessionId) {
|
|
|
16200
16320
|
|
|
16201
16321
|
// src/tools/team-tasks-add.ts
|
|
16202
16322
|
async function executeTeamTasksAdd(deps, args, sessionId) {
|
|
16203
|
-
const teamInfo =
|
|
16204
|
-
if (!teamInfo)
|
|
16205
|
-
throw new Error("This session is not in a team.");
|
|
16323
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16206
16324
|
const ids = [];
|
|
16207
16325
|
const now = Date.now();
|
|
16208
16326
|
for (const task of args.tasks) {
|
|
@@ -16225,9 +16343,7 @@ async function executeTeamTasksAdd(deps, args, sessionId) {
|
|
|
16225
16343
|
|
|
16226
16344
|
// src/tools/team-tasks-complete.ts
|
|
16227
16345
|
async function executeTeamTasksComplete(deps, args, sessionId) {
|
|
16228
|
-
const teamInfo =
|
|
16229
|
-
if (!teamInfo)
|
|
16230
|
-
throw new Error("This session is not in a team.");
|
|
16346
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16231
16347
|
const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?").get(args.task_id, teamInfo.teamId);
|
|
16232
16348
|
if (!task)
|
|
16233
16349
|
throw new Error(`Task "${args.task_id}" not found`);
|
|
@@ -16258,9 +16374,7 @@ async function executeTeamTasksComplete(deps, args, sessionId) {
|
|
|
16258
16374
|
|
|
16259
16375
|
// src/tools/team-claim.ts
|
|
16260
16376
|
async function executeTeamClaim(deps, args, sessionId) {
|
|
16261
|
-
const teamInfo =
|
|
16262
|
-
if (!teamInfo)
|
|
16263
|
-
throw new Error("This session is not in a team.");
|
|
16377
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16264
16378
|
const claimerName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
|
|
16265
16379
|
const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?").get(args.task_id, teamInfo.teamId);
|
|
16266
16380
|
if (!task)
|
|
@@ -16280,9 +16394,7 @@ async function executeTeamClaim(deps, args, sessionId) {
|
|
|
16280
16394
|
|
|
16281
16395
|
// src/tools/team-results.ts
|
|
16282
16396
|
async function executeTeamResults(deps, args, sessionId) {
|
|
16283
|
-
const team =
|
|
16284
|
-
if (!team)
|
|
16285
|
-
throw new Error("not in a team");
|
|
16397
|
+
const team = requireTeamMember(deps, sessionId);
|
|
16286
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);
|
|
16287
16399
|
if (rows.length === 0)
|
|
16288
16400
|
return "No unread messages.";
|
|
@@ -16299,16 +16411,14 @@ ${r.content}
|
|
|
16299
16411
|
var lastCallTime = new Map;
|
|
16300
16412
|
var RATE_LIMIT_MS = 30000;
|
|
16301
16413
|
async function executeTeamStatus(deps, sessionId) {
|
|
16302
|
-
const teamInfo =
|
|
16303
|
-
if (!teamInfo)
|
|
16304
|
-
throw new Error("This session is not in a team.");
|
|
16414
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16305
16415
|
const now = Date.now();
|
|
16306
16416
|
const last = lastCallTime.get(teamInfo.teamId);
|
|
16307
16417
|
if (last !== undefined && now - last < RATE_LIMIT_MS) {
|
|
16308
16418
|
return "No changes since last check.";
|
|
16309
16419
|
}
|
|
16310
16420
|
lastCallTime.set(teamInfo.teamId, now);
|
|
16311
|
-
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);
|
|
16312
16422
|
const tasks = deps.db.query("SELECT status FROM team_task WHERE team_id = ?").all(teamInfo.teamId);
|
|
16313
16423
|
const lines = [];
|
|
16314
16424
|
lines.push(`Team: ${teamInfo.teamName} (you are the ${teamInfo.role})`);
|
|
@@ -16322,6 +16432,9 @@ async function executeTeamStatus(deps, sessionId) {
|
|
|
16322
16432
|
const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : "";
|
|
16323
16433
|
const plan = m.plan_approval !== "none" ? `, plan: ${m.plan_approval}` : "";
|
|
16324
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
|
+
}
|
|
16325
16438
|
}
|
|
16326
16439
|
}
|
|
16327
16440
|
if (tasks.length > 0) {
|
|
@@ -16358,9 +16471,7 @@ async function executeTeamStatus(deps, sessionId) {
|
|
|
16358
16471
|
|
|
16359
16472
|
// src/tools/team-view.ts
|
|
16360
16473
|
async function executeTeamView(deps, args, sessionId) {
|
|
16361
|
-
const teamInfo =
|
|
16362
|
-
if (!teamInfo)
|
|
16363
|
-
throw new Error("This session is not in a team.");
|
|
16474
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
16364
16475
|
const member = deps.db.query("SELECT session_id, status, agent FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
|
|
16365
16476
|
if (!member)
|
|
16366
16477
|
throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
|
|
@@ -16450,7 +16561,28 @@ class Watchdog {
|
|
|
16450
16561
|
this.ttlMs = opts.ttlMs;
|
|
16451
16562
|
this.checkIntervalMs = opts.checkIntervalMs ?? 60000;
|
|
16452
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
|
+
}
|
|
16453
16584
|
async check() {
|
|
16585
|
+
await this.cleanupStaleWorktrees();
|
|
16454
16586
|
if (this.ttlMs === 0)
|
|
16455
16587
|
return;
|
|
16456
16588
|
const cutoff = Date.now() - this.ttlMs;
|
|
@@ -16478,8 +16610,6 @@ class Watchdog {
|
|
|
16478
16610
|
start() {
|
|
16479
16611
|
if (this.timer)
|
|
16480
16612
|
return;
|
|
16481
|
-
if (this.ttlMs === 0)
|
|
16482
|
-
return;
|
|
16483
16613
|
this.timer = setInterval(() => this.check(), this.checkIntervalMs);
|
|
16484
16614
|
}
|
|
16485
16615
|
stop() {
|
|
@@ -16638,10 +16768,13 @@ var plugin = async (input) => {
|
|
|
16638
16768
|
output.env.ENSEMBLE_ROLE = teamInfo.role;
|
|
16639
16769
|
if (teamInfo.memberName) {
|
|
16640
16770
|
output.env.ENSEMBLE_MEMBER = teamInfo.memberName;
|
|
16641
|
-
const member = db.query("SELECT worktree_branch FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, teamInfo.memberName);
|
|
16771
|
+
const member = db.query("SELECT worktree_branch, worktree_dir FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, teamInfo.memberName);
|
|
16642
16772
|
if (member?.worktree_branch) {
|
|
16643
16773
|
output.env.ENSEMBLE_BRANCH = member.worktree_branch;
|
|
16644
16774
|
}
|
|
16775
|
+
if (member?.worktree_dir) {
|
|
16776
|
+
output.env.ENSEMBLE_WORKTREE_DIR = member.worktree_dir;
|
|
16777
|
+
}
|
|
16645
16778
|
}
|
|
16646
16779
|
},
|
|
16647
16780
|
tool: {
|
|
@@ -16765,18 +16898,21 @@ var plugin = async (input) => {
|
|
|
16765
16898
|
},
|
|
16766
16899
|
async execute(args, ctx) {
|
|
16767
16900
|
const result = await executeTeamShutdown(deps, args, ctx.sessionID);
|
|
16768
|
-
|
|
16901
|
+
const hasWarning = result.includes("uncommitted");
|
|
16902
|
+
ctx.metadata({ title: hasWarning ? `${args.member} shut down \u2014 uncommitted changes` : `${args.member} shut down` });
|
|
16769
16903
|
return result;
|
|
16770
16904
|
}
|
|
16771
16905
|
}),
|
|
16772
16906
|
team_cleanup: tool({
|
|
16773
16907
|
description: "Clean up the team. All teammates must be shut down first. Removes team data and frees resources.",
|
|
16774
16908
|
args: {
|
|
16775
|
-
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)
|
|
16776
16911
|
},
|
|
16777
16912
|
async execute(args, ctx) {
|
|
16778
16913
|
const result = await executeTeamCleanup(deps, args, ctx.sessionID);
|
|
16779
|
-
|
|
16914
|
+
const blocked = result.includes("uncommitted");
|
|
16915
|
+
ctx.metadata({ title: blocked ? "Cleanup blocked \u2014 uncommitted changes" : "Team cleaned up" });
|
|
16780
16916
|
return result;
|
|
16781
16917
|
}
|
|
16782
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/client.ts
CHANGED
|
@@ -25,6 +25,7 @@ interface RawClient {
|
|
|
25
25
|
session: { create: SdkMethod; promptAsync: SdkMethod; abort: SdkMethod; status: SdkMethod }
|
|
26
26
|
tui: { showToast: SdkMethod; selectSession: SdkMethod }
|
|
27
27
|
worktree: { create: SdkMethod; remove: SdkMethod; list: SdkMethod; reset: SdkMethod }
|
|
28
|
+
experimental: { workspace: { create: SdkMethod; remove: SdkMethod; list: SdkMethod } }
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
/**
|
|
@@ -50,5 +51,10 @@ export function wrapThrowingClient(raw: unknown): PluginClient {
|
|
|
50
51
|
list: throwing(r.worktree.list.bind(r.worktree)),
|
|
51
52
|
reset: throwing(r.worktree.reset.bind(r.worktree)),
|
|
52
53
|
},
|
|
54
|
+
workspace: {
|
|
55
|
+
create: throwing(r.experimental.workspace.create.bind(r.experimental.workspace)),
|
|
56
|
+
remove: throwing(r.experimental.workspace.remove.bind(r.experimental.workspace)),
|
|
57
|
+
list: throwing(r.experimental.workspace.list.bind(r.experimental.workspace)),
|
|
58
|
+
},
|
|
53
59
|
} as PluginClient
|
|
54
60
|
}
|
package/src/index.ts
CHANGED
|
@@ -217,11 +217,14 @@ const plugin: Plugin = async (input) => {
|
|
|
217
217
|
output.env.ENSEMBLE_ROLE = teamInfo.role
|
|
218
218
|
if (teamInfo.memberName) {
|
|
219
219
|
output.env.ENSEMBLE_MEMBER = teamInfo.memberName
|
|
220
|
-
const member = db.query("SELECT worktree_branch FROM team_member WHERE team_id = ? AND name = ?")
|
|
221
|
-
.get(teamInfo.teamId, teamInfo.memberName) as { worktree_branch: string | null } | null
|
|
220
|
+
const member = db.query("SELECT worktree_branch, worktree_dir FROM team_member WHERE team_id = ? AND name = ?")
|
|
221
|
+
.get(teamInfo.teamId, teamInfo.memberName) as { worktree_branch: string | null; worktree_dir: string | null } | null
|
|
222
222
|
if (member?.worktree_branch) {
|
|
223
223
|
output.env.ENSEMBLE_BRANCH = member.worktree_branch
|
|
224
224
|
}
|
|
225
|
+
if (member?.worktree_dir) {
|
|
226
|
+
output.env.ENSEMBLE_WORKTREE_DIR = member.worktree_dir
|
|
227
|
+
}
|
|
225
228
|
}
|
|
226
229
|
},
|
|
227
230
|
|
|
@@ -358,7 +361,8 @@ const plugin: Plugin = async (input) => {
|
|
|
358
361
|
},
|
|
359
362
|
async execute(args, ctx) {
|
|
360
363
|
const result = await executeTeamShutdown(deps, args, ctx.sessionID)
|
|
361
|
-
|
|
364
|
+
const hasWarning = result.includes("uncommitted")
|
|
365
|
+
ctx.metadata({ title: hasWarning ? `${args.member} shut down — uncommitted changes` : `${args.member} shut down` })
|
|
362
366
|
return result
|
|
363
367
|
},
|
|
364
368
|
}),
|
|
@@ -367,10 +371,12 @@ const plugin: Plugin = async (input) => {
|
|
|
367
371
|
description: "Clean up the team. All teammates must be shut down first. Removes team data and frees resources.",
|
|
368
372
|
args: {
|
|
369
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),
|
|
370
375
|
},
|
|
371
376
|
async execute(args, ctx) {
|
|
372
377
|
const result = await executeTeamCleanup(deps, args, ctx.sessionID)
|
|
373
|
-
|
|
378
|
+
const blocked = result.includes("uncommitted")
|
|
379
|
+
ctx.metadata({ title: blocked ? "Cleanup blocked — uncommitted changes" : "Team cleaned up" })
|
|
374
380
|
return result
|
|
375
381
|
},
|
|
376
382
|
}),
|
package/src/schema.ts
CHANGED
|
@@ -78,6 +78,8 @@ export const MIGRATIONS: string[] = [
|
|
|
78
78
|
CHECK(plan_approval IN ('none', 'pending', 'approved', 'rejected'));`,
|
|
79
79
|
// Migration 5: Track lead's agent mode so message delivery preserves it
|
|
80
80
|
`ALTER TABLE team ADD COLUMN lead_agent TEXT;`,
|
|
81
|
+
// Migration 6: Track workspace ID for worktree-session binding
|
|
82
|
+
`ALTER TABLE team_member ADD COLUMN workspace_id TEXT;`,
|
|
81
83
|
]
|
|
82
84
|
|
|
83
85
|
/**
|