@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.
- package/README.md +57 -35
- package/dist/index.js +28760 -19583
- package/package.json +18 -5
- package/src/index.ts +26 -3
- package/src/log.ts +9 -0
- package/src/notify.ts +3 -1
- package/src/rpc.ts +99 -0
- package/src/tool-arg-hint.ts +71 -0
- package/src/tools/team-message.ts +1 -0
- package/src/tools/team-spawn.ts +48 -4
- package/src/tools/team-tasks-add.ts +17 -0
- package/src/tools/team-view.ts +10 -4
- package/src/tui-view.ts +50 -0
- package/src/tui.tsx +101 -0
- package/src/types.ts +6 -0
- package/src/v2-client.ts +312 -0
- package/src/v2-events.ts +73 -0
- package/src/v2-rpc.ts +24 -0
- package/src/v2-session.ts +56 -0
- package/src/v2-setup.ts +362 -0
- package/src/v2-tools.ts +349 -0
package/src/v2-setup.ts
ADDED
|
@@ -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
|
+
}
|