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