@hueyexe/opencode-ensemble 0.13.3 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.13.3",
3
+ "version": "0.14.1",
4
4
  "description": "Agent teams for OpenCode — parallel agents with peer-to-peer communication, shared tasks, and coordinated execution",
5
5
  "module": "src/index.ts",
6
6
  "main": "dist/index.js",
@@ -41,7 +41,7 @@
41
41
  "typescript": "^5"
42
42
  },
43
43
  "dependencies": {
44
- "@opencode-ai/plugin": "^1.3.0",
45
- "@opencode-ai/sdk": "^1.3.0"
44
+ "@opencode-ai/plugin": "^1.14.45",
45
+ "@opencode-ai/sdk": "^1.14.45"
46
46
  }
47
47
  }
package/src/index.ts CHANGED
@@ -6,7 +6,7 @@ import { mkdirSync } from "node:fs"
6
6
  import { createDb, getDbPath } from "./db"
7
7
  import { wrapThrowingClient } from "./client"
8
8
  import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktrees, recoverOrphanedBranches } from "./recovery"
9
- import { MemberRegistry, DescendantTracker } from "./state"
9
+ import { MemberRegistry, DescendantTracker, PendingPurgeApprovals } from "./state"
10
10
  import { isWorktreeInstance } from "./util"
11
11
  import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation, shouldNudgeIdleMember } from "./hooks"
12
12
  import { notifyTeamEvent, notifyWorkingProgress } from "./notify"
@@ -56,6 +56,7 @@ const plugin: Plugin = async (input) => {
56
56
  // Initialize in-memory state
57
57
  const registry = new MemberRegistry()
58
58
  const tracker = new DescendantTracker()
59
+ const purgeApprovals = new PendingPurgeApprovals()
59
60
  const nudgedMembers = new Set<string>()
60
61
  const progressTracker = new ProgressTracker()
61
62
  const wakeLeadTimestamps = new Map<string, number>()
@@ -69,7 +70,7 @@ const plugin: Plugin = async (input) => {
69
70
  const rawClient = new OpencodeClient({ client: pluginTransport })
70
71
  initLog(rawClient)
71
72
  const client = wrapThrowingClient(rawClient)
72
- const deps: ToolDeps = { db, registry, tracker, client, directory: input.directory, config }
73
+ const deps: ToolDeps = { db, registry, tracker, purgeApprovals, client, directory: input.directory, config }
73
74
 
74
75
  // Recovery only runs for the main project instance — NOT for teammate worktree instances.
75
76
  // Worktree instances are created during session.create. Running recovery there makes HTTP
@@ -306,6 +307,12 @@ const plugin: Plugin = async (input) => {
306
307
  }
307
308
  },
308
309
 
310
+ "tool.execute.after": async (input, output) => {
311
+ if (input.tool === "question") {
312
+ purgeApprovals.recordQuestionAnswer(input.sessionID, output.output, input.args)
313
+ }
314
+ },
315
+
309
316
  // System prompt injection — keeps lead aware of team state, reminds teammates of role
310
317
  "experimental.chat.system.transform": async (input, output) => {
311
318
  if (!input.sessionID) return
@@ -498,15 +505,38 @@ const plugin: Plugin = async (input) => {
498
505
  }),
499
506
 
500
507
  team_cleanup: tool({
501
- description: "Clean up the team. All teammates must be shut down first. Removes team data and frees resources.",
508
+ description: "Clean up the current team, or purge archived teams after human approval. " +
509
+ "Omit purge for normal cleanup. Pass purge with archived team names, or ['*'] for all archived teams. " +
510
+ "First purge call returns a preview, exact approval and denial options, and confirmation token only. " +
511
+ "Archived worktree/workspace references and stale Ensemble-owned branches are shown in the preview and cleaned during confirmed purge. " +
512
+ "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.",
502
513
  args: {
503
514
  force: tool.schema.boolean().default(false).describe("Force cleanup even if members are active (will abort them)"),
504
515
  acknowledge_uncommitted: tool.schema.boolean().default(false),
516
+ purge: tool.schema.array(tool.schema.string()).optional().describe("Archived team names to permanently delete, or ['*'] for all archived teams. Requires human approval."),
517
+ 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."),
518
+ 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."),
505
519
  },
506
520
  async execute(args, ctx) {
507
- const result = await executeTeamCleanup(deps, args, ctx.sessionID, undefined, undefined, undefined, config.mergeOnCleanup)
521
+ const approvePurge = args.purge && args.purge.length > 0 && args.confirm_purge
522
+ ? async (preview: string) => {
523
+ await ctx.ask({
524
+ permission: "team_cleanup.purge",
525
+ patterns: args.purge ?? [],
526
+ always: [],
527
+ metadata: {
528
+ title: "Purge archived teams",
529
+ preview,
530
+ },
531
+ })
532
+ }
533
+ : undefined
534
+ const result = await executeTeamCleanup(deps, args, ctx.sessionID, undefined, undefined, undefined, config.mergeOnCleanup, undefined, approvePurge)
508
535
  const blocked = result.includes("uncommitted")
509
- ctx.metadata({ title: blocked ? "Cleanup blocked — uncommitted changes" : "Team cleaned up" })
536
+ const title = args.purge
537
+ ? result.startsWith("No archived teams") ? "No archived teams to purge" : result.startsWith("Purge preview") ? "Purge confirmation required" : "Archived teams purged"
538
+ : blocked ? "Cleanup blocked — uncommitted changes" : "Team cleaned up"
539
+ ctx.metadata({ title })
510
540
  return result
511
541
  },
512
542
  }),
package/src/state.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from "node:crypto"
2
+
1
3
  /** Info about a registered team member in the in-memory registry. */
2
4
  export interface MemberEntry {
3
5
  teamId: string
@@ -67,6 +69,40 @@ export class MemberRegistry {
67
69
  }
68
70
 
69
71
  const DEFAULT_MAX_DEPTH = 10
72
+ const DEFAULT_PURGE_APPROVAL_TTL_MS = 10 * 60 * 1000
73
+
74
+ interface PendingPurgeApproval {
75
+ sessionId: string
76
+ purgeKey: string
77
+ approved: boolean
78
+ timeCreated: number
79
+ }
80
+
81
+ function canonicalPurgeKey(purge: string[]): string {
82
+ if (purge.includes("*")) return JSON.stringify(["*"])
83
+ return JSON.stringify([...new Set(purge)].sort())
84
+ }
85
+
86
+ function outputSelectedAnswer(output: string, label: string): boolean {
87
+ const quoted = JSON.stringify(label)
88
+ return output.includes(`=${quoted}`) || output.includes(`=[${quoted}]`)
89
+ }
90
+
91
+ function optionLabels(args: unknown): string[] {
92
+ if (!args || typeof args !== "object") return []
93
+ const questions = (args as { questions?: unknown }).questions
94
+ if (!Array.isArray(questions)) return []
95
+ return questions.flatMap(question => {
96
+ if (!question || typeof question !== "object") return []
97
+ const options = (question as { options?: unknown }).options
98
+ if (!Array.isArray(options)) return []
99
+ return options.flatMap(option => {
100
+ if (!option || typeof option !== "object") return []
101
+ const label = (option as { label?: unknown }).label
102
+ return typeof label === "string" ? [label] : []
103
+ })
104
+ })
105
+ }
70
106
 
71
107
  /**
72
108
  * Tracks parent-child session relationships for sub-agent isolation.
@@ -106,3 +142,74 @@ export class DescendantTracker {
106
142
  this.parents.delete(sessionId)
107
143
  }
108
144
  }
145
+
146
+ /** Tracks pending archived-team purge confirmations between preview and execution. */
147
+ export class PendingPurgeApprovals {
148
+ private pending = new Map<string, PendingPurgeApproval>()
149
+ private readonly ttlMs: number
150
+ private readonly now: () => number
151
+ private readonly createToken: () => string
152
+
153
+ constructor(ttlMs = DEFAULT_PURGE_APPROVAL_TTL_MS, now: () => number = () => Date.now(), createToken: () => string = randomUUID) {
154
+ this.ttlMs = ttlMs
155
+ this.now = now
156
+ this.createToken = createToken
157
+ }
158
+
159
+ /** Create a confirmation token for a purge preview. */
160
+ create(sessionId: string, purge: string[]): string {
161
+ this.pruneExpired()
162
+ const token = this.createToken()
163
+ this.pending.set(token, {
164
+ sessionId,
165
+ purgeKey: canonicalPurgeKey(purge),
166
+ approved: false,
167
+ timeCreated: this.now(),
168
+ })
169
+ return token
170
+ }
171
+
172
+ /** Return the exact user-facing answer label that approves a confirmation token. */
173
+ approvalLabel(token: string): string {
174
+ return `Approve purge ${token.slice(0, 8)}`
175
+ }
176
+
177
+ /** Return the exact user-facing answer label that denies a confirmation token. */
178
+ denialLabel(token: string): string {
179
+ return `Deny purge ${token.slice(0, 8)}`
180
+ }
181
+
182
+ /** Record a question tool answer and approve only tokens whose exact approval label was selected. */
183
+ recordQuestionAnswer(sessionId: string, output: string, args: unknown): void {
184
+ this.pruneExpired()
185
+ const labels = new Set(optionLabels(args))
186
+ for (const [token, approval] of this.pending.entries()) {
187
+ const approvalLabel = this.approvalLabel(token)
188
+ const denialLabel = this.denialLabel(token)
189
+ const hasRequiredOptions = labels.has(approvalLabel) && labels.has(denialLabel)
190
+ if (approval.sessionId === sessionId && hasRequiredOptions && outputSelectedAnswer(output, approvalLabel)) {
191
+ approval.approved = true
192
+ }
193
+ }
194
+ }
195
+
196
+ /** Consume a confirmation token once it is safe to execute the matching purge. */
197
+ consume(sessionId: string, token: string, purge: string[]): void {
198
+ this.pruneExpired()
199
+ const approval = this.pending.get(token)
200
+ if (!approval || approval.sessionId !== sessionId || approval.purgeKey !== canonicalPurgeKey(purge)) {
201
+ throw new Error("Purge confirmation token is invalid or expired.")
202
+ }
203
+ if (!approval.approved) {
204
+ throw new Error("Purge was not approved by the user. Use the question tool with the exact approval option from the preview before confirming.")
205
+ }
206
+ this.pending.delete(token)
207
+ }
208
+
209
+ private pruneExpired(): void {
210
+ const cutoff = this.now() - this.ttlMs
211
+ for (const [token, approval] of this.pending.entries()) {
212
+ if (approval.timeCreated < cutoff) this.pending.delete(token)
213
+ }
214
+ }
215
+ }
@@ -152,6 +152,11 @@ export function buildLeadSystemPrompt(db: Database, teamId: string, config?: Req
152
152
  "Before calling team_cleanup, verify teammates have committed their work.",
153
153
  "team_shutdown will warn you if a teammate has uncommitted changes.",
154
154
  "team_cleanup will block if any worktree has uncommitted changes — merge or commit first.",
155
+ "To permanently delete archived teams, call team_cleanup with purge: [\"team-name\"] or purge: [\"*\"] for all archived teams.",
156
+ "The first purge call is preview-only and deletes nothing.",
157
+ "Use the question tool to ask the user for visible human approval before deleting archived team records or preserved Ensemble branches.",
158
+ "The question must include the exact approval and denial option labels shown in the preview.",
159
+ "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.",
155
160
  )
156
161
 
157
162
  return lines.join("\n")
@@ -52,3 +52,38 @@ export function requireTeamMember(
52
52
  if (!teamInfo) throw new Error("This session is not in a team.")
53
53
  return teamInfo
54
54
  }
55
+
56
+ /** Validate that a session can purge archived teams. Throws if not allowed. */
57
+ export function requireCanPurgeArchivedTeams(
58
+ deps: Pick<ToolDeps, "db" | "registry" | "tracker">,
59
+ sessionId: string,
60
+ ): void {
61
+ const activeMembers = deps.db.query(
62
+ `SELECT tm.session_id
63
+ FROM team_member tm
64
+ JOIN team t ON tm.team_id = t.id
65
+ WHERE t.status = 'active'`
66
+ ).all() as Array<{ session_id: string }>
67
+
68
+ if (activeMembers.some(member => member.session_id === sessionId)) {
69
+ throw new Error("Team members cannot purge archived teams")
70
+ }
71
+
72
+ const activeLeads = deps.db.query("SELECT lead_session_id FROM team WHERE status = 'active'")
73
+ .all() as Array<{ lead_session_id: string }>
74
+
75
+ if (deps.tracker.getParent(sessionId)) {
76
+ throw new Error("Sub-agents cannot purge archived teams")
77
+ }
78
+
79
+ if (activeLeads.some(team => team.lead_session_id === sessionId)) return
80
+
81
+ const activeTeamSessions = new Set([
82
+ ...activeMembers.map(member => member.session_id),
83
+ ...activeLeads.map(team => team.lead_session_id),
84
+ ])
85
+
86
+ if (deps.tracker.isDescendantOf(sessionId, activeTeamSessions)) {
87
+ throw new Error("Sub-agents cannot purge archived teams")
88
+ }
89
+ }