@hueyexe/opencode-ensemble 0.5.1 → 0.6.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 +4 -0
- package/dist/index.js +3247 -255
- package/package.json +3 -3
- package/src/client.ts +54 -0
- package/src/index.ts +9 -3
- package/src/recovery.ts +3 -3
- package/src/schema.ts +2 -0
- package/src/tools/team-broadcast.ts +2 -2
- package/src/tools/team-cleanup.ts +1 -1
- package/src/tools/team-message.ts +2 -2
- package/src/tools/team-shutdown.ts +6 -8
- package/src/tools/team-spawn.ts +25 -13
- package/src/types.ts +24 -7
- package/src/watchdog.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hueyexe/opencode-ensemble",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Agent teams for OpenCode — parallel agents with peer-to-peer communication, shared tasks, and coordinated execution",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"typescript": "^5"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@opencode-ai/plugin": "^1.
|
|
42
|
-
"@opencode-ai/sdk": "^1.
|
|
41
|
+
"@opencode-ai/plugin": "^1.3.0",
|
|
42
|
+
"@opencode-ai/sdk": "^1.3.0"
|
|
43
43
|
}
|
|
44
44
|
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { PluginClient } from "./types"
|
|
2
|
+
|
|
3
|
+
/** Extract error message from a HeyAPI error response. */
|
|
4
|
+
function extractError(err: unknown): string {
|
|
5
|
+
if (err && typeof err === "object" && "message" in err) return String((err as { message: string }).message)
|
|
6
|
+
return String(err)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
|
+
type AnyFn = (...args: any[]) => Promise<any>
|
|
11
|
+
|
|
12
|
+
/** Wrap a single async SDK method to throw on { error } responses. */
|
|
13
|
+
function throwing(fn: AnyFn): AnyFn {
|
|
14
|
+
return async (...args: unknown[]) => {
|
|
15
|
+
const result = await fn(...args)
|
|
16
|
+
if (result && typeof result === "object" && "error" in result && result.error !== undefined) {
|
|
17
|
+
throw new Error(extractError(result.error))
|
|
18
|
+
}
|
|
19
|
+
return result
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Shape of the raw v2 SDK client — just the methods we wrap. */
|
|
24
|
+
interface RawClient {
|
|
25
|
+
session: { create: AnyFn; promptAsync: AnyFn; abort: AnyFn; status: AnyFn }
|
|
26
|
+
tui: { showToast: AnyFn; selectSession: AnyFn }
|
|
27
|
+
worktree: { create: AnyFn; remove: AnyFn; list: AnyFn; reset: AnyFn }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Wraps a raw v2 SDK client so that methods throw on error responses
|
|
32
|
+
* instead of returning { error }. This makes existing try/catch blocks work.
|
|
33
|
+
*/
|
|
34
|
+
export function wrapThrowingClient(raw: unknown): PluginClient {
|
|
35
|
+
const r = raw as RawClient
|
|
36
|
+
return {
|
|
37
|
+
session: {
|
|
38
|
+
create: throwing(r.session.create.bind(r.session)),
|
|
39
|
+
promptAsync: throwing(r.session.promptAsync.bind(r.session)),
|
|
40
|
+
abort: throwing(r.session.abort.bind(r.session)),
|
|
41
|
+
status: throwing(r.session.status.bind(r.session)),
|
|
42
|
+
},
|
|
43
|
+
tui: {
|
|
44
|
+
showToast: throwing(r.tui.showToast.bind(r.tui)),
|
|
45
|
+
selectSession: throwing(r.tui.selectSession.bind(r.tui)),
|
|
46
|
+
},
|
|
47
|
+
worktree: {
|
|
48
|
+
create: throwing(r.worktree.create.bind(r.worktree)),
|
|
49
|
+
remove: throwing(r.worktree.remove.bind(r.worktree)),
|
|
50
|
+
list: throwing(r.worktree.list.bind(r.worktree)),
|
|
51
|
+
reset: throwing(r.worktree.reset.bind(r.worktree)),
|
|
52
|
+
},
|
|
53
|
+
} as PluginClient
|
|
54
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
2
|
import { tool } from "@opencode-ai/plugin"
|
|
3
|
+
import { OpencodeClient as OpencodeClientV2 } from "@opencode-ai/sdk/v2"
|
|
3
4
|
import path from "path"
|
|
4
5
|
import { mkdirSync } from "node:fs"
|
|
5
6
|
import { createDb, getDbPath } from "./db"
|
|
7
|
+
import { wrapThrowingClient } from "./client"
|
|
6
8
|
import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktrees } from "./recovery"
|
|
7
9
|
import { MemberRegistry, DescendantTracker } from "./state"
|
|
8
10
|
import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation } from "./hooks"
|
|
@@ -47,8 +49,12 @@ const plugin: Plugin = async (input) => {
|
|
|
47
49
|
const registry = new MemberRegistry()
|
|
48
50
|
const tracker = new DescendantTracker()
|
|
49
51
|
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
+
// Reuse the v1 client's working HTTP transport with v2's method signatures.
|
|
53
|
+
// The v1 client (input.client) has the correct connection (Unix socket/auth),
|
|
54
|
+
// while v2 gives us flat params + permission on create + agent/tools on promptAsync.
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
56
|
+
const rawClient = new OpencodeClientV2({ client: (input.client as any)._client })
|
|
57
|
+
const client = wrapThrowingClient(rawClient)
|
|
52
58
|
const deps: ToolDeps = { db, registry, tracker, client, directory: input.directory }
|
|
53
59
|
|
|
54
60
|
// Run recovery on init — mark stale busy members as error + abort orphaned sessions
|
|
@@ -125,7 +131,7 @@ const plugin: Plugin = async (input) => {
|
|
|
125
131
|
const entry = registry.getBySession(sessionID)
|
|
126
132
|
if (entry) {
|
|
127
133
|
try {
|
|
128
|
-
await client.session.abort({
|
|
134
|
+
await client.session.abort({ sessionID })
|
|
129
135
|
} catch { /* best effort */ }
|
|
130
136
|
}
|
|
131
137
|
}
|
package/src/recovery.ts
CHANGED
|
@@ -29,7 +29,7 @@ export async function recoverStaleMembers(db: Database, client?: PluginClient):
|
|
|
29
29
|
if (client) {
|
|
30
30
|
for (const member of stale) {
|
|
31
31
|
try {
|
|
32
|
-
await client.session.abort({
|
|
32
|
+
await client.session.abort({ sessionID: member.session_id })
|
|
33
33
|
} catch { /* best effort */ }
|
|
34
34
|
}
|
|
35
35
|
}
|
|
@@ -111,8 +111,8 @@ export async function recoverUndeliveredMessages(
|
|
|
111
111
|
|
|
112
112
|
try {
|
|
113
113
|
await client.session.promptAsync({
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
sessionID: recipientSessionId,
|
|
115
|
+
parts: [{ type: "text", text: `[Recovered team message from ${msg.from_name}]: ${msg.content}` }],
|
|
116
116
|
})
|
|
117
117
|
markDelivered(db, msg.id)
|
|
118
118
|
redelivered++
|
package/src/schema.ts
CHANGED
|
@@ -76,6 +76,8 @@ export const MIGRATIONS: string[] = [
|
|
|
76
76
|
// Migration 4: Add plan_approval column to team_member for plan-before-build workflow
|
|
77
77
|
`ALTER TABLE team_member ADD COLUMN plan_approval TEXT NOT NULL DEFAULT 'none'
|
|
78
78
|
CHECK(plan_approval IN ('none', 'pending', 'approved', 'rejected'));`,
|
|
79
|
+
// Migration 5: Track lead's agent mode so message delivery preserves it
|
|
80
|
+
`ALTER TABLE team ADD COLUMN lead_agent TEXT;`,
|
|
79
81
|
]
|
|
80
82
|
|
|
81
83
|
/**
|
|
@@ -44,8 +44,8 @@ export async function executeTeamBroadcast(
|
|
|
44
44
|
for (const recipient of recipients) {
|
|
45
45
|
try {
|
|
46
46
|
await deps.client.session.promptAsync({
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
sessionID: recipient.sessionId,
|
|
48
|
+
parts: [{ type: "text", text: `[Team broadcast from ${senderName}]: ${args.text}` }],
|
|
49
49
|
})
|
|
50
50
|
delivered++
|
|
51
51
|
} catch {
|
|
@@ -29,7 +29,7 @@ export async function executeTeamCleanup(
|
|
|
29
29
|
if (args.force) {
|
|
30
30
|
for (const member of active) {
|
|
31
31
|
try {
|
|
32
|
-
await deps.client.session.abort({
|
|
32
|
+
await deps.client.session.abort({ sessionID: member.session_id })
|
|
33
33
|
} catch { /* best effort */ }
|
|
34
34
|
}
|
|
35
35
|
}
|
|
@@ -68,8 +68,8 @@ export async function executeTeamMessage(
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
await deps.client.session.promptAsync({
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
sessionID: recipientSessionId,
|
|
72
|
+
parts: [{ type: "text", text: deliveryText }],
|
|
73
73
|
})
|
|
74
74
|
|
|
75
75
|
markDelivered(deps.db, msgId)
|
|
@@ -52,13 +52,11 @@ export async function executeTeamShutdown(
|
|
|
52
52
|
// Busy + not force → graceful: send shutdown message, set shutdown_requested
|
|
53
53
|
try {
|
|
54
54
|
await deps.client.session.promptAsync({
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}],
|
|
61
|
-
},
|
|
55
|
+
sessionID: member.session_id,
|
|
56
|
+
parts: [{
|
|
57
|
+
type: "text",
|
|
58
|
+
text: `[Shutdown requested]: The lead has requested you shut down. Finish your current task, send your final findings to the lead via team_message, then stop.`,
|
|
59
|
+
}],
|
|
62
60
|
})
|
|
63
61
|
} catch {
|
|
64
62
|
// promptAsync failed — best effort
|
|
@@ -83,7 +81,7 @@ async function abortAndShutdown(
|
|
|
83
81
|
sessionId: string,
|
|
84
82
|
): Promise<void> {
|
|
85
83
|
try {
|
|
86
|
-
await deps.client.session.abort({
|
|
84
|
+
await deps.client.session.abort({ sessionID: sessionId })
|
|
87
85
|
} catch {
|
|
88
86
|
// Abort failed — session may already be gone
|
|
89
87
|
}
|
package/src/tools/team-spawn.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ToolDeps } from "../types"
|
|
1
|
+
import type { ToolDeps, PermissionRule } from "../types"
|
|
2
2
|
import { generateId, validateMemberName } from "../util"
|
|
3
3
|
import { findTeamBySession } from "../types"
|
|
4
4
|
import path from "path"
|
|
@@ -36,7 +36,7 @@ export async function executeTeamSpawn(
|
|
|
36
36
|
if (useWorktree) {
|
|
37
37
|
const worktreeName = `ensemble-${teamInfo.teamName}-${args.name}`
|
|
38
38
|
try {
|
|
39
|
-
const result = await deps.client.worktree.create({ name: worktreeName })
|
|
39
|
+
const result = await deps.client.worktree.create({ worktreeCreateInput: { name: worktreeName } })
|
|
40
40
|
if (result.data) {
|
|
41
41
|
worktreeDir = result.data.directory
|
|
42
42
|
worktreeBranch = result.data.branch
|
|
@@ -54,17 +54,28 @@ export async function executeTeamSpawn(
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
57
|
+
// Permission rules on session.create are the hard gate (server-enforced).
|
|
58
|
+
// For read-only agents, deny write tools and explicitly allow team tools
|
|
59
|
+
// so the agent's built-in restrictive permissions don't block them.
|
|
60
|
+
const isReadOnly = args.agent === "plan" || args.agent === "explore"
|
|
61
|
+
const TEAM_TOOLS = ["team_message", "team_broadcast", "team_tasks_list", "team_tasks_add", "team_tasks_complete", "team_claim"] as const
|
|
62
|
+
const permission: PermissionRule[] | undefined = isReadOnly
|
|
63
|
+
? [
|
|
64
|
+
{ permission: "edit", pattern: "*", action: "deny" },
|
|
65
|
+
{ permission: "bash", pattern: "*", action: "deny" },
|
|
66
|
+
...TEAM_TOOLS.map(t => ({ permission: t, pattern: "*", action: "allow" as const })),
|
|
67
|
+
]
|
|
68
|
+
: undefined
|
|
64
69
|
|
|
70
|
+
// Create child session — use worktree directory if available
|
|
65
71
|
let childSessionId: string | undefined
|
|
66
72
|
try {
|
|
67
|
-
const createResult = await deps.client.session.create(
|
|
73
|
+
const createResult = await deps.client.session.create({
|
|
74
|
+
parentID: sessionId,
|
|
75
|
+
title: `${args.name} (@${args.agent} teammate)`,
|
|
76
|
+
...(permission ? { permission } : {}),
|
|
77
|
+
...(worktreeDir ? { directory: worktreeDir } : {}),
|
|
78
|
+
})
|
|
68
79
|
childSessionId = createResult.data?.id
|
|
69
80
|
} catch (err) {
|
|
70
81
|
// Rollback worktree if session creation failed
|
|
@@ -160,14 +171,15 @@ export async function executeTeamSpawn(
|
|
|
160
171
|
// Fire-and-forget: send prompt to teammate session
|
|
161
172
|
try {
|
|
162
173
|
await deps.client.session.promptAsync({
|
|
163
|
-
|
|
164
|
-
|
|
174
|
+
sessionID: childSessionId,
|
|
175
|
+
parts: [{ type: "text", text: contextStr }],
|
|
176
|
+
agent: args.agent,
|
|
165
177
|
})
|
|
166
178
|
} catch (err) {
|
|
167
179
|
// Rollback: clean up DB, registry, session, and worktree
|
|
168
180
|
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND name = ?", [teamInfo.teamId, args.name])
|
|
169
181
|
deps.registry.unregister(childSessionId)
|
|
170
|
-
try { await deps.client.session.abort({
|
|
182
|
+
try { await deps.client.session.abort({ sessionID: childSessionId }) } catch { /* best effort */ }
|
|
171
183
|
if (worktreeDir) {
|
|
172
184
|
try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
|
|
173
185
|
}
|
package/src/types.ts
CHANGED
|
@@ -15,19 +15,36 @@ export interface ToolDeps {
|
|
|
15
15
|
directory: string
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/** A single permission rule for session-level enforcement. */
|
|
19
|
+
export interface PermissionRule {
|
|
20
|
+
permission: string
|
|
21
|
+
pattern: string
|
|
22
|
+
action: "allow" | "deny" | "ask"
|
|
23
|
+
}
|
|
24
|
+
|
|
18
25
|
/**
|
|
19
|
-
* Minimal interface for the OpenCode client methods we actually use.
|
|
20
|
-
*
|
|
26
|
+
* Minimal interface for the OpenCode v2 SDK client methods we actually use.
|
|
27
|
+
* Uses flat params matching the v2 SDK (imported from @opencode-ai/sdk/v2).
|
|
21
28
|
* Makes mocking trivial in tests.
|
|
22
29
|
*/
|
|
23
30
|
export interface PluginClient {
|
|
24
31
|
session: {
|
|
25
|
-
create(options: {
|
|
32
|
+
create(options: {
|
|
33
|
+
parentID?: string
|
|
34
|
+
title?: string
|
|
35
|
+
permission?: PermissionRule[]
|
|
36
|
+
workspaceID?: string
|
|
37
|
+
directory?: string
|
|
38
|
+
}): Promise<{ data?: { id: string } }>
|
|
26
39
|
promptAsync(options: {
|
|
27
|
-
|
|
28
|
-
|
|
40
|
+
sessionID: string
|
|
41
|
+
parts: Array<{ type: "text"; text: string }>
|
|
42
|
+
model?: { providerID: string; modelID: string }
|
|
43
|
+
agent?: string
|
|
44
|
+
tools?: Record<string, boolean>
|
|
45
|
+
system?: string
|
|
29
46
|
}): Promise<unknown>
|
|
30
|
-
abort(options: {
|
|
47
|
+
abort(options: { sessionID: string }): Promise<unknown>
|
|
31
48
|
status(): Promise<{ data?: Record<string, { type: string }> }>
|
|
32
49
|
}
|
|
33
50
|
tui: {
|
|
@@ -40,7 +57,7 @@ export interface PluginClient {
|
|
|
40
57
|
selectSession(options: { sessionID?: string }): Promise<unknown>
|
|
41
58
|
}
|
|
42
59
|
worktree: {
|
|
43
|
-
create(options: { name?: string; startCommand?: string }): Promise<{ data?: { name: string; branch: string; directory: string } }>
|
|
60
|
+
create(options: { worktreeCreateInput?: { name?: string; startCommand?: string } }): Promise<{ data?: { name: string; branch: string; directory: string } }>
|
|
44
61
|
remove(options: { worktreeRemoveInput?: { directory: string } }): Promise<unknown>
|
|
45
62
|
list(): Promise<{ data?: Array<{ name: string; branch: string; directory: string }> }>
|
|
46
63
|
reset(options: { worktreeResetInput?: { directory: string } }): Promise<unknown>
|
package/src/watchdog.ts
CHANGED