@hueyexe/opencode-ensemble 0.5.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.5.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.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
  }
@@ -346,9 +352,10 @@ const plugin: Plugin = async (input) => {
346
352
  args: {},
347
353
  async execute(_args, ctx) {
348
354
  const result = await executeTeamStatus(deps, ctx.sessionID)
349
- const members = deps.db.query("SELECT status FROM team_member WHERE team_id IN (SELECT id FROM team WHERE lead_session_id = ? OR id IN (SELECT team_id FROM team_member WHERE session_id = ?))").all(ctx.sessionID, ctx.sessionID) as Array<{ status: string }>
350
- const busy = members.filter(m => m.status === "busy").length
351
- ctx.metadata({ title: `Team: ${members.length} members (${busy} working)` })
355
+ const statusMap: Record<string, string> = { busy: "working", ready: "idle", shutdown_requested: "stopping", shutdown: "done", error: "error" }
356
+ const members = deps.db.query("SELECT name, status FROM team_member WHERE team_id IN (SELECT id FROM team WHERE lead_session_id = ? OR id IN (SELECT team_id FROM team_member WHERE session_id = ?))").all(ctx.sessionID, ctx.sessionID) as Array<{ name: string; status: string }>
357
+ const summary = members.map(m => `${m.name}: ${statusMap[m.status] ?? m.status}`).join(", ")
358
+ ctx.metadata({ title: summary || "No teammates" })
352
359
  return result
353
360
  },
354
361
  }),
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
  /**
@@ -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,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
- // 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
- }
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(createOpts as Parameters<typeof deps.client.session.create>[0])
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
- path: { id: childSessionId },
164
- body: { parts: [{ type: "text", text: contextStr }] },
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({ path: { id: childSessionId } }) } catch { /* best effort */ }
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
- * 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