@hueyexe/opencode-ensemble 0.13.2 → 0.14.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 +9 -7
- package/dist/index.js +476 -9
- package/package.json +1 -1
- package/src/index.ts +35 -5
- package/src/state.ts +107 -0
- package/src/system-prompt.ts +7 -0
- package/src/tools/shared.ts +35 -0
- package/src/tools/team-cleanup.ts +422 -2
- package/src/tools/team-spawn.ts +2 -2
- package/src/types.ts +2 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
[](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
|
|
8
8
|
[](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
|
|
9
|
-
[]()
|
|
10
10
|
[]()
|
|
11
11
|
[]()
|
|
12
12
|
[](./LICENSE)
|
|
@@ -19,7 +19,7 @@ Plugin built on the public OpenCode SDK. No internal dependencies.
|
|
|
19
19
|
|
|
20
20
|
```json
|
|
21
21
|
{
|
|
22
|
-
"plugin": ["@hueyexe/opencode-ensemble@0.
|
|
22
|
+
"plugin": ["@hueyexe/opencode-ensemble@0.14.0"]
|
|
23
23
|
}
|
|
24
24
|
```
|
|
25
25
|
|
|
@@ -140,7 +140,7 @@ Add to your OpenCode config with a pinned version. Project-level or global.
|
|
|
140
140
|
|
|
141
141
|
```json
|
|
142
142
|
{
|
|
143
|
-
"plugin": ["@hueyexe/opencode-ensemble@0.
|
|
143
|
+
"plugin": ["@hueyexe/opencode-ensemble@0.14.0"]
|
|
144
144
|
}
|
|
145
145
|
```
|
|
146
146
|
|
|
@@ -148,7 +148,7 @@ Add to your OpenCode config with a pinned version. Project-level or global.
|
|
|
148
148
|
|
|
149
149
|
```json
|
|
150
150
|
{
|
|
151
|
-
"plugin": ["@hueyexe/opencode-ensemble@0.
|
|
151
|
+
"plugin": ["@hueyexe/opencode-ensemble@0.14.0"]
|
|
152
152
|
}
|
|
153
153
|
```
|
|
154
154
|
|
|
@@ -198,7 +198,7 @@ Build with `bun run build`, then restart OpenCode to pick up changes.
|
|
|
198
198
|
|
|
199
199
|
14 tools. The lead has all of them. Teammates get 6 (messaging + tasks).
|
|
200
200
|
|
|
201
|
-
**Team lifecycle** (lead only)
|
|
201
|
+
**Team lifecycle** (lead only, except archived-team purge may also be run from the main session)
|
|
202
202
|
|
|
203
203
|
| Tool | What it does |
|
|
204
204
|
|------|-------------|
|
|
@@ -206,10 +206,12 @@ Build with `bun run build`, then restart OpenCode to pick up changes.
|
|
|
206
206
|
| `team_spawn` | Start a new teammate with a task. Supports `plan_approval` mode. |
|
|
207
207
|
| `team_shutdown` | Ask a teammate to stop. Preserves their branch before aborting. Supports `force` flag. |
|
|
208
208
|
| `team_merge` | Merge a shutdown teammate's branch into working directory (unstaged). Blocks if you have local changes to overlapping files. |
|
|
209
|
-
| `team_cleanup` | Remove the team when done. Safety-net merges
|
|
209
|
+
| `team_cleanup` | Remove the current team when done. Safety-net merges forgotten branches. With `purge`, previews archived-team deletion and returns exact approval labels plus a confirmation token. |
|
|
210
210
|
| `team_status` | See all members, their status, and a task summary. |
|
|
211
211
|
| `team_view` | Switch the TUI to a teammate's session. |
|
|
212
212
|
|
|
213
|
+
Archived-team purge is intentionally two-step. First call `team_cleanup` with `purge` to get a preview, exact approval and denial option labels, and `confirm_token`; no data is deleted. Stale archived worktree/workspace references and stale Ensemble-owned branches are counted in the preview and cleaned during confirmed purge. Arbitrary non-Ensemble branches still block purge for safety. The lead must then use the question tool with those exact options. Only after the user selects the exact approval option should it call `team_cleanup` again with the same `purge`, `confirm_purge: true`, and the preview token.
|
|
214
|
+
|
|
213
215
|
**Communication** (everyone)
|
|
214
216
|
|
|
215
217
|
| Tool | What it does |
|
|
@@ -410,7 +412,7 @@ Same coordination model (shared tasks, peer messaging, lead coordination) with s
|
|
|
410
412
|
```bash
|
|
411
413
|
bun install
|
|
412
414
|
bun run typecheck
|
|
413
|
-
bun test #
|
|
415
|
+
bun test # 558 tests
|
|
414
416
|
bun run build
|
|
415
417
|
```
|
|
416
418
|
|
package/dist/index.js
CHANGED
|
@@ -15697,6 +15697,8 @@ async function recoverOrphanedBranches(db, cwd) {
|
|
|
15697
15697
|
}
|
|
15698
15698
|
|
|
15699
15699
|
// src/state.ts
|
|
15700
|
+
import { randomUUID } from "crypto";
|
|
15701
|
+
|
|
15700
15702
|
class MemberRegistry {
|
|
15701
15703
|
bySession = new Map;
|
|
15702
15704
|
byTeamName = new Map;
|
|
@@ -15740,6 +15742,36 @@ class MemberRegistry {
|
|
|
15740
15742
|
}
|
|
15741
15743
|
}
|
|
15742
15744
|
var DEFAULT_MAX_DEPTH = 10;
|
|
15745
|
+
var DEFAULT_PURGE_APPROVAL_TTL_MS = 10 * 60 * 1000;
|
|
15746
|
+
function canonicalPurgeKey(purge) {
|
|
15747
|
+
if (purge.includes("*"))
|
|
15748
|
+
return JSON.stringify(["*"]);
|
|
15749
|
+
return JSON.stringify([...new Set(purge)].sort());
|
|
15750
|
+
}
|
|
15751
|
+
function outputSelectedAnswer(output, label) {
|
|
15752
|
+
const quoted = JSON.stringify(label);
|
|
15753
|
+
return output.includes(`=${quoted}`) || output.includes(`=[${quoted}]`);
|
|
15754
|
+
}
|
|
15755
|
+
function optionLabels(args) {
|
|
15756
|
+
if (!args || typeof args !== "object")
|
|
15757
|
+
return [];
|
|
15758
|
+
const questions = args.questions;
|
|
15759
|
+
if (!Array.isArray(questions))
|
|
15760
|
+
return [];
|
|
15761
|
+
return questions.flatMap((question) => {
|
|
15762
|
+
if (!question || typeof question !== "object")
|
|
15763
|
+
return [];
|
|
15764
|
+
const options = question.options;
|
|
15765
|
+
if (!Array.isArray(options))
|
|
15766
|
+
return [];
|
|
15767
|
+
return options.flatMap((option) => {
|
|
15768
|
+
if (!option || typeof option !== "object")
|
|
15769
|
+
return [];
|
|
15770
|
+
const label = option.label;
|
|
15771
|
+
return typeof label === "string" ? [label] : [];
|
|
15772
|
+
});
|
|
15773
|
+
});
|
|
15774
|
+
}
|
|
15743
15775
|
|
|
15744
15776
|
class DescendantTracker {
|
|
15745
15777
|
parents = new Map;
|
|
@@ -15766,6 +15798,65 @@ class DescendantTracker {
|
|
|
15766
15798
|
}
|
|
15767
15799
|
}
|
|
15768
15800
|
|
|
15801
|
+
class PendingPurgeApprovals {
|
|
15802
|
+
pending = new Map;
|
|
15803
|
+
ttlMs;
|
|
15804
|
+
now;
|
|
15805
|
+
createToken;
|
|
15806
|
+
constructor(ttlMs = DEFAULT_PURGE_APPROVAL_TTL_MS, now = () => Date.now(), createToken = randomUUID) {
|
|
15807
|
+
this.ttlMs = ttlMs;
|
|
15808
|
+
this.now = now;
|
|
15809
|
+
this.createToken = createToken;
|
|
15810
|
+
}
|
|
15811
|
+
create(sessionId, purge) {
|
|
15812
|
+
this.pruneExpired();
|
|
15813
|
+
const token = this.createToken();
|
|
15814
|
+
this.pending.set(token, {
|
|
15815
|
+
sessionId,
|
|
15816
|
+
purgeKey: canonicalPurgeKey(purge),
|
|
15817
|
+
approved: false,
|
|
15818
|
+
timeCreated: this.now()
|
|
15819
|
+
});
|
|
15820
|
+
return token;
|
|
15821
|
+
}
|
|
15822
|
+
approvalLabel(token) {
|
|
15823
|
+
return `Approve purge ${token.slice(0, 8)}`;
|
|
15824
|
+
}
|
|
15825
|
+
denialLabel(token) {
|
|
15826
|
+
return `Deny purge ${token.slice(0, 8)}`;
|
|
15827
|
+
}
|
|
15828
|
+
recordQuestionAnswer(sessionId, output, args) {
|
|
15829
|
+
this.pruneExpired();
|
|
15830
|
+
const labels = new Set(optionLabels(args));
|
|
15831
|
+
for (const [token, approval] of this.pending.entries()) {
|
|
15832
|
+
const approvalLabel = this.approvalLabel(token);
|
|
15833
|
+
const denialLabel = this.denialLabel(token);
|
|
15834
|
+
const hasRequiredOptions = labels.has(approvalLabel) && labels.has(denialLabel);
|
|
15835
|
+
if (approval.sessionId === sessionId && hasRequiredOptions && outputSelectedAnswer(output, approvalLabel)) {
|
|
15836
|
+
approval.approved = true;
|
|
15837
|
+
}
|
|
15838
|
+
}
|
|
15839
|
+
}
|
|
15840
|
+
consume(sessionId, token, purge) {
|
|
15841
|
+
this.pruneExpired();
|
|
15842
|
+
const approval = this.pending.get(token);
|
|
15843
|
+
if (!approval || approval.sessionId !== sessionId || approval.purgeKey !== canonicalPurgeKey(purge)) {
|
|
15844
|
+
throw new Error("Purge confirmation token is invalid or expired.");
|
|
15845
|
+
}
|
|
15846
|
+
if (!approval.approved) {
|
|
15847
|
+
throw new Error("Purge was not approved by the user. Use the question tool with the exact approval option from the preview before confirming.");
|
|
15848
|
+
}
|
|
15849
|
+
this.pending.delete(token);
|
|
15850
|
+
}
|
|
15851
|
+
pruneExpired() {
|
|
15852
|
+
const cutoff = this.now() - this.ttlMs;
|
|
15853
|
+
for (const [token, approval] of this.pending.entries()) {
|
|
15854
|
+
if (approval.timeCreated < cutoff)
|
|
15855
|
+
this.pending.delete(token);
|
|
15856
|
+
}
|
|
15857
|
+
}
|
|
15858
|
+
}
|
|
15859
|
+
|
|
15769
15860
|
// src/hooks.ts
|
|
15770
15861
|
var TEAM_TOOL_PREFIX = "team_";
|
|
15771
15862
|
function handleSessionStatusEvent(db, registry2, sessionId, status) {
|
|
@@ -15983,7 +16074,7 @@ function buildLeadSystemPrompt(db, teamId, config2) {
|
|
|
15983
16074
|
}
|
|
15984
16075
|
lines.push(`If user picks "I'll choose per agent", ask for each agent's model individually before each team_spawn call.`, "Pass the chosen model via the model parameter on each team_spawn call.");
|
|
15985
16076
|
}
|
|
15986
|
-
lines.push("", "Spawn teammates ONE AT A TIME. Wait for each tool result before spawning the next.", "This avoids git worktree contention. Once all are spawned, wait for their messages.", "", "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.", "", "MERGE WORKFLOW:", "After a teammate finishes and you shut them down, use team_merge to merge their branch.", "Do NOT tell teammates to commit \u2014 they handle that themselves.", "Do NOT run git merge manually \u2014 use team_merge which squash-merges and unstages for you.", "team_cleanup will safety-net merge any branches you forgot, but prefer explicit team_merge.", "", "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.");
|
|
16077
|
+
lines.push("", "Spawn teammates ONE AT A TIME. Wait for each tool result before spawning the next.", "This avoids git worktree contention. Once all are spawned, wait for their messages.", "Read-only agents (explore, plan) automatically skip worktree creation.", "For other agents that only need to read, pass worktree: false to avoid unnecessary isolation.", "", "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.", "", "MERGE WORKFLOW:", "After a teammate finishes and you shut them down, use team_merge to merge their branch.", "Do NOT tell teammates to commit \u2014 they handle that themselves.", "Do NOT run git merge manually \u2014 use team_merge which squash-merges and unstages for you.", "team_cleanup will safety-net merge any branches you forgot, but prefer explicit team_merge.", "", "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.", 'To permanently delete archived teams, call team_cleanup with purge: ["team-name"] or purge: ["*"] for all archived teams.', "The first purge call is preview-only and deletes nothing.", "Use the question tool to ask the user for visible human approval before deleting archived team records or preserved Ensemble branches.", "The question must include the exact approval and denial option labels shown in the preview.", "Only if the user selects that exact approval option, call team_cleanup again with the same purge value, confirm_purge: true, and the confirm_token from the preview.");
|
|
15987
16078
|
return lines.join(`
|
|
15988
16079
|
`);
|
|
15989
16080
|
}
|
|
@@ -17013,6 +17104,28 @@ function requireTeamMember(deps, sessionId) {
|
|
|
17013
17104
|
throw new Error("This session is not in a team.");
|
|
17014
17105
|
return teamInfo;
|
|
17015
17106
|
}
|
|
17107
|
+
function requireCanPurgeArchivedTeams(deps, sessionId) {
|
|
17108
|
+
const activeMembers = deps.db.query(`SELECT tm.session_id
|
|
17109
|
+
FROM team_member tm
|
|
17110
|
+
JOIN team t ON tm.team_id = t.id
|
|
17111
|
+
WHERE t.status = 'active'`).all();
|
|
17112
|
+
if (activeMembers.some((member) => member.session_id === sessionId)) {
|
|
17113
|
+
throw new Error("Team members cannot purge archived teams");
|
|
17114
|
+
}
|
|
17115
|
+
const activeLeads = deps.db.query("SELECT lead_session_id FROM team WHERE status = 'active'").all();
|
|
17116
|
+
if (deps.tracker.getParent(sessionId)) {
|
|
17117
|
+
throw new Error("Sub-agents cannot purge archived teams");
|
|
17118
|
+
}
|
|
17119
|
+
if (activeLeads.some((team) => team.lead_session_id === sessionId))
|
|
17120
|
+
return;
|
|
17121
|
+
const activeTeamSessions = new Set([
|
|
17122
|
+
...activeMembers.map((member) => member.session_id),
|
|
17123
|
+
...activeLeads.map((team) => team.lead_session_id)
|
|
17124
|
+
]);
|
|
17125
|
+
if (deps.tracker.isDescendantOf(sessionId, activeTeamSessions)) {
|
|
17126
|
+
throw new Error("Sub-agents cannot purge archived teams");
|
|
17127
|
+
}
|
|
17128
|
+
}
|
|
17016
17129
|
|
|
17017
17130
|
// src/tools/team-spawn.ts
|
|
17018
17131
|
var spawnFailures = new Map;
|
|
@@ -17064,7 +17177,8 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
17064
17177
|
const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.name);
|
|
17065
17178
|
if (existing)
|
|
17066
17179
|
throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
|
|
17067
|
-
const
|
|
17180
|
+
const isReadOnly = args.agent === "plan" || args.agent === "explore";
|
|
17181
|
+
const useWorktree = args.worktree !== false && !isReadOnly && !isWorktreeDirectory(deps.directory);
|
|
17068
17182
|
const usePlanApproval = args.plan_approval === true;
|
|
17069
17183
|
log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`);
|
|
17070
17184
|
let worktreeDir = null;
|
|
@@ -17104,7 +17218,6 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
17104
17218
|
log(`spawn:workspace:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`);
|
|
17105
17219
|
}
|
|
17106
17220
|
}
|
|
17107
|
-
const isReadOnly = args.agent === "plan" || args.agent === "explore";
|
|
17108
17221
|
const TEAM_TOOLS = ["team_message", "team_broadcast", "team_tasks_list", "team_tasks_add", "team_tasks_complete", "team_claim"];
|
|
17109
17222
|
const permission = [];
|
|
17110
17223
|
if (worktreeDir) {
|
|
@@ -17467,7 +17580,340 @@ ${parts.join(`
|
|
|
17467
17580
|
|
|
17468
17581
|
// src/tools/team-cleanup.ts
|
|
17469
17582
|
init_merge_helper();
|
|
17470
|
-
async function
|
|
17583
|
+
async function listPreservedBranches(teamName, cwd) {
|
|
17584
|
+
try {
|
|
17585
|
+
const proc = Bun.spawn(["git", "branch", "--list", `ensemble/preserved/${teamName}/*`, "--format", "%(refname:short)"], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
17586
|
+
const out = await new Response(proc.stdout).text();
|
|
17587
|
+
const stderr = await new Response(proc.stderr).text();
|
|
17588
|
+
const exit = await proc.exited;
|
|
17589
|
+
if (exit !== 0)
|
|
17590
|
+
throw new Error(stderr.trim() || `git branch exited with code ${exit}`);
|
|
17591
|
+
return out.split(`
|
|
17592
|
+
`).map((branch) => branch.trim()).filter(Boolean);
|
|
17593
|
+
} catch (err) {
|
|
17594
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
17595
|
+
if (message.includes("not a git repository"))
|
|
17596
|
+
return [];
|
|
17597
|
+
throw new Error(`Failed to list preserved branches for ${teamName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
17598
|
+
}
|
|
17599
|
+
}
|
|
17600
|
+
async function branchExists(branch, cwd) {
|
|
17601
|
+
try {
|
|
17602
|
+
const proc = Bun.spawn(["git", "branch", "--list", branch, "--format", "%(refname:short)"], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
17603
|
+
const out = await new Response(proc.stdout).text();
|
|
17604
|
+
const stderr = await new Response(proc.stderr).text();
|
|
17605
|
+
const exit = await proc.exited;
|
|
17606
|
+
if (exit !== 0) {
|
|
17607
|
+
if (stderr.includes("not a git repository"))
|
|
17608
|
+
return false;
|
|
17609
|
+
throw new Error(stderr.trim() || `git branch exited with code ${exit}`);
|
|
17610
|
+
}
|
|
17611
|
+
return out.split(`
|
|
17612
|
+
`).map((item) => item.trim()).includes(branch);
|
|
17613
|
+
} catch (err) {
|
|
17614
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
17615
|
+
if (message.includes("not a git repository"))
|
|
17616
|
+
return false;
|
|
17617
|
+
throw new Error(`Failed to check stale Ensemble branch ${branch}: ${message}`);
|
|
17618
|
+
}
|
|
17619
|
+
}
|
|
17620
|
+
function normalizeBranchName(branch) {
|
|
17621
|
+
return branch.trim().replace(/^\*\s*/, "");
|
|
17622
|
+
}
|
|
17623
|
+
function resolvePurgeTargets(deps, purge) {
|
|
17624
|
+
if (purge.length === 0)
|
|
17625
|
+
throw new Error("Pass at least one archived team name to purge, or ['*'] for all archived teams.");
|
|
17626
|
+
if (purge.includes("*") && purge.length !== 1) {
|
|
17627
|
+
throw new Error("Wildcard purge cannot be combined with explicit team names. Use purge: ['*'] by itself.");
|
|
17628
|
+
}
|
|
17629
|
+
if (purge.includes("*")) {
|
|
17630
|
+
return deps.db.query("SELECT id, name, time_updated FROM team WHERE status = 'archived' ORDER BY time_updated DESC, name ASC").all();
|
|
17631
|
+
}
|
|
17632
|
+
const uniqueNames = [...new Set(purge)];
|
|
17633
|
+
const rows = uniqueNames.map((name) => ({
|
|
17634
|
+
name,
|
|
17635
|
+
team: deps.db.query("SELECT id, name, status, time_updated FROM team WHERE name = ?").get(name)
|
|
17636
|
+
}));
|
|
17637
|
+
const missing = rows.filter((row) => row.team === null).map((row) => row.name);
|
|
17638
|
+
if (missing.length > 0)
|
|
17639
|
+
throw new Error(`Team not found: ${missing.join(", ")}`);
|
|
17640
|
+
const active = rows.filter((row) => row.team?.status === "active").map((row) => row.name);
|
|
17641
|
+
if (active.length > 0)
|
|
17642
|
+
throw new Error(`Cannot purge active team: ${active.join(", ")}`);
|
|
17643
|
+
return rows.map((row) => row.team).sort((a, b) => b.time_updated - a.time_updated || a.name.localeCompare(b.name));
|
|
17644
|
+
}
|
|
17645
|
+
function deleteArchivedTeams(deps, targets) {
|
|
17646
|
+
const transaction = deps.db.transaction((teams) => {
|
|
17647
|
+
teams.forEach((team) => {
|
|
17648
|
+
const row = deps.db.query("SELECT status FROM team WHERE id = ?").get(team.id);
|
|
17649
|
+
if (!row)
|
|
17650
|
+
throw new Error(`Team not found: ${team.name}`);
|
|
17651
|
+
if (row.status === "active")
|
|
17652
|
+
throw new Error(`Cannot purge active team: ${team.name}`);
|
|
17653
|
+
});
|
|
17654
|
+
validatePurgeResources(deps, teams);
|
|
17655
|
+
teams.forEach((team) => {
|
|
17656
|
+
deps.db.run("DELETE FROM team WHERE id = ? AND status = 'archived'", [team.id]);
|
|
17657
|
+
});
|
|
17658
|
+
});
|
|
17659
|
+
transaction(targets);
|
|
17660
|
+
targets.forEach((team) => {
|
|
17661
|
+
deps.registry.unregisterTeam(team.id);
|
|
17662
|
+
spawnFailures.delete(team.id);
|
|
17663
|
+
});
|
|
17664
|
+
}
|
|
17665
|
+
function getPurgeMemberResources(deps, targets) {
|
|
17666
|
+
return targets.flatMap((target) => deps.db.query(`SELECT t.name as team_name,
|
|
17667
|
+
tm.team_id,
|
|
17668
|
+
tm.name as member_name,
|
|
17669
|
+
tm.worktree_dir,
|
|
17670
|
+
tm.workspace_id,
|
|
17671
|
+
tm.worktree_branch
|
|
17672
|
+
FROM team_member tm
|
|
17673
|
+
JOIN team t ON tm.team_id = t.id
|
|
17674
|
+
WHERE tm.team_id = ?`).all(target.id));
|
|
17675
|
+
}
|
|
17676
|
+
function preservedBranchPrefix(resource) {
|
|
17677
|
+
return `ensemble/preserved/${resource.team_name}/`;
|
|
17678
|
+
}
|
|
17679
|
+
function staleEnsembleBranchNames(resource) {
|
|
17680
|
+
return [
|
|
17681
|
+
`ensemble-${resource.team_name}-${resource.member_name}`,
|
|
17682
|
+
`opencode/ensemble-${resource.team_name}-${resource.member_name}`
|
|
17683
|
+
];
|
|
17684
|
+
}
|
|
17685
|
+
function isPreservedBranch(resource) {
|
|
17686
|
+
return resource.worktree_branch !== null && resource.worktree_branch.startsWith(preservedBranchPrefix(resource));
|
|
17687
|
+
}
|
|
17688
|
+
function isStaleEnsembleBranch(resource) {
|
|
17689
|
+
return resource.worktree_branch !== null && staleEnsembleBranchNames(resource).includes(resource.worktree_branch);
|
|
17690
|
+
}
|
|
17691
|
+
function validatePurgeResources(deps, targets) {
|
|
17692
|
+
const resources = getPurgeMemberResources(deps, targets);
|
|
17693
|
+
const nonPreserved = resources.filter((resource) => resource.worktree_branch !== null && !isPreservedBranch(resource) && !isStaleEnsembleBranch(resource));
|
|
17694
|
+
if (nonPreserved.length > 0) {
|
|
17695
|
+
const details = nonPreserved.map((resource) => `${resource.team_name}/${resource.member_name} (${resource.worktree_branch})`).join(", ");
|
|
17696
|
+
throw new Error(`Cannot purge archived teams: ${details} has a non-preserved worktree branch or a branch outside its preserved namespace.`);
|
|
17697
|
+
}
|
|
17698
|
+
const activeResourceRefs = resources.flatMap((resource) => {
|
|
17699
|
+
const worktreeRefs = resource.worktree_dir ? deps.db.query(`SELECT t.name as team_name, tm.name as member_name
|
|
17700
|
+
FROM team_member tm
|
|
17701
|
+
JOIN team t ON tm.team_id = t.id
|
|
17702
|
+
WHERE t.status = 'active' AND tm.worktree_dir = ?`).all(resource.worktree_dir) : [];
|
|
17703
|
+
const workspaceRefs = resource.workspace_id ? deps.db.query(`SELECT t.name as team_name, tm.name as member_name
|
|
17704
|
+
FROM team_member tm
|
|
17705
|
+
JOIN team t ON tm.team_id = t.id
|
|
17706
|
+
WHERE t.status = 'active' AND tm.workspace_id = ?`).all(resource.workspace_id) : [];
|
|
17707
|
+
return [
|
|
17708
|
+
...worktreeRefs.map((ref) => `${resource.team_name}/${resource.member_name} worktree is also referenced by active team ${ref.team_name}/${ref.member_name}`),
|
|
17709
|
+
...workspaceRefs.map((ref) => `${resource.team_name}/${resource.member_name} workspace is also referenced by active team ${ref.team_name}/${ref.member_name}`)
|
|
17710
|
+
];
|
|
17711
|
+
});
|
|
17712
|
+
if (activeResourceRefs.length > 0) {
|
|
17713
|
+
throw new Error(`Cannot purge archived teams: ${[...new Set(activeResourceRefs)].join(", ")}.`);
|
|
17714
|
+
}
|
|
17715
|
+
}
|
|
17716
|
+
function collectStaleEnsembleBranches(deps, targets) {
|
|
17717
|
+
return [...new Set(getPurgeMemberResources(deps, targets).filter(isStaleEnsembleBranch).map((resource) => resource.worktree_branch))];
|
|
17718
|
+
}
|
|
17719
|
+
function countStaleResourceRefs(deps, target) {
|
|
17720
|
+
return getPurgeMemberResources(deps, [target]).reduce((count, resource) => count + (resource.worktree_dir ? 1 : 0) + (resource.workspace_id ? 1 : 0), 0);
|
|
17721
|
+
}
|
|
17722
|
+
function countStaleBranchRefs(deps, target) {
|
|
17723
|
+
return collectStaleEnsembleBranches(deps, [target]).length;
|
|
17724
|
+
}
|
|
17725
|
+
async function existingWorktreeDirs(deps, resources) {
|
|
17726
|
+
if (!resources.some((resource) => resource.worktree_dir))
|
|
17727
|
+
return new Set;
|
|
17728
|
+
try {
|
|
17729
|
+
const result = await deps.client.worktree.list();
|
|
17730
|
+
return new Set((result.data ?? []).map((worktree) => worktree.directory));
|
|
17731
|
+
} catch (err) {
|
|
17732
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
17733
|
+
throw new Error(`Cannot purge archived teams: failed to list worktrees before stale resource cleanup: ${message}`);
|
|
17734
|
+
}
|
|
17735
|
+
}
|
|
17736
|
+
async function existingWorkspaceIds(deps, resources) {
|
|
17737
|
+
if (!resources.some((resource) => resource.workspace_id))
|
|
17738
|
+
return new Set;
|
|
17739
|
+
try {
|
|
17740
|
+
const result = await deps.client.workspace.list();
|
|
17741
|
+
return new Set((result.data ?? []).map((workspace) => workspace.id));
|
|
17742
|
+
} catch (err) {
|
|
17743
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
17744
|
+
throw new Error(`Cannot purge archived teams: failed to list workspaces before stale resource cleanup: ${message}`);
|
|
17745
|
+
}
|
|
17746
|
+
}
|
|
17747
|
+
async function cleanupStalePurgeResources(deps, targets, isDirty) {
|
|
17748
|
+
const resources = getPurgeMemberResources(deps, targets).filter((resource) => resource.worktree_dir || resource.workspace_id);
|
|
17749
|
+
if (resources.length === 0)
|
|
17750
|
+
return;
|
|
17751
|
+
const worktreeDirs = await existingWorktreeDirs(deps, resources);
|
|
17752
|
+
const workspaceIds = await existingWorkspaceIds(deps, resources);
|
|
17753
|
+
for (const resource of resources) {
|
|
17754
|
+
if (resource.workspace_id) {
|
|
17755
|
+
if (workspaceIds.has(resource.workspace_id)) {
|
|
17756
|
+
try {
|
|
17757
|
+
await deps.client.workspace.remove({ id: resource.workspace_id });
|
|
17758
|
+
} catch (err) {
|
|
17759
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
17760
|
+
throw new Error(`Failed to remove stale workspace for ${resource.team_name}/${resource.member_name}: ${message}`);
|
|
17761
|
+
}
|
|
17762
|
+
}
|
|
17763
|
+
deps.db.run("UPDATE team_member SET workspace_id = NULL WHERE team_id = ? AND name = ?", [resource.team_id, resource.member_name]);
|
|
17764
|
+
}
|
|
17765
|
+
if (resource.worktree_dir) {
|
|
17766
|
+
if (worktreeDirs.has(resource.worktree_dir)) {
|
|
17767
|
+
let dirty;
|
|
17768
|
+
try {
|
|
17769
|
+
dirty = await isDirty(resource.worktree_dir);
|
|
17770
|
+
} catch (err) {
|
|
17771
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
17772
|
+
throw new Error(`Cannot purge archived teams: failed to check archived worktree for uncommitted changes at ${resource.worktree_dir}: ${message}`);
|
|
17773
|
+
}
|
|
17774
|
+
if (dirty) {
|
|
17775
|
+
throw new Error(`Cannot purge archived teams: ${resource.team_name}/${resource.member_name} has uncommitted changes in archived worktree ${resource.worktree_dir}.`);
|
|
17776
|
+
}
|
|
17777
|
+
try {
|
|
17778
|
+
await deps.client.worktree.remove({ worktreeRemoveInput: { directory: resource.worktree_dir } });
|
|
17779
|
+
} catch (err) {
|
|
17780
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
17781
|
+
throw new Error(`Failed to remove stale worktree for ${resource.team_name}/${resource.member_name}: ${message}`);
|
|
17782
|
+
}
|
|
17783
|
+
}
|
|
17784
|
+
deps.db.run("UPDATE team_member SET worktree_dir = NULL WHERE team_id = ? AND name = ?", [resource.team_id, resource.member_name]);
|
|
17785
|
+
}
|
|
17786
|
+
}
|
|
17787
|
+
}
|
|
17788
|
+
async function deleteStaleEnsembleBranches(branches, cwd, delBranch, exists) {
|
|
17789
|
+
for (const branch of branches) {
|
|
17790
|
+
if (!await exists(branch, cwd))
|
|
17791
|
+
continue;
|
|
17792
|
+
const ok = await delBranch(branch, cwd);
|
|
17793
|
+
if (!ok)
|
|
17794
|
+
throw new Error(`Failed to delete stale Ensemble branch: ${branch}`);
|
|
17795
|
+
}
|
|
17796
|
+
}
|
|
17797
|
+
function validatePurgeTargetsStillArchived(deps, targets) {
|
|
17798
|
+
const missing = [];
|
|
17799
|
+
const active = [];
|
|
17800
|
+
targets.forEach((target) => {
|
|
17801
|
+
const row = deps.db.query("SELECT status FROM team WHERE id = ?").get(target.id);
|
|
17802
|
+
if (!row)
|
|
17803
|
+
missing.push(target.name);
|
|
17804
|
+
else if (row.status === "active")
|
|
17805
|
+
active.push(target.name);
|
|
17806
|
+
});
|
|
17807
|
+
if (missing.length > 0)
|
|
17808
|
+
throw new Error(`Team not found: ${missing.join(", ")}`);
|
|
17809
|
+
if (active.length > 0)
|
|
17810
|
+
throw new Error(`Cannot purge active team: ${active.join(", ")}`);
|
|
17811
|
+
}
|
|
17812
|
+
async function collectPreservedBranches(targets, cwd, listBranches) {
|
|
17813
|
+
const entries = await Promise.all(targets.map(async (target) => {
|
|
17814
|
+
const prefix = `ensemble/preserved/${target.name}/`;
|
|
17815
|
+
let listed;
|
|
17816
|
+
try {
|
|
17817
|
+
listed = await listBranches(target.name, cwd);
|
|
17818
|
+
} catch (err) {
|
|
17819
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
17820
|
+
if (message.includes("not a git repository"))
|
|
17821
|
+
listed = [];
|
|
17822
|
+
else
|
|
17823
|
+
throw new Error(`Failed to list preserved branches for ${target.name}: ${message}`);
|
|
17824
|
+
}
|
|
17825
|
+
const branches = listed.map(normalizeBranchName).filter((branch) => branch.startsWith(prefix));
|
|
17826
|
+
return [target.id, [...new Set(branches)]];
|
|
17827
|
+
}));
|
|
17828
|
+
return new Map(entries);
|
|
17829
|
+
}
|
|
17830
|
+
async function deletePreservedBranches(branchesByTeam, cwd, delBranch) {
|
|
17831
|
+
const branches = [...branchesByTeam.values()].flat();
|
|
17832
|
+
for (const branch of branches) {
|
|
17833
|
+
const ok = await delBranch(branch, cwd);
|
|
17834
|
+
if (!ok)
|
|
17835
|
+
throw new Error(`Failed to delete preserved branch: ${branch}`);
|
|
17836
|
+
}
|
|
17837
|
+
}
|
|
17838
|
+
function formatCount(count, noun, plural = `${noun}s`) {
|
|
17839
|
+
return `${count} ${count === 1 ? noun : plural}`;
|
|
17840
|
+
}
|
|
17841
|
+
function buildPurgePreview(deps, targets, branchesByTeam) {
|
|
17842
|
+
const rows = targets.map((target) => {
|
|
17843
|
+
const members = deps.db.query("SELECT COUNT(*) as c FROM team_member WHERE team_id = ?").get(target.id).c;
|
|
17844
|
+
const tasks = deps.db.query("SELECT COUNT(*) as c FROM team_task WHERE team_id = ?").get(target.id).c;
|
|
17845
|
+
const messages = deps.db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ?").get(target.id).c;
|
|
17846
|
+
const branches = branchesByTeam.get(target.id)?.length ?? 0;
|
|
17847
|
+
const staleResources = countStaleResourceRefs(deps, target);
|
|
17848
|
+
const staleBranches = countStaleBranchRefs(deps, target);
|
|
17849
|
+
return { ...target, members, tasks, messages, branches, staleResources, staleBranches };
|
|
17850
|
+
});
|
|
17851
|
+
const totals = rows.reduce((acc, row) => ({
|
|
17852
|
+
members: acc.members + row.members,
|
|
17853
|
+
tasks: acc.tasks + row.tasks,
|
|
17854
|
+
messages: acc.messages + row.messages,
|
|
17855
|
+
branches: acc.branches + row.branches,
|
|
17856
|
+
staleResources: acc.staleResources + row.staleResources,
|
|
17857
|
+
staleBranches: acc.staleBranches + row.staleBranches
|
|
17858
|
+
}), { members: 0, tasks: 0, messages: 0, branches: 0, staleResources: 0, staleBranches: 0 });
|
|
17859
|
+
const details = rows.slice(0, 10).map((row) => `- ${row.name}: ${formatCount(row.members, "member")}, ${formatCount(row.tasks, "task")}, ${formatCount(row.messages, "message")}, ${formatCount(row.branches, "preserved branch", "preserved branches")}, ${formatCount(row.staleResources, "stale resource")}, ${formatCount(row.staleBranches, "stale branch", "stale branches")}`);
|
|
17860
|
+
const hidden = rows.length > 10 ? [`...and ${rows.length - 10} more archived team${rows.length - 10 === 1 ? "" : "s"}`] : [];
|
|
17861
|
+
return [
|
|
17862
|
+
"Permanently delete archived teams?",
|
|
17863
|
+
"This will delete archived team records and cascade-delete their members, tasks, and messages.",
|
|
17864
|
+
"",
|
|
17865
|
+
...details,
|
|
17866
|
+
...hidden,
|
|
17867
|
+
"",
|
|
17868
|
+
`Total: ${formatCount(rows.length, "team")}, ${formatCount(totals.members, "member")}, ${formatCount(totals.tasks, "task")}, ${formatCount(totals.messages, "message")}, ${formatCount(totals.branches, "preserved branch", "preserved branches")}, ${formatCount(totals.staleResources, "stale resource")}, ${formatCount(totals.staleBranches, "stale branch", "stale branches")}`
|
|
17869
|
+
].join(`
|
|
17870
|
+
`);
|
|
17871
|
+
}
|
|
17872
|
+
function buildPurgeConfirmationInstructions(preview, confirmToken) {
|
|
17873
|
+
const approvalLabel = `Approve purge ${confirmToken.slice(0, 8)}`;
|
|
17874
|
+
const denialLabel = `Deny purge ${confirmToken.slice(0, 8)}`;
|
|
17875
|
+
return [
|
|
17876
|
+
"Purge preview only \u2014 no teams were deleted.",
|
|
17877
|
+
"",
|
|
17878
|
+
preview,
|
|
17879
|
+
"",
|
|
17880
|
+
"Use the question tool to ask the user whether to permanently delete these archived teams.",
|
|
17881
|
+
`The approval option label must be exactly: ${approvalLabel}`,
|
|
17882
|
+
`The denial option label must be exactly: ${denialLabel}`,
|
|
17883
|
+
`Confirmation token: ${confirmToken}`,
|
|
17884
|
+
`Only if the user selects "${approvalLabel}", call team_cleanup again with the same purge value, confirm_purge: true, and confirm_token set to this token.`
|
|
17885
|
+
].join(`
|
|
17886
|
+
`);
|
|
17887
|
+
}
|
|
17888
|
+
async function executeTeamCleanup(deps, args, sessionId, isDirty = checkWorktreeDirty, merge2 = mergeBranch, delBranch = deleteBranch, mergeOnCleanup = true, overlapCheck = getOverlappingFiles, _approvePurge, _listBranches, _branchExists) {
|
|
17889
|
+
if (args.purge && args.purge.length > 0) {
|
|
17890
|
+
requireCanPurgeArchivedTeams(deps, sessionId);
|
|
17891
|
+
const targets = resolvePurgeTargets(deps, args.purge);
|
|
17892
|
+
if (targets.length === 0)
|
|
17893
|
+
return "No archived teams to purge.";
|
|
17894
|
+
validatePurgeTargetsStillArchived(deps, targets);
|
|
17895
|
+
validatePurgeResources(deps, targets);
|
|
17896
|
+
const branchesByTeam = await collectPreservedBranches(targets, deps.directory, _listBranches ?? listPreservedBranches);
|
|
17897
|
+
const preview = buildPurgePreview(deps, targets, branchesByTeam);
|
|
17898
|
+
if (!args.confirm_purge) {
|
|
17899
|
+
const confirmToken = deps.purgeApprovals.create(sessionId, targets.map((target) => target.id));
|
|
17900
|
+
return buildPurgeConfirmationInstructions(preview, confirmToken);
|
|
17901
|
+
}
|
|
17902
|
+
if (!args.confirm_token) {
|
|
17903
|
+
throw new Error("A purge confirmation token is required. First call team_cleanup without confirm_purge, then use the question tool before confirming.");
|
|
17904
|
+
}
|
|
17905
|
+
deps.purgeApprovals.consume(sessionId, args.confirm_token, targets.map((target) => target.id));
|
|
17906
|
+
validatePurgeTargetsStillArchived(deps, targets);
|
|
17907
|
+
validatePurgeResources(deps, targets);
|
|
17908
|
+
await cleanupStalePurgeResources(deps, targets, isDirty);
|
|
17909
|
+
validatePurgeResources(deps, targets);
|
|
17910
|
+
await deleteStaleEnsembleBranches(collectStaleEnsembleBranches(deps, targets), deps.directory, delBranch, _branchExists ?? branchExists);
|
|
17911
|
+
const finalBranchesByTeam = await collectPreservedBranches(targets, deps.directory, _listBranches ?? listPreservedBranches);
|
|
17912
|
+
await deletePreservedBranches(finalBranchesByTeam, deps.directory, delBranch);
|
|
17913
|
+
deleteArchivedTeams(deps, targets);
|
|
17914
|
+
const noun = targets.length === 1 ? "archived team" : "archived teams";
|
|
17915
|
+
return `Permanently deleted ${targets.length} ${noun}: ${targets.map((target) => target.name).join(", ")}.`;
|
|
17916
|
+
}
|
|
17471
17917
|
const teamInfo = requireLead(deps, sessionId);
|
|
17472
17918
|
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);
|
|
17473
17919
|
const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
|
|
@@ -18067,6 +18513,7 @@ var plugin = async (input) => {
|
|
|
18067
18513
|
const config2 = loadConfig(input.directory);
|
|
18068
18514
|
const registry2 = new MemberRegistry;
|
|
18069
18515
|
const tracker = new DescendantTracker;
|
|
18516
|
+
const purgeApprovals = new PendingPurgeApprovals;
|
|
18070
18517
|
const nudgedMembers = new Set;
|
|
18071
18518
|
const progressTracker = new ProgressTracker;
|
|
18072
18519
|
const wakeLeadTimestamps = new Map;
|
|
@@ -18075,7 +18522,7 @@ var plugin = async (input) => {
|
|
|
18075
18522
|
const rawClient = new OpencodeClient({ client: pluginTransport });
|
|
18076
18523
|
initLog(rawClient);
|
|
18077
18524
|
const client3 = wrapThrowingClient(rawClient);
|
|
18078
|
-
const deps = { db, registry: registry2, tracker, client: client3, directory: input.directory, config: config2 };
|
|
18525
|
+
const deps = { db, registry: registry2, tracker, purgeApprovals, client: client3, directory: input.directory, config: config2 };
|
|
18079
18526
|
if (!isWorktreeInstance(input.directory)) {
|
|
18080
18527
|
log("init:recovery:start (main instance)");
|
|
18081
18528
|
const recovery = await recoverStaleMembers(db, client3, input.directory);
|
|
@@ -18258,6 +18705,11 @@ var plugin = async (input) => {
|
|
|
18258
18705
|
}
|
|
18259
18706
|
}
|
|
18260
18707
|
},
|
|
18708
|
+
"tool.execute.after": async (input2, output) => {
|
|
18709
|
+
if (input2.tool === "question") {
|
|
18710
|
+
purgeApprovals.recordQuestionAnswer(input2.sessionID, output.output, input2.args);
|
|
18711
|
+
}
|
|
18712
|
+
},
|
|
18261
18713
|
"experimental.chat.system.transform": async (input2, output) => {
|
|
18262
18714
|
if (!input2.sessionID)
|
|
18263
18715
|
return;
|
|
@@ -18430,15 +18882,30 @@ var plugin = async (input) => {
|
|
|
18430
18882
|
}
|
|
18431
18883
|
}),
|
|
18432
18884
|
team_cleanup: tool({
|
|
18433
|
-
description: "Clean up the team
|
|
18885
|
+
description: "Clean up the current team, or purge archived teams after human approval. Omit purge for normal cleanup. Pass purge with archived team names, or ['*'] for all archived teams. First purge call returns a preview, exact approval and denial options, and confirmation token only. Archived worktree/workspace references and stale Ensemble-owned branches are shown in the preview and cleaned during confirmed purge. Use the question tool with those exact options, then call again with confirm_purge: true and confirm_token only if the user selected the exact approval option.",
|
|
18434
18886
|
args: {
|
|
18435
18887
|
force: tool.schema.boolean().default(false).describe("Force cleanup even if members are active (will abort them)"),
|
|
18436
|
-
acknowledge_uncommitted: tool.schema.boolean().default(false)
|
|
18888
|
+
acknowledge_uncommitted: tool.schema.boolean().default(false),
|
|
18889
|
+
purge: tool.schema.array(tool.schema.string()).optional().describe("Archived team names to permanently delete, or ['*'] for all archived teams. Requires human approval."),
|
|
18890
|
+
confirm_purge: tool.schema.boolean().default(false).describe("Set true only after the user explicitly selects the exact approval option from the purge preview via the question tool."),
|
|
18891
|
+
confirm_token: tool.schema.string().optional().describe("Confirmation token from the purge preview. Valid only after the matching exact approval answer is selected in this session.")
|
|
18437
18892
|
},
|
|
18438
18893
|
async execute(args, ctx) {
|
|
18439
|
-
const
|
|
18894
|
+
const approvePurge = args.purge && args.purge.length > 0 && args.confirm_purge ? async (preview) => {
|
|
18895
|
+
await ctx.ask({
|
|
18896
|
+
permission: "team_cleanup.purge",
|
|
18897
|
+
patterns: args.purge ?? [],
|
|
18898
|
+
always: [],
|
|
18899
|
+
metadata: {
|
|
18900
|
+
title: "Purge archived teams",
|
|
18901
|
+
preview
|
|
18902
|
+
}
|
|
18903
|
+
});
|
|
18904
|
+
} : undefined;
|
|
18905
|
+
const result = await executeTeamCleanup(deps, args, ctx.sessionID, undefined, undefined, undefined, config2.mergeOnCleanup, undefined, approvePurge);
|
|
18440
18906
|
const blocked = result.includes("uncommitted");
|
|
18441
|
-
|
|
18907
|
+
const title = args.purge ? result.startsWith("No archived teams") ? "No archived teams to purge" : result.startsWith("Purge preview") ? "Purge confirmation required" : "Archived teams purged" : blocked ? "Cleanup blocked \u2014 uncommitted changes" : "Team cleaned up";
|
|
18908
|
+
ctx.metadata({ title });
|
|
18442
18909
|
return result;
|
|
18443
18910
|
}
|
|
18444
18911
|
}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hueyexe/opencode-ensemble",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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",
|