@hueyexe/opencode-ensemble 0.13.3 → 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 CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  [![npm version](https://img.shields.io/npm/v/@hueyexe/opencode-ensemble.svg)](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
8
8
  [![npm downloads](https://img.shields.io/npm/dm/@hueyexe/opencode-ensemble.svg)](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
9
- [![tests](https://img.shields.io/badge/tests-512%20passing-brightgreen.svg)]()
9
+ [![tests](https://img.shields.io/badge/tests-558%20passing-brightgreen.svg)]()
10
10
  [![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue.svg)]()
11
11
  [![OpenCode SDK](https://img.shields.io/badge/deps-OpenCode%20SDK%20only-blue.svg)]()
12
12
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./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.13.3"]
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.13.3"]
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.13.3"]
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 any forgotten branches. |
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 # 512 tests
415
+ bun test # 558 tests
414
416
  bun run build
415
417
  ```
416
418
 
package/dist/index.js CHANGED
@@ -1,16 +1,12 @@
1
1
  // @bun
2
2
  var __defProp = Object.defineProperty;
3
- var __returnValue = (v) => v;
4
- function __exportSetter(name, newValue) {
5
- this[name] = __returnValue.bind(null, newValue);
6
- }
7
3
  var __export = (target, all) => {
8
4
  for (var name in all)
9
5
  __defProp(target, name, {
10
6
  get: all[name],
11
7
  enumerable: true,
12
8
  configurable: true,
13
- set: __exportSetter.bind(all, name)
9
+ set: (newValue) => all[name] = () => newValue
14
10
  });
15
11
  };
16
12
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
@@ -15701,6 +15697,8 @@ async function recoverOrphanedBranches(db, cwd) {
15701
15697
  }
15702
15698
 
15703
15699
  // src/state.ts
15700
+ import { randomUUID } from "crypto";
15701
+
15704
15702
  class MemberRegistry {
15705
15703
  bySession = new Map;
15706
15704
  byTeamName = new Map;
@@ -15744,6 +15742,36 @@ class MemberRegistry {
15744
15742
  }
15745
15743
  }
15746
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
+ }
15747
15775
 
15748
15776
  class DescendantTracker {
15749
15777
  parents = new Map;
@@ -15770,6 +15798,65 @@ class DescendantTracker {
15770
15798
  }
15771
15799
  }
15772
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
+
15773
15860
  // src/hooks.ts
15774
15861
  var TEAM_TOOL_PREFIX = "team_";
15775
15862
  function handleSessionStatusEvent(db, registry2, sessionId, status) {
@@ -15987,7 +16074,7 @@ function buildLeadSystemPrompt(db, teamId, config2) {
15987
16074
  }
15988
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.");
15989
16076
  }
15990
- 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.");
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.");
15991
16078
  return lines.join(`
15992
16079
  `);
15993
16080
  }
@@ -17017,6 +17104,28 @@ function requireTeamMember(deps, sessionId) {
17017
17104
  throw new Error("This session is not in a team.");
17018
17105
  return teamInfo;
17019
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
+ }
17020
17129
 
17021
17130
  // src/tools/team-spawn.ts
17022
17131
  var spawnFailures = new Map;
@@ -17471,7 +17580,340 @@ ${parts.join(`
17471
17580
 
17472
17581
  // src/tools/team-cleanup.ts
17473
17582
  init_merge_helper();
17474
- async function executeTeamCleanup(deps, args, sessionId, isDirty = checkWorktreeDirty, merge2 = mergeBranch, delBranch = deleteBranch, mergeOnCleanup = true, overlapCheck = getOverlappingFiles) {
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
+ }
17475
17917
  const teamInfo = requireLead(deps, sessionId);
17476
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);
17477
17919
  const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
@@ -18071,6 +18513,7 @@ var plugin = async (input) => {
18071
18513
  const config2 = loadConfig(input.directory);
18072
18514
  const registry2 = new MemberRegistry;
18073
18515
  const tracker = new DescendantTracker;
18516
+ const purgeApprovals = new PendingPurgeApprovals;
18074
18517
  const nudgedMembers = new Set;
18075
18518
  const progressTracker = new ProgressTracker;
18076
18519
  const wakeLeadTimestamps = new Map;
@@ -18079,7 +18522,7 @@ var plugin = async (input) => {
18079
18522
  const rawClient = new OpencodeClient({ client: pluginTransport });
18080
18523
  initLog(rawClient);
18081
18524
  const client3 = wrapThrowingClient(rawClient);
18082
- 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 };
18083
18526
  if (!isWorktreeInstance(input.directory)) {
18084
18527
  log("init:recovery:start (main instance)");
18085
18528
  const recovery = await recoverStaleMembers(db, client3, input.directory);
@@ -18262,6 +18705,11 @@ var plugin = async (input) => {
18262
18705
  }
18263
18706
  }
18264
18707
  },
18708
+ "tool.execute.after": async (input2, output) => {
18709
+ if (input2.tool === "question") {
18710
+ purgeApprovals.recordQuestionAnswer(input2.sessionID, output.output, input2.args);
18711
+ }
18712
+ },
18265
18713
  "experimental.chat.system.transform": async (input2, output) => {
18266
18714
  if (!input2.sessionID)
18267
18715
  return;
@@ -18434,15 +18882,30 @@ var plugin = async (input) => {
18434
18882
  }
18435
18883
  }),
18436
18884
  team_cleanup: tool({
18437
- description: "Clean up the team. All teammates must be shut down first. Removes team data and frees resources.",
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.",
18438
18886
  args: {
18439
18887
  force: tool.schema.boolean().default(false).describe("Force cleanup even if members are active (will abort them)"),
18440
- 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.")
18441
18892
  },
18442
18893
  async execute(args, ctx) {
18443
- const result = await executeTeamCleanup(deps, args, ctx.sessionID, undefined, undefined, undefined, config2.mergeOnCleanup);
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);
18444
18906
  const blocked = result.includes("uncommitted");
18445
- ctx.metadata({ title: blocked ? "Cleanup blocked \u2014 uncommitted changes" : "Team cleaned up" });
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 });
18446
18909
  return result;
18447
18910
  }
18448
18911
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.13.3",
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",