@hueyexe/opencode-ensemble 0.5.1 → 0.6.1
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 +3053 -66
- 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/system-prompt.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 -25
- 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.1",
|
|
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
|
/**
|
package/src/system-prompt.ts
CHANGED
|
@@ -47,6 +47,8 @@ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
|
|
|
47
47
|
"",
|
|
48
48
|
"Teammates work asynchronously and message you when done.",
|
|
49
49
|
"Do NOT poll team_status or team_tasks_list repeatedly — wait for messages.",
|
|
50
|
+
"Spawn teammates ONE at a time — do not send multiple team_spawn calls in a single response.",
|
|
51
|
+
"After each team_spawn, verify the output confirms the spawn succeeded before spawning the next.",
|
|
50
52
|
"After spawning teammates, tell the user what you've set up and wait.",
|
|
51
53
|
"When all teammates finish, summarize results and suggest next steps.",
|
|
52
54
|
].join("\n")
|
|
@@ -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,7 +1,6 @@
|
|
|
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
|
-
import path from "path"
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* Execute the team_spawn tool. Creates a child session and starts a teammate.
|
|
@@ -36,7 +35,7 @@ export async function executeTeamSpawn(
|
|
|
36
35
|
if (useWorktree) {
|
|
37
36
|
const worktreeName = `ensemble-${teamInfo.teamName}-${args.name}`
|
|
38
37
|
try {
|
|
39
|
-
const result = await deps.client.worktree.create({ name: worktreeName })
|
|
38
|
+
const result = await deps.client.worktree.create({ worktreeCreateInput: { name: worktreeName } })
|
|
40
39
|
if (result.data) {
|
|
41
40
|
worktreeDir = result.data.directory
|
|
42
41
|
worktreeBranch = result.data.branch
|
|
@@ -54,17 +53,28 @@ export async function executeTeamSpawn(
|
|
|
54
53
|
}
|
|
55
54
|
}
|
|
56
55
|
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
56
|
+
// Permission rules on session.create are the hard gate (server-enforced).
|
|
57
|
+
// For read-only agents, deny write tools and explicitly allow team tools
|
|
58
|
+
// so the agent's built-in restrictive permissions don't block them.
|
|
59
|
+
const isReadOnly = args.agent === "plan" || args.agent === "explore"
|
|
60
|
+
const TEAM_TOOLS = ["team_message", "team_broadcast", "team_tasks_list", "team_tasks_add", "team_tasks_complete", "team_claim"] as const
|
|
61
|
+
const permission: PermissionRule[] | undefined = isReadOnly
|
|
62
|
+
? [
|
|
63
|
+
{ permission: "edit", pattern: "*", action: "deny" },
|
|
64
|
+
{ permission: "bash", pattern: "*", action: "deny" },
|
|
65
|
+
...TEAM_TOOLS.map(t => ({ permission: t, pattern: "*", action: "allow" as const })),
|
|
66
|
+
]
|
|
67
|
+
: undefined
|
|
64
68
|
|
|
69
|
+
// Create child session — use worktree directory if available
|
|
65
70
|
let childSessionId: string | undefined
|
|
66
71
|
try {
|
|
67
|
-
const createResult = await deps.client.session.create(
|
|
72
|
+
const createResult = await deps.client.session.create({
|
|
73
|
+
parentID: sessionId,
|
|
74
|
+
title: `${args.name} (@${args.agent} teammate)`,
|
|
75
|
+
...(permission ? { permission } : {}),
|
|
76
|
+
...(worktreeDir ? { directory: worktreeDir } : {}),
|
|
77
|
+
})
|
|
68
78
|
childSessionId = createResult.data?.id
|
|
69
79
|
} catch (err) {
|
|
70
80
|
// Rollback worktree if session creation failed
|
|
@@ -134,17 +144,6 @@ export async function executeTeamSpawn(
|
|
|
134
144
|
"Your plain text output is NOT visible to the team. You MUST use team_message to communicate.",
|
|
135
145
|
)
|
|
136
146
|
|
|
137
|
-
// Load project AGENTS.md if it exists
|
|
138
|
-
try {
|
|
139
|
-
const agentsPath = path.join(deps.directory, "AGENTS.md")
|
|
140
|
-
const file = Bun.file(agentsPath)
|
|
141
|
-
if (await file.exists()) {
|
|
142
|
-
const content = await file.text()
|
|
143
|
-
const truncated = content.length > 2000 ? content.slice(0, 2000) + "\n...(truncated)" : content
|
|
144
|
-
context.push("", "Project guidelines (from AGENTS.md):", truncated)
|
|
145
|
-
}
|
|
146
|
-
} catch { /* file may not exist or be unreadable */ }
|
|
147
|
-
|
|
148
147
|
context.push(
|
|
149
148
|
"",
|
|
150
149
|
"Your task:",
|
|
@@ -160,14 +159,15 @@ export async function executeTeamSpawn(
|
|
|
160
159
|
// Fire-and-forget: send prompt to teammate session
|
|
161
160
|
try {
|
|
162
161
|
await deps.client.session.promptAsync({
|
|
163
|
-
|
|
164
|
-
|
|
162
|
+
sessionID: childSessionId,
|
|
163
|
+
parts: [{ type: "text", text: contextStr }],
|
|
164
|
+
agent: args.agent,
|
|
165
165
|
})
|
|
166
166
|
} catch (err) {
|
|
167
167
|
// Rollback: clean up DB, registry, session, and worktree
|
|
168
168
|
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND name = ?", [teamInfo.teamId, args.name])
|
|
169
169
|
deps.registry.unregister(childSessionId)
|
|
170
|
-
try { await deps.client.session.abort({
|
|
170
|
+
try { await deps.client.session.abort({ sessionID: childSessionId }) } catch { /* best effort */ }
|
|
171
171
|
if (worktreeDir) {
|
|
172
172
|
try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
|
|
173
173
|
}
|
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