@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.5.1",
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.2.27",
42
- "@opencode-ai/sdk": "^1.2.27"
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
- // Build shared tool dependencies
51
- const client = input.client as unknown as PluginClient
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({ path: { id: sessionID } })
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({ path: { id: member.session_id } })
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
- path: { id: recipientSessionId },
115
- body: { parts: [{ type: "text", text: `[Recovered team message from ${msg.from_name}]: ${msg.content}` }] },
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
  /**
@@ -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
- path: { id: recipient.sessionId },
48
- body: { parts: [{ type: "text", text: `[Team broadcast from ${senderName}]: ${args.text}` }] },
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({ path: { id: member.session_id } })
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
- path: { id: recipientSessionId },
72
- body: { parts: [{ type: "text", text: deliveryText }] },
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
- path: { id: member.session_id },
56
- body: {
57
- parts: [{
58
- type: "text",
59
- 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.`,
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({ path: { id: sessionId } })
84
+ await deps.client.session.abort({ sessionID: sessionId })
87
85
  } catch {
88
86
  // Abort failed — session may already be gone
89
87
  }
@@ -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
- // Create child session — use worktree directory if available
58
- const createOpts: Record<string, unknown> = {
59
- body: { parentID: sessionId, title: `${args.name} (@${args.agent} teammate)` },
60
- }
61
- if (worktreeDir) {
62
- createOpts.directory = worktreeDir
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(createOpts as Parameters<typeof deps.client.session.create>[0])
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
- path: { id: childSessionId },
164
- body: { parts: [{ type: "text", text: contextStr }] },
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({ path: { id: childSessionId } }) } catch { /* best effort */ }
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
- * This is a subset of OpencodeClient, typed for our needs.
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: { body?: { parentID?: string; title?: string } }): Promise<{ data?: { id: string } }>
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
- path: { id: string }
28
- body: { parts: Array<{ type: "text"; text: string }>; model?: { providerID: string; modelID: string } }
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: { path: { id: string } }): Promise<unknown>
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
@@ -55,7 +55,7 @@ export class Watchdog {
55
55
 
56
56
  // Abort session (best effort)
57
57
  try {
58
- await this.client.session.abort({ path: { id: member.session_id } })
58
+ await this.client.session.abort({ sessionID: member.session_id })
59
59
  } catch { /* best effort */ }
60
60
 
61
61
  // Notify