@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 +9 -7
- package/dist/index.js +475 -12
- package/package.json +1 -1
- package/src/index.ts +35 -5
- package/src/state.ts +107 -0
- package/src/system-prompt.ts +5 -0
- package/src/tools/shared.ts +35 -0
- package/src/tools/team-cleanup.ts +422 -2
- package/src/types.ts +2 -1
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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/system-prompt.ts
CHANGED
|
@@ -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")
|
package/src/tools/shared.ts
CHANGED
|
@@ -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
|
+
}
|