@hueyexe/opencode-ensemble 0.17.0 → 0.20.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.
@@ -0,0 +1,362 @@
1
+ import type { Database } from "./db"
2
+ import { createDb, getDbPath } from "./db"
3
+ import type { MemberRegistry, DescendantTracker } from "./state"
4
+ import { MemberRegistry as MemberRegistryImpl, DescendantTracker as DescendantTrackerImpl, PendingPurgeApprovals } from "./state"
5
+ import { dispatchV2Event, type V2EventLike } from "./v2-events"
6
+ import { deliverPrompt, type V2SessionPort } from "./v2-session"
7
+ import type { V2Context } from "./v2-client"
8
+ import { createV2Client } from "./v2-client"
9
+ import { registerV2Tools } from "./v2-tools"
10
+ import type { ToolDeps } from "./types"
11
+ import { ProgressTracker } from "./progress"
12
+ import { checkToolIsolation, shouldNudgeIdleMember } from "./hooks"
13
+ import { hasReportedCompletion } from "./messaging"
14
+ import { enrichToolError } from "./tool-arg-hint"
15
+ import { rehydrateRegistry } from "./recovery"
16
+ import { findTeamBySession } from "./types"
17
+ import { loadConfig } from "./config"
18
+ import { TokenBucket } from "./rate-limit"
19
+ import { ActivityBuffer, recordFromToolBefore, recordFromToolAfter } from "./activity"
20
+ import { buildLeadSystemPrompt, buildTeammateSystemPrompt, buildTeamCompactionContext } from "./system-prompt"
21
+ import { startDashboard, type DashboardServer } from "./dashboard"
22
+ import { EnsembleRpc, emitMemberEvent, emitNoticeEvent } from "./v2-rpc"
23
+ import { isWorktreeInstance } from "./util"
24
+ import { vlog } from "./log"
25
+
26
+ /** V2 setup context subset (structural, mock-friendly). */
27
+ export interface V2SetupContext {
28
+ location: { directory: string; project?: { id?: string } }
29
+ agent?: V2Context["agent"]
30
+ options: Record<string, unknown>
31
+ session: V2Context["session"] & {
32
+ hook(name: string, cb: (event: never) => unknown): Promise<{ dispose(): Promise<void> }>
33
+ }
34
+ worktree: V2Context["worktree"]
35
+ permission: V2Context["permission"]
36
+ event: {
37
+ subscribe(opts?: { signal?: AbortSignal }): AsyncIterable<V2EventLike>
38
+ }
39
+ tool: {
40
+ hook(name: string, cb: (event: never) => unknown): Promise<{ dispose(): Promise<void> }>
41
+ transform(cb: (editor: never) => void): Promise<{ dispose(): Promise<void> }>
42
+ }
43
+ rpc: {
44
+ register(
45
+ definition: unknown,
46
+ handlers: Record<string, (input: unknown, context: unknown) => Promise<unknown>>,
47
+ ): Promise<{ events: { emit(name: string, data: unknown): Promise<unknown> } }>
48
+ }
49
+ shell: {
50
+ hook(name: string, cb: (event: never) => unknown): Promise<{ dispose(): Promise<void> }>
51
+ }
52
+ }
53
+
54
+ /** Extract answer text from a V2 tool result for approval matching. */
55
+ export function extractQuestionOutput(result?: {
56
+ content?: string | Array<{ type?: string; text?: string }>
57
+ }): string {
58
+ const content = result?.content
59
+ if (typeof content === "string") return content
60
+ if (Array.isArray(content)) {
61
+ return content
62
+ .filter((part) => part.type === "text")
63
+ .map((part) => part.text ?? "")
64
+ .join("\n")
65
+ }
66
+ return ""
67
+ }
68
+
69
+ /** Options for setupEnsemble. */
70
+ export interface SetupOptions {
71
+ /** SQLite path. Defaults to the global ensemble.db. */
72
+ dbPath?: string
73
+ /** Dashboard port. Defaults to config; 0 disables. */
74
+ dashboardPort?: number
75
+ }
76
+
77
+ /** Live handle for a V2 ensemble instance. */
78
+ export interface EnsembleHandle {
79
+ db: Database
80
+ registry: MemberRegistry
81
+ tracker: DescendantTracker
82
+ /** Dispatch one event (used by the live subscription loop; tests call directly). */
83
+ dispatch(event: V2EventLike): Promise<void>
84
+ /** Stop the event loop and release resources. */
85
+ dispose(): Promise<void>
86
+ }
87
+
88
+ /**
89
+ * Initialize Ensemble on a V2 plugin context: database, registries, event
90
+ * subscription, tool hooks, session hooks, and shell env. Tool registration
91
+ * lands in the next slice.
92
+ */
93
+ export async function setupEnsemble(
94
+ ctx: V2SetupContext,
95
+ options: SetupOptions = {},
96
+ ): Promise<EnsembleHandle> {
97
+ const db = createDb(options.dbPath ?? getDbPath())
98
+ const config = loadConfig(ctx.location.directory)
99
+ const registry: MemberRegistry = new MemberRegistryImpl()
100
+ const tracker: DescendantTracker = new DescendantTrackerImpl()
101
+ const purgeApprovals = new PendingPurgeApprovals()
102
+ const activityBuffer = new ActivityBuffer()
103
+ const progressTracker = new ProgressTracker()
104
+ const nudgedMembers = new Set<string>()
105
+
106
+ // Rehydrate the in-memory registry from SQLite so members from before a
107
+ // restart are visible again. Without this, their events are ignored and
108
+ // teams freeze silently after every server restart (seen live 2026-09-14).
109
+ const rehydrated = rehydrateRegistry(db, registry)
110
+ if (rehydrated > 0) vlog(`init:registry:rehydrated members=${rehydrated}`)
111
+
112
+ /** Send the idle-without-report nudge once per member. */
113
+ const nudgeMember = (teamId: string, memberName: string, sessionId: string): void => {
114
+ const nudgeKey = `${teamId}:${memberName}`
115
+ if (nudgedMembers.has(nudgeKey)) return
116
+ if (!shouldNudgeIdleMember(db, teamId, memberName)) return
117
+ if (hasReportedCompletion(db, teamId, memberName)) return
118
+ nudgedMembers.add(nudgeKey)
119
+ // Persist so hot-reloads don't re-nudge: every setup re-runs the sweep,
120
+ // and without this each file save re-nudged every silent member (seen
121
+ // live 2026-09-14 — 40+ nudges on one scout).
122
+ db.run("UPDATE team_member SET last_nudged_at = ? WHERE team_id = ? AND name = ?", [
123
+ Date.now(),
124
+ teamId,
125
+ memberName,
126
+ ])
127
+ vlog(`nudge:idle-without-report name=${memberName}`)
128
+ void deliverPrompt(
129
+ ctx.session as unknown as V2SessionPort,
130
+ sessionId,
131
+ "[System]: You completed your work but did not report results. Send your findings to the lead via team_message now.",
132
+ ).catch((err) => {
133
+ vlog(`nudge:idle-without-report:failed name=${memberName} err=${err instanceof Error ? err.message : String(err)}`)
134
+ })
135
+ }
136
+
137
+ // Startup sweep: members already idle (e.g. finished while the plugin was
138
+ // down) never emit a fresh transition, so nudge them now. Skip members
139
+ // nudged within the hour — the guard survives reloads via the DB column.
140
+ const nudgeCutoff = Date.now() - 60 * 60 * 1000
141
+ const silent = db.query(
142
+ `SELECT tm.team_id, tm.name, tm.session_id FROM team_member tm
143
+ JOIN team t ON tm.team_id = t.id
144
+ WHERE t.status = 'active' AND tm.status = 'ready'
145
+ AND (tm.last_nudged_at IS NULL OR tm.last_nudged_at < ?)`,
146
+ ).all(nudgeCutoff) as Array<{ team_id: string; name: string; session_id: string }>
147
+ for (const member of silent) nudgeMember(member.team_id, member.name, member.session_id)
148
+ const rateLimiter = new TokenBucket({
149
+ capacity: config.rateLimitCapacity,
150
+ refillRate: 2,
151
+ refillIntervalMs: 1000,
152
+ })
153
+ const controller = new AbortController()
154
+
155
+ const dispatch = async (event: V2EventLike): Promise<void> => {
156
+ const transition = dispatchV2Event(db, registry, tracker, event)
157
+ // Idle-without-report nudge (parity with V1): a member that went idle
158
+ // without ever messaging the lead gets ONE reminder to report via
159
+ // team_message. Without this, weak models finish silently and the lead
160
+ // waits forever — the exact failure seen live on 2026-09-14.
161
+ if (transition && transition.to === "ready" && transition.from === "busy") {
162
+ const entry = registry.getByName(transition.teamId, transition.memberName)
163
+ if (entry) nudgeMember(transition.teamId, transition.memberName, entry.sessionId)
164
+ }
165
+ if (transition && rpc) {
166
+ await emitMemberEvent(rpc, transition).catch(() => {
167
+ // Companions are optional — never break the state machine for them.
168
+ })
169
+ if (transition.to === "ready" && transition.from === "busy") {
170
+ await emitNoticeEvent(rpc, {
171
+ title: "Team",
172
+ message: `${transition.memberName} finished`,
173
+ variant: "success",
174
+ }).catch(() => undefined)
175
+ } else if (transition.to === "error") {
176
+ await emitNoticeEvent(rpc, {
177
+ title: "Team",
178
+ message: `${transition.memberName} errored`,
179
+ variant: "error",
180
+ }).catch(() => undefined)
181
+ }
182
+ }
183
+ }
184
+
185
+ // RPC bridge for terminal companions (toasts, navigation). Best-effort:
186
+ // a failed registration must not break tools, hooks, or events.
187
+ let rpc: { events: { emit(name: string, data: unknown): Promise<unknown> } } | null = null
188
+ try {
189
+ rpc = await ctx.rpc.register(EnsembleRpc, {
190
+ summary: async (input) => {
191
+ const team = (input as { team?: string }).team ?? ""
192
+ const row = db.query("SELECT status FROM team WHERE name = ?").get(team) as {
193
+ status: string
194
+ } | null
195
+ if (!row) return { text: `No team "${team}".` }
196
+ const members = db.query("SELECT name, status FROM team_member WHERE team_id IN (SELECT id FROM team WHERE name = ?)").all(team) as Array<{
197
+ name: string
198
+ status: string
199
+ }>
200
+ const summary = members.map((m) => `${m.name}: ${m.status}`).join(", ")
201
+ return { text: `Team "${team}" (${row.status}): ${summary || "no members"}` }
202
+ },
203
+ teamContext: async (input) => {
204
+ const sessionID = (input as { sessionID?: string }).sessionID ?? ""
205
+ const teamInfo = findTeamBySession(db, registry, sessionID)
206
+ if (!teamInfo) return { team: null, members: [], tasks: { pending: 0, done: 0 } }
207
+ const members = db.query("SELECT name, status FROM team_member WHERE team_id = ?").all(
208
+ teamInfo.teamId,
209
+ ) as Array<{ name: string; status: string }>
210
+ const pending = (
211
+ db.query("SELECT COUNT(*) as c FROM team_task WHERE team_id = ? AND status != 'completed'").get(
212
+ teamInfo.teamId,
213
+ ) as { c: number }
214
+ ).c
215
+ const done = (
216
+ db.query("SELECT COUNT(*) as c FROM team_task WHERE team_id = ? AND status = 'completed'").get(
217
+ teamInfo.teamId,
218
+ ) as { c: number }
219
+ ).c
220
+ return { team: teamInfo.teamName, members, tasks: { pending, done } }
221
+ },
222
+ })
223
+ } catch (err) {
224
+ vlog(`init:rpc:failed err=${err instanceof Error ? err.message : String(err)}`)
225
+ }
226
+
227
+ // Live event loop — fire-and-forget; dispose() aborts it.
228
+ void (async () => {
229
+ try {
230
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
231
+ await dispatch(event)
232
+ }
233
+ } catch {
234
+ // Aborted on dispose or stream error — nothing to report.
235
+ }
236
+ })()
237
+
238
+ await ctx.tool.hook("execute.before", (raw) => {
239
+ const input = raw as unknown as { tool: string; sessionID: string }
240
+ checkToolIsolation(registry, tracker, input.tool, input.sessionID, db)
241
+ if (input.tool.startsWith("team_")) {
242
+ if (!rateLimiter.tryConsume()) {
243
+ return rateLimiter.waitForToken().then(() => undefined)
244
+ }
245
+ }
246
+ recordFromToolBefore({ sessionID: input.sessionID, tool: input.tool }, registry, activityBuffer)
247
+ })
248
+
249
+ await ctx.tool.hook("execute.after", (raw) => {
250
+ const event = raw as unknown as {
251
+ tool: string
252
+ sessionID: string
253
+ input: unknown
254
+ status: string
255
+ result?: { content?: string | Array<{ type?: string; text?: string }> }
256
+ error?: { message?: unknown }
257
+ }
258
+ if (event.status === "error" && event.error) {
259
+ // Weak-model arg fumbles (issue #39): the call is already dead from
260
+ // validation, so append an explicit "the param is X, not Y" hint to
261
+ // the error the model sees. Arguments are never rewritten — the
262
+ // model stays in the correction loop. Fail-open by design: a
263
+ // non-fumble error is returned untouched.
264
+ if (enrichToolError(event.tool, event.error, event.input)) {
265
+ vlog(`tool-arg-hint:fired tool=${event.tool} session=${event.sessionID}`)
266
+ }
267
+ }
268
+ if (event.tool === "question") {
269
+ // Feed the answer text to purge approvals (team_cleanup confirm flow).
270
+ // The question tool renders in every client, so this is the universal
271
+ // approval channel on V2 — no forms or TUI needed.
272
+ purgeApprovals.recordQuestionAnswer(
273
+ event.sessionID,
274
+ extractQuestionOutput(event.result),
275
+ event.input,
276
+ )
277
+ }
278
+ recordFromToolAfter(
279
+ { sessionID: event.sessionID, tool: event.tool },
280
+ {},
281
+ registry,
282
+ activityBuffer,
283
+ )
284
+ })
285
+
286
+ await ctx.session.hook("context", (raw) => {
287
+ const event = raw as unknown as {
288
+ sessionID: string
289
+ system: Array<{ type: string; text: string }>
290
+ }
291
+ if (!event.sessionID) return
292
+ const teamInfo = findTeamBySession(db, registry, event.sessionID)
293
+ if (!teamInfo) return
294
+ const prompt =
295
+ teamInfo.role === "lead"
296
+ ? buildLeadSystemPrompt(db, teamInfo.teamId, config)
297
+ : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown")
298
+ vlog(`system-prompt:injected role=${teamInfo.role} len=${prompt.length}`)
299
+ event.system.push({ type: "text", text: prompt })
300
+ if (teamInfo.role === "member") {
301
+ // V2 invokes plugin tools through Code Mode, not direct calls —
302
+ // without this hint teammates attempt direct calls that the runtime
303
+ // rejects with "No tool named team_message is currently available"
304
+ // (seen live 2026-09-14: explore scout denied twice, gave up).
305
+ event.system.push({
306
+ type: "text",
307
+ text: 'Ensemble tools on this platform are called through the execute tool, e.g. tools.team_message({ to: "lead", text: "..." }). Do not call them directly.',
308
+ })
309
+ }
310
+ })
311
+
312
+ await ctx.session.hook("compaction", (raw) => {
313
+ const event = raw as unknown as {
314
+ sessionID: string
315
+ system: Array<{ type: string; text: string }>
316
+ }
317
+ if (!event.sessionID) return
318
+ const teamInfo = findTeamBySession(db, registry, event.sessionID)
319
+ if (!teamInfo) return
320
+ const context = buildTeamCompactionContext(db, teamInfo.teamId, teamInfo.role, teamInfo.memberName)
321
+ event.system.push({ type: "text", text: context })
322
+ })
323
+
324
+ // OQ-V2-shell: the V2 shell hook carries no sessionID, so per-session
325
+ // ENSEMBLE_* env cannot be scoped. Registered as a placeholder until the
326
+ // API gains session scope — team tools and prompts carry identity instead.
327
+ await ctx.shell.hook("create.before", () => undefined)
328
+
329
+ const client = createV2Client(ctx, rpc ? { rpcEmitter: rpc } : {})
330
+ const deps: ToolDeps = {
331
+ db,
332
+ registry,
333
+ tracker,
334
+ purgeApprovals,
335
+ client,
336
+ directory: ctx.location.directory,
337
+ config,
338
+ progressTracker,
339
+ }
340
+ await registerV2Tools(ctx.tool, deps)
341
+
342
+ // Dashboard mirrors V1: main instance only, skipped when port is 0.
343
+ let dashboard: DashboardServer | null = null
344
+ const dashboardPort = options.dashboardPort ?? config.dashboardPort
345
+ if (dashboardPort !== 0 && !isWorktreeInstance(ctx.location.directory)) {
346
+ dashboard = await startDashboard(db, dashboardPort, { activityBuffer, client }).catch((err) => {
347
+ vlog(`init:dashboard:failed err=${err instanceof Error ? err.message : String(err)}`)
348
+ return null
349
+ })
350
+ }
351
+
352
+ return {
353
+ db,
354
+ registry,
355
+ tracker,
356
+ dispatch,
357
+ dispose: async () => {
358
+ controller.abort()
359
+ dashboard?.stop()
360
+ },
361
+ }
362
+ }