@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.
@@ -1,11 +1,394 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { requireLead, checkWorktreeDirty } from "./shared"
2
+ import { requireLead, requireCanPurgeArchivedTeams, checkWorktreeDirty } from "./shared"
3
3
  import type { IsDirtyFn } from "./shared"
4
4
  import { spawnFailures } from "./team-spawn"
5
5
  import { mergeBranch, deleteBranch, preserveBranch, preservedBranchName, getOverlappingFiles } from "./merge-helper"
6
6
  import type { MergeBranchFn, DeleteBranchFn, PreserveBranchFn, OverlapCheckFn } from "./merge-helper"
7
7
  import { log } from "../log"
8
8
 
9
+ type PurgeApprovalFn = (preview: string) => Promise<void>
10
+ type ListBranchesFn = (teamName: string, cwd: string) => Promise<string[]>
11
+ type BranchExistsFn = (branch: string, cwd: string) => Promise<boolean>
12
+
13
+ interface PurgeTarget {
14
+ id: string
15
+ name: string
16
+ time_updated: number
17
+ }
18
+
19
+ interface PurgeStats extends PurgeTarget {
20
+ members: number
21
+ tasks: number
22
+ messages: number
23
+ branches: number
24
+ staleResources: number
25
+ staleBranches: number
26
+ }
27
+
28
+ interface PurgeMemberResource {
29
+ team_id: string
30
+ team_name: string
31
+ member_name: string
32
+ worktree_dir: string | null
33
+ workspace_id: string | null
34
+ worktree_branch: string | null
35
+ }
36
+
37
+ async function listPreservedBranches(teamName: string, cwd: string): Promise<string[]> {
38
+ try {
39
+ const proc = Bun.spawn(["git", "branch", "--list", `ensemble/preserved/${teamName}/*`, "--format", "%(refname:short)"], { cwd, stdout: "pipe", stderr: "pipe" })
40
+ const out = await new Response(proc.stdout).text()
41
+ const stderr = await new Response(proc.stderr).text()
42
+ const exit = await proc.exited
43
+ if (exit !== 0) throw new Error(stderr.trim() || `git branch exited with code ${exit}`)
44
+ return out.split("\n").map(branch => branch.trim()).filter(Boolean)
45
+ } catch (err) {
46
+ const message = err instanceof Error ? err.message : String(err)
47
+ if (message.includes("not a git repository")) return []
48
+ throw new Error(`Failed to list preserved branches for ${teamName}: ${err instanceof Error ? err.message : String(err)}`)
49
+ }
50
+ }
51
+
52
+ async function branchExists(branch: string, cwd: string): Promise<boolean> {
53
+ try {
54
+ const proc = Bun.spawn(["git", "branch", "--list", branch, "--format", "%(refname:short)"], { cwd, stdout: "pipe", stderr: "pipe" })
55
+ const out = await new Response(proc.stdout).text()
56
+ const stderr = await new Response(proc.stderr).text()
57
+ const exit = await proc.exited
58
+ if (exit !== 0) {
59
+ if (stderr.includes("not a git repository")) return false
60
+ throw new Error(stderr.trim() || `git branch exited with code ${exit}`)
61
+ }
62
+ return out.split("\n").map(item => item.trim()).includes(branch)
63
+ } catch (err) {
64
+ const message = err instanceof Error ? err.message : String(err)
65
+ if (message.includes("not a git repository")) return false
66
+ throw new Error(`Failed to check stale Ensemble branch ${branch}: ${message}`)
67
+ }
68
+ }
69
+
70
+ function normalizeBranchName(branch: string): string {
71
+ return branch.trim().replace(/^\*\s*/, "")
72
+ }
73
+
74
+ function resolvePurgeTargets(deps: ToolDeps, purge: string[]): PurgeTarget[] {
75
+ if (purge.length === 0) throw new Error("Pass at least one archived team name to purge, or ['*'] for all archived teams.")
76
+ if (purge.includes("*") && purge.length !== 1) {
77
+ throw new Error("Wildcard purge cannot be combined with explicit team names. Use purge: ['*'] by itself.")
78
+ }
79
+
80
+ if (purge.includes("*")) {
81
+ return deps.db.query("SELECT id, name, time_updated FROM team WHERE status = 'archived' ORDER BY time_updated DESC, name ASC")
82
+ .all() as PurgeTarget[]
83
+ }
84
+
85
+ const uniqueNames = [...new Set(purge)]
86
+ const rows = uniqueNames.map(name => ({
87
+ name,
88
+ team: deps.db.query("SELECT id, name, status, time_updated FROM team WHERE name = ?").get(name) as { id: string; name: string; status: string; time_updated: number } | null,
89
+ }))
90
+
91
+ const missing = rows.filter(row => row.team === null).map(row => row.name)
92
+ if (missing.length > 0) throw new Error(`Team not found: ${missing.join(", ")}`)
93
+
94
+ const active = rows.filter(row => row.team?.status === "active").map(row => row.name)
95
+ if (active.length > 0) throw new Error(`Cannot purge active team: ${active.join(", ")}`)
96
+
97
+ return rows
98
+ .map(row => row.team!)
99
+ .sort((a, b) => b.time_updated - a.time_updated || a.name.localeCompare(b.name))
100
+ }
101
+
102
+ function deleteArchivedTeams(deps: ToolDeps, targets: PurgeTarget[]): void {
103
+ const transaction = deps.db.transaction((teams: PurgeTarget[]) => {
104
+ teams.forEach(team => {
105
+ const row = deps.db.query("SELECT status FROM team WHERE id = ?").get(team.id) as { status: string } | null
106
+ if (!row) throw new Error(`Team not found: ${team.name}`)
107
+ if (row.status === "active") throw new Error(`Cannot purge active team: ${team.name}`)
108
+ })
109
+ validatePurgeResources(deps, teams)
110
+ teams.forEach(team => {
111
+ deps.db.run("DELETE FROM team WHERE id = ? AND status = 'archived'", [team.id])
112
+ })
113
+ })
114
+ transaction(targets)
115
+ targets.forEach(team => {
116
+ deps.registry.unregisterTeam(team.id)
117
+ spawnFailures.delete(team.id)
118
+ })
119
+ }
120
+
121
+ function getPurgeMemberResources(deps: ToolDeps, targets: PurgeTarget[]): PurgeMemberResource[] {
122
+ return targets.flatMap(target => deps.db.query(
123
+ `SELECT t.name as team_name,
124
+ tm.team_id,
125
+ tm.name as member_name,
126
+ tm.worktree_dir,
127
+ tm.workspace_id,
128
+ tm.worktree_branch
129
+ FROM team_member tm
130
+ JOIN team t ON tm.team_id = t.id
131
+ WHERE tm.team_id = ?`
132
+ ).all(target.id) as PurgeMemberResource[])
133
+ }
134
+
135
+ function preservedBranchPrefix(resource: PurgeMemberResource): string {
136
+ return `ensemble/preserved/${resource.team_name}/`
137
+ }
138
+
139
+ function staleEnsembleBranchNames(resource: PurgeMemberResource): string[] {
140
+ return [
141
+ `ensemble-${resource.team_name}-${resource.member_name}`,
142
+ `opencode/ensemble-${resource.team_name}-${resource.member_name}`,
143
+ ]
144
+ }
145
+
146
+ function isPreservedBranch(resource: PurgeMemberResource): boolean {
147
+ return resource.worktree_branch !== null && resource.worktree_branch.startsWith(preservedBranchPrefix(resource))
148
+ }
149
+
150
+ function isStaleEnsembleBranch(resource: PurgeMemberResource): boolean {
151
+ return resource.worktree_branch !== null && staleEnsembleBranchNames(resource).includes(resource.worktree_branch)
152
+ }
153
+
154
+ function validatePurgeResources(deps: ToolDeps, targets: PurgeTarget[]): void {
155
+ const resources = getPurgeMemberResources(deps, targets)
156
+ const nonPreserved = resources.filter(resource =>
157
+ resource.worktree_branch !== null && !isPreservedBranch(resource) && !isStaleEnsembleBranch(resource)
158
+ )
159
+ if (nonPreserved.length > 0) {
160
+ const details = nonPreserved.map(resource => `${resource.team_name}/${resource.member_name} (${resource.worktree_branch})`).join(", ")
161
+ throw new Error(`Cannot purge archived teams: ${details} has a non-preserved worktree branch or a branch outside its preserved namespace.`)
162
+ }
163
+
164
+ const activeResourceRefs = resources.flatMap(resource => {
165
+ const worktreeRefs = resource.worktree_dir
166
+ ? deps.db.query(
167
+ `SELECT t.name as team_name, tm.name as member_name
168
+ FROM team_member tm
169
+ JOIN team t ON tm.team_id = t.id
170
+ WHERE t.status = 'active' AND tm.worktree_dir = ?`
171
+ ).all(resource.worktree_dir) as Array<{ team_name: string; member_name: string }>
172
+ : []
173
+ const workspaceRefs = resource.workspace_id
174
+ ? deps.db.query(
175
+ `SELECT t.name as team_name, tm.name as member_name
176
+ FROM team_member tm
177
+ JOIN team t ON tm.team_id = t.id
178
+ WHERE t.status = 'active' AND tm.workspace_id = ?`
179
+ ).all(resource.workspace_id) as Array<{ team_name: string; member_name: string }>
180
+ : []
181
+ return [
182
+ ...worktreeRefs.map(ref => `${resource.team_name}/${resource.member_name} worktree is also referenced by active team ${ref.team_name}/${ref.member_name}`),
183
+ ...workspaceRefs.map(ref => `${resource.team_name}/${resource.member_name} workspace is also referenced by active team ${ref.team_name}/${ref.member_name}`),
184
+ ]
185
+ })
186
+ if (activeResourceRefs.length > 0) {
187
+ throw new Error(`Cannot purge archived teams: ${[...new Set(activeResourceRefs)].join(", ")}.`)
188
+ }
189
+ }
190
+
191
+ function collectStaleEnsembleBranches(deps: ToolDeps, targets: PurgeTarget[]): string[] {
192
+ return [...new Set(
193
+ getPurgeMemberResources(deps, targets)
194
+ .filter(isStaleEnsembleBranch)
195
+ .map(resource => resource.worktree_branch!)
196
+ )]
197
+ }
198
+
199
+ function countStaleResourceRefs(deps: ToolDeps, target: PurgeTarget): number {
200
+ return getPurgeMemberResources(deps, [target]).reduce(
201
+ (count, resource) => count + (resource.worktree_dir ? 1 : 0) + (resource.workspace_id ? 1 : 0),
202
+ 0,
203
+ )
204
+ }
205
+
206
+ function countStaleBranchRefs(deps: ToolDeps, target: PurgeTarget): number {
207
+ return collectStaleEnsembleBranches(deps, [target]).length
208
+ }
209
+
210
+ async function existingWorktreeDirs(deps: ToolDeps, resources: PurgeMemberResource[]): Promise<Set<string>> {
211
+ if (!resources.some(resource => resource.worktree_dir)) return new Set()
212
+ try {
213
+ const result = await deps.client.worktree.list()
214
+ return new Set((result.data ?? []).map(worktree => worktree.directory))
215
+ } catch (err) {
216
+ const message = err instanceof Error ? err.message : String(err)
217
+ throw new Error(`Cannot purge archived teams: failed to list worktrees before stale resource cleanup: ${message}`)
218
+ }
219
+ }
220
+
221
+ async function existingWorkspaceIds(deps: ToolDeps, resources: PurgeMemberResource[]): Promise<Set<string>> {
222
+ if (!resources.some(resource => resource.workspace_id)) return new Set()
223
+ try {
224
+ const result = await deps.client.workspace.list()
225
+ return new Set((result.data ?? []).map(workspace => workspace.id))
226
+ } catch (err) {
227
+ const message = err instanceof Error ? err.message : String(err)
228
+ throw new Error(`Cannot purge archived teams: failed to list workspaces before stale resource cleanup: ${message}`)
229
+ }
230
+ }
231
+
232
+ async function cleanupStalePurgeResources(deps: ToolDeps, targets: PurgeTarget[], isDirty: IsDirtyFn): Promise<void> {
233
+ const resources = getPurgeMemberResources(deps, targets).filter(resource => resource.worktree_dir || resource.workspace_id)
234
+ if (resources.length === 0) return
235
+
236
+ const worktreeDirs = await existingWorktreeDirs(deps, resources)
237
+ const workspaceIds = await existingWorkspaceIds(deps, resources)
238
+
239
+ for (const resource of resources) {
240
+ if (resource.workspace_id) {
241
+ if (workspaceIds.has(resource.workspace_id)) {
242
+ try {
243
+ await deps.client.workspace.remove({ id: resource.workspace_id })
244
+ } catch (err) {
245
+ const message = err instanceof Error ? err.message : String(err)
246
+ throw new Error(`Failed to remove stale workspace for ${resource.team_name}/${resource.member_name}: ${message}`)
247
+ }
248
+ }
249
+ deps.db.run("UPDATE team_member SET workspace_id = NULL WHERE team_id = ? AND name = ?", [resource.team_id, resource.member_name])
250
+ }
251
+
252
+ if (resource.worktree_dir) {
253
+ if (worktreeDirs.has(resource.worktree_dir)) {
254
+ let dirty: boolean
255
+ try {
256
+ dirty = await isDirty(resource.worktree_dir)
257
+ } catch (err) {
258
+ const message = err instanceof Error ? err.message : String(err)
259
+ throw new Error(`Cannot purge archived teams: failed to check archived worktree for uncommitted changes at ${resource.worktree_dir}: ${message}`)
260
+ }
261
+ if (dirty) {
262
+ throw new Error(`Cannot purge archived teams: ${resource.team_name}/${resource.member_name} has uncommitted changes in archived worktree ${resource.worktree_dir}.`)
263
+ }
264
+ try {
265
+ await deps.client.worktree.remove({ worktreeRemoveInput: { directory: resource.worktree_dir } })
266
+ } catch (err) {
267
+ const message = err instanceof Error ? err.message : String(err)
268
+ throw new Error(`Failed to remove stale worktree for ${resource.team_name}/${resource.member_name}: ${message}`)
269
+ }
270
+ }
271
+ deps.db.run("UPDATE team_member SET worktree_dir = NULL WHERE team_id = ? AND name = ?", [resource.team_id, resource.member_name])
272
+ }
273
+ }
274
+ }
275
+
276
+ async function deleteStaleEnsembleBranches(
277
+ branches: string[],
278
+ cwd: string,
279
+ delBranch: DeleteBranchFn,
280
+ exists: BranchExistsFn,
281
+ ): Promise<void> {
282
+ for (const branch of branches) {
283
+ if (!await exists(branch, cwd)) continue
284
+ const ok = await delBranch(branch, cwd)
285
+ if (!ok) throw new Error(`Failed to delete stale Ensemble branch: ${branch}`)
286
+ }
287
+ }
288
+
289
+ function validatePurgeTargetsStillArchived(deps: ToolDeps, targets: PurgeTarget[]): void {
290
+ const missing: string[] = []
291
+ const active: string[] = []
292
+ targets.forEach(target => {
293
+ const row = deps.db.query("SELECT status FROM team WHERE id = ?").get(target.id) as { status: string } | null
294
+ if (!row) missing.push(target.name)
295
+ else if (row.status === "active") active.push(target.name)
296
+ })
297
+
298
+ if (missing.length > 0) throw new Error(`Team not found: ${missing.join(", ")}`)
299
+ if (active.length > 0) throw new Error(`Cannot purge active team: ${active.join(", ")}`)
300
+ }
301
+
302
+ async function collectPreservedBranches(
303
+ targets: PurgeTarget[],
304
+ cwd: string,
305
+ listBranches: ListBranchesFn,
306
+ ): Promise<Map<string, string[]>> {
307
+ const entries: Array<[string, string[]]> = await Promise.all(targets.map(async target => {
308
+ const prefix = `ensemble/preserved/${target.name}/`
309
+ let listed: string[]
310
+ try {
311
+ listed = await listBranches(target.name, cwd)
312
+ } catch (err) {
313
+ const message = err instanceof Error ? err.message : String(err)
314
+ if (message.includes("not a git repository")) listed = []
315
+ else throw new Error(`Failed to list preserved branches for ${target.name}: ${message}`)
316
+ }
317
+ const branches = listed.map(normalizeBranchName).filter(branch => branch.startsWith(prefix))
318
+ return [target.id, [...new Set(branches)]]
319
+ }))
320
+ return new Map(entries)
321
+ }
322
+
323
+ async function deletePreservedBranches(
324
+ branchesByTeam: Map<string, string[]>,
325
+ cwd: string,
326
+ delBranch: DeleteBranchFn,
327
+ ): Promise<void> {
328
+ const branches = [...branchesByTeam.values()].flat()
329
+ for (const branch of branches) {
330
+ const ok = await delBranch(branch, cwd)
331
+ if (!ok) throw new Error(`Failed to delete preserved branch: ${branch}`)
332
+ }
333
+ }
334
+
335
+ function formatCount(count: number, noun: string, plural = `${noun}s`): string {
336
+ return `${count} ${count === 1 ? noun : plural}`
337
+ }
338
+
339
+ function buildPurgePreview(deps: ToolDeps, targets: PurgeTarget[], branchesByTeam: Map<string, string[]>): string {
340
+ const rows = targets.map(target => {
341
+ const members = (deps.db.query("SELECT COUNT(*) as c FROM team_member WHERE team_id = ?").get(target.id) as { c: number }).c
342
+ const tasks = (deps.db.query("SELECT COUNT(*) as c FROM team_task WHERE team_id = ?").get(target.id) as { c: number }).c
343
+ const messages = (deps.db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ?").get(target.id) as { c: number }).c
344
+ const branches = branchesByTeam.get(target.id)?.length ?? 0
345
+ const staleResources = countStaleResourceRefs(deps, target)
346
+ const staleBranches = countStaleBranchRefs(deps, target)
347
+ return { ...target, members, tasks, messages, branches, staleResources, staleBranches }
348
+ }) satisfies PurgeStats[]
349
+ const totals = rows.reduce(
350
+ (acc, row) => ({
351
+ members: acc.members + row.members,
352
+ tasks: acc.tasks + row.tasks,
353
+ messages: acc.messages + row.messages,
354
+ branches: acc.branches + row.branches,
355
+ staleResources: acc.staleResources + row.staleResources,
356
+ staleBranches: acc.staleBranches + row.staleBranches,
357
+ }),
358
+ { members: 0, tasks: 0, messages: 0, branches: 0, staleResources: 0, staleBranches: 0 }
359
+ )
360
+ const details = rows.slice(0, 10).map(row =>
361
+ `- ${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")}`
362
+ )
363
+ const hidden = rows.length > 10 ? [`...and ${rows.length - 10} more archived team${rows.length - 10 === 1 ? "" : "s"}`] : []
364
+
365
+ return [
366
+ "Permanently delete archived teams?",
367
+ "This will delete archived team records and cascade-delete their members, tasks, and messages.",
368
+ "",
369
+ ...details,
370
+ ...hidden,
371
+ "",
372
+ `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")}`,
373
+ ].join("\n")
374
+ }
375
+
376
+ function buildPurgeConfirmationInstructions(preview: string, confirmToken: string): string {
377
+ const approvalLabel = `Approve purge ${confirmToken.slice(0, 8)}`
378
+ const denialLabel = `Deny purge ${confirmToken.slice(0, 8)}`
379
+ return [
380
+ "Purge preview only — no teams were deleted.",
381
+ "",
382
+ preview,
383
+ "",
384
+ "Use the question tool to ask the user whether to permanently delete these archived teams.",
385
+ `The approval option label must be exactly: ${approvalLabel}`,
386
+ `The denial option label must be exactly: ${denialLabel}`,
387
+ `Confirmation token: ${confirmToken}`,
388
+ `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.`,
389
+ ].join("\n")
390
+ }
391
+
9
392
  /**
10
393
  * Execute the team_cleanup tool. Archives the team and cleans up resources.
11
394
  * Acts as a safety net: merges any remaining unmerged preserved branches
@@ -13,14 +396,51 @@ import { log } from "../log"
13
396
  */
14
397
  export async function executeTeamCleanup(
15
398
  deps: ToolDeps,
16
- args: { force: boolean; acknowledge_uncommitted?: boolean },
399
+ args: { force: boolean; acknowledge_uncommitted?: boolean; purge?: string[]; confirm_purge?: boolean; confirm_token?: string },
17
400
  sessionId: string,
18
401
  isDirty: IsDirtyFn = checkWorktreeDirty,
19
402
  merge: MergeBranchFn = mergeBranch,
20
403
  delBranch: DeleteBranchFn = deleteBranch,
21
404
  mergeOnCleanup = true,
22
405
  overlapCheck: OverlapCheckFn = getOverlappingFiles,
406
+ _approvePurge?: PurgeApprovalFn,
407
+ _listBranches?: ListBranchesFn,
408
+ _branchExists?: BranchExistsFn,
23
409
  ): Promise<string> {
410
+ if (args.purge && args.purge.length > 0) {
411
+ requireCanPurgeArchivedTeams(deps, sessionId)
412
+ const targets = resolvePurgeTargets(deps, args.purge)
413
+ if (targets.length === 0) return "No archived teams to purge."
414
+
415
+ validatePurgeTargetsStillArchived(deps, targets)
416
+ validatePurgeResources(deps, targets)
417
+ const branchesByTeam = await collectPreservedBranches(targets, deps.directory, _listBranches ?? listPreservedBranches)
418
+ const preview = buildPurgePreview(deps, targets, branchesByTeam)
419
+ if (!args.confirm_purge) {
420
+ const confirmToken = deps.purgeApprovals.create(sessionId, targets.map(target => target.id))
421
+ return buildPurgeConfirmationInstructions(preview, confirmToken)
422
+ }
423
+
424
+ if (!args.confirm_token) {
425
+ throw new Error("A purge confirmation token is required. First call team_cleanup without confirm_purge, then use the question tool before confirming.")
426
+ }
427
+ deps.purgeApprovals.consume(sessionId, args.confirm_token, targets.map(target => target.id))
428
+
429
+ validatePurgeTargetsStillArchived(deps, targets)
430
+ validatePurgeResources(deps, targets)
431
+ await cleanupStalePurgeResources(deps, targets, isDirty)
432
+
433
+ validatePurgeResources(deps, targets)
434
+ await deleteStaleEnsembleBranches(collectStaleEnsembleBranches(deps, targets), deps.directory, delBranch, _branchExists ?? branchExists)
435
+ const finalBranchesByTeam = await collectPreservedBranches(targets, deps.directory, _listBranches ?? listPreservedBranches)
436
+ await deletePreservedBranches(finalBranchesByTeam, deps.directory, delBranch)
437
+
438
+ deleteArchivedTeams(deps, targets)
439
+
440
+ const noun = targets.length === 1 ? "archived team" : "archived teams"
441
+ return `Permanently deleted ${targets.length} ${noun}: ${targets.map(target => target.name).join(", ")}.`
442
+ }
443
+
24
444
  const teamInfo = requireLead(deps, sessionId)
25
445
 
26
446
  const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch, workspace_id FROM team_member WHERE team_id = ?")
package/src/types.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Database } from "bun:sqlite"
2
- import type { MemberRegistry, DescendantTracker } from "./state"
2
+ import type { MemberRegistry, DescendantTracker, PendingPurgeApprovals } from "./state"
3
3
  import type { EnsembleConfig } from "./config"
4
4
 
5
5
  /**
@@ -10,6 +10,7 @@ export interface ToolDeps {
10
10
  db: Database
11
11
  registry: MemberRegistry
12
12
  tracker: DescendantTracker
13
+ purgeApprovals: PendingPurgeApprovals
13
14
  /** The OpenCode SDK client — used for session.create, promptAsync, abort, etc. */
14
15
  client: PluginClient
15
16
  /** The project root directory — used for reading AGENTS.md and other project files. */