@hueyexe/opencode-ensemble 0.17.0 → 0.18.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,9 +1,14 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.17.0",
3
+ "version": "0.18.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",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./rpc": "./src/rpc.ts",
10
+ "./tui": "./src/tui.tsx"
11
+ },
7
12
  "type": "module",
8
13
  "license": "MIT",
9
14
  "repository": {
@@ -30,6 +35,7 @@
30
35
  "lint": "bunx biome lint src/",
31
36
  "typecheck": "tsc --noEmit",
32
37
  "test": "bun test",
38
+ "eval:smoke": "bun eval/smoke.ts",
33
39
  "build": "bun build src/index.ts --outdir dist --target node",
34
40
  "prepublishOnly": "bun run typecheck && bun test && bun run build"
35
41
  },
@@ -39,13 +45,20 @@
39
45
  },
40
46
  "devDependencies": {
41
47
  "@biomejs/biome": "^2.4.9",
42
- "@types/bun": "latest"
48
+ "@opentui/core": "0.5.10",
49
+ "@opentui/solid": "0.5.10",
50
+ "@types/bun": "latest",
51
+ "solid-js": "1.9.0"
43
52
  },
44
53
  "peerDependencies": {
45
- "typescript": "^5"
54
+ "typescript": "^5",
55
+ "@opentui/core": ">=0.5.10",
56
+ "@opentui/solid": ">=0.5.10",
57
+ "solid-js": ">=1.9.0"
46
58
  },
47
59
  "dependencies": {
48
60
  "@opencode-ai/plugin": "^1.17.18",
49
- "@opencode-ai/sdk": "^1.17.18"
61
+ "@opencode-ai/sdk": "^1.17.18",
62
+ "@opencode/plugin": "2.0.3"
50
63
  }
51
- }
64
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin"
2
+ import { Plugin as PluginV2 } from "@opencode/plugin"
3
+ import { setupEnsemble, type V2SetupContext } from "./v2-setup"
2
4
  import { tool } from "@opencode-ai/plugin"
3
5
  import { OpencodeClient } from "@opencode-ai/sdk/v2"
4
6
  import path from "node:path"
@@ -656,10 +658,11 @@ const plugin: Plugin = async (input) => {
656
658
  }),
657
659
 
658
660
  team_view: tool({
659
- description: "Navigate the TUI to a teammate's session so you can see what they are doing. " +
660
- "Use the session picker (ctrl+p) to return to the lead session.",
661
+ description: "Resolve a teammate's session so the user can inspect it. Default reports the session ID + status without switching. " +
662
+ "Pass navigate: true ONLY when the user explicitly asked to view the teammate's session — it switches their client to it. Return via the session picker (ctrl+p).",
661
663
  args: {
662
664
  member: tool.schema.string().describe("Teammate name to view"),
665
+ navigate: tool.schema.boolean().default(false).describe("Switch the user's client to this session. Only when the user explicitly asked."),
663
666
  },
664
667
  async execute(args, ctx) {
665
668
  const result = await executeTeamView(deps, args, ctx.sessionID)
@@ -671,4 +674,24 @@ const plugin: Plugin = async (input) => {
671
674
  }
672
675
  }
673
676
 
674
- export default plugin
677
+ /**
678
+ * Dual V1/V2 export (issue #36).
679
+ * - V2 reads `id` + `setup()` and ignores `server()`.
680
+ * - V1 (>=1.18.29) calls `server()` and ignores the V2 fields.
681
+ * The V2 setup body lands in later phases; the V1 implementation below is
682
+ * unchanged so current users keep working during the port.
683
+ */
684
+ export default {
685
+ ...PluginV2.define({
686
+ id: "ensemble",
687
+ async setup(ctx) {
688
+ // Dual support (issue #36): V2 initializes here; the V1 server()
689
+ // below keeps serving V1 hosts. Tool registration lands next slice.
690
+ const handle = await setupEnsemble(ctx as unknown as V2SetupContext)
691
+ return () => {
692
+ void handle.dispose()
693
+ }
694
+ },
695
+ }),
696
+ server: plugin,
697
+ }
package/src/log.ts CHANGED
@@ -11,3 +11,12 @@ export function log(msg: string): void {
11
11
  if (!_client) return
12
12
  _client.app.log({ service: "ensemble", level: "info", message: msg }).catch(() => {})
13
13
  }
14
+
15
+ /**
16
+ * V2 fallback logger. The V2 plugin context exposes no app.log, so server
17
+ * log lines go to stdout with a prefix (the host captures plugin stdout
18
+ * into its own log). Used only by V2 setup paths.
19
+ */
20
+ export function vlog(msg: string): void {
21
+ console.log(`[ensemble] ${msg}`)
22
+ }
package/src/notify.ts CHANGED
@@ -116,10 +116,12 @@ export function notifyLead(
116
116
  if (!team?.lead_session_id) return id
117
117
 
118
118
  // Fire-and-forget wake — the system prompt transform delivers the actual
119
- // content on the lead's next turn.
119
+ // content on the lead's next turn. Synthetic: renders as a persistent
120
+ // system entry, not a fake user turn.
120
121
  client.session.promptAsync({
121
122
  sessionID: team.lead_session_id,
122
123
  parts: [{ type: "text", text: "[System: New team message from system]" }],
124
+ synthetic: true,
123
125
  }).catch((err) => {
124
126
  log(`notifyLead:wake:failed team=${teamId} err=${err instanceof Error ? err.message : String(err)}`)
125
127
  })
package/src/rpc.ts ADDED
@@ -0,0 +1,99 @@
1
+ import { Rpc } from "@opencode/plugin/rpc"
2
+
3
+ /**
4
+ * RPC contract between the server plugin and terminal companions.
5
+ * Published as `./rpc` so companions import the shape without the
6
+ * implementation. Companion behavior (toasts, navigation) is enhancement
7
+ * only — every client works fully without it.
8
+ */
9
+ export const EnsembleRpc = Rpc.define({
10
+ id: "ensemble",
11
+ methods: {
12
+ summary: {
13
+ input: {
14
+ type: "object",
15
+ properties: { team: { type: "string" } },
16
+ required: ["team"],
17
+ additionalProperties: false,
18
+ },
19
+ output: {
20
+ type: "object",
21
+ properties: { text: { type: "string" } },
22
+ required: ["text"],
23
+ additionalProperties: false,
24
+ },
25
+ },
26
+ teamContext: {
27
+ input: {
28
+ type: "object",
29
+ properties: { sessionID: { type: "string" } },
30
+ required: ["sessionID"],
31
+ additionalProperties: false,
32
+ },
33
+ output: {
34
+ type: "object",
35
+ properties: {
36
+ team: { type: ["string", "null"] },
37
+ members: {
38
+ type: "array",
39
+ items: {
40
+ type: "object",
41
+ properties: { name: { type: "string" }, status: { type: "string" } },
42
+ required: ["name", "status"],
43
+ additionalProperties: false,
44
+ },
45
+ },
46
+ tasks: {
47
+ type: "object",
48
+ properties: {
49
+ pending: { type: "number" },
50
+ done: { type: "number" },
51
+ },
52
+ required: ["pending", "done"],
53
+ additionalProperties: false,
54
+ },
55
+ },
56
+ required: ["team", "members", "tasks"],
57
+ additionalProperties: false,
58
+ },
59
+ },
60
+ },
61
+ events: {
62
+ member: {
63
+ schema: {
64
+ type: "object",
65
+ properties: {
66
+ memberName: { type: "string" },
67
+ teamId: { type: "string" },
68
+ from: { type: "string" },
69
+ to: { type: "string" },
70
+ },
71
+ required: ["memberName", "teamId", "from", "to"],
72
+ additionalProperties: false,
73
+ },
74
+ },
75
+ notice: {
76
+ schema: {
77
+ type: "object",
78
+ properties: {
79
+ title: { type: "string" },
80
+ message: { type: "string" },
81
+ variant: { type: "string" },
82
+ },
83
+ required: ["message"],
84
+ additionalProperties: false,
85
+ },
86
+ },
87
+ view: {
88
+ schema: {
89
+ type: "object",
90
+ properties: {
91
+ sessionID: { type: "string" },
92
+ memberName: { type: "string" },
93
+ },
94
+ required: ["sessionID"],
95
+ additionalProperties: false,
96
+ },
97
+ },
98
+ },
99
+ })
@@ -125,6 +125,7 @@ export async function executeTeamMessage(
125
125
  deps.client.session.promptAsync({
126
126
  sessionID: recipientSessionId,
127
127
  parts: [{ type: "text", text: `[System: New team message from ${senderName}]` }],
128
+ synthetic: true,
128
129
  }).catch((err) => {
129
130
  log(`team_message:wake-lead:failed from=${senderName} err=${err instanceof Error ? err.message : String(err)}`)
130
131
  })
@@ -147,7 +147,21 @@ export async function executeTeamSpawn(
147
147
  // Permission rules on session.create are the hard gate (server-enforced).
148
148
  // For read-only agents, deny write tools and explicitly allow team tools.
149
149
  // For all agents with worktrees, allowlist the worktree path for edit/bash.
150
- const TEAM_TOOLS = ["team_message", "team_broadcast", "team_tasks_list", "team_tasks_add", "team_tasks_complete", "team_claim"] as const
150
+ // The 9 member-accessible tools per the documented tool table: the 6 worker
151
+ // tools plus the 3 inspection tools (team_results/team_status/team_view —
152
+ // any member). Caught live 2026-09-15: teammates could not read each
153
+ // other's messages because team_results was missing from this list.
154
+ const TEAM_TOOLS = [
155
+ "team_message",
156
+ "team_broadcast",
157
+ "team_tasks_list",
158
+ "team_tasks_add",
159
+ "team_tasks_complete",
160
+ "team_claim",
161
+ "team_results",
162
+ "team_status",
163
+ "team_view",
164
+ ] as const
151
165
  const permission: PermissionRule[] = []
152
166
 
153
167
  if (worktreeDir) {
@@ -170,6 +184,12 @@ export async function executeTeamSpawn(
170
184
  ...TEAM_TOOLS.map(t => ({ permission: t, pattern: "*", action: "allow" as const })),
171
185
  )
172
186
 
187
+ // Code Mode is the invocation path for plugin tools on V2 — without this,
188
+ // read-only agents (deny-all policies like explore's) cannot reach any
189
+ // team tool. Safe: nested calls inside execute still enforce their own
190
+ // permissions, so explore stays read-only.
191
+ permission.push({ permission: "execute", pattern: "*", action: "allow" })
192
+
173
193
  // Create child session — bind to workspace if available (server-enforced CWD isolation).
174
194
  // Falls back to no workspace binding if workspace.create failed.
175
195
  let childSessionId: string | undefined
@@ -2,12 +2,14 @@ import type { ToolDeps } from "../types"
2
2
  import { requireTeamMember } from "./shared"
3
3
 
4
4
  /**
5
- * Execute the team_view tool. Navigates the TUI to a teammate's session
6
- * so the user can see what they're doing.
5
+ * Execute the team_view tool. Resolves a teammate's session; navigates the
6
+ * TUI to it ONLY when navigate: true is passed — an agent must never hijack
7
+ * the user's client unprompted. Default behavior reports where the session
8
+ * is so the user can open it themselves (ctrl+p).
7
9
  */
8
10
  export async function executeTeamView(
9
11
  deps: ToolDeps,
10
- args: { member: string },
12
+ args: { member: string; navigate?: boolean },
11
13
  sessionId: string,
12
14
  ): Promise<string> {
13
15
  const teamInfo = requireTeamMember(deps, sessionId)
@@ -16,9 +18,13 @@ export async function executeTeamView(
16
18
  .get(teamInfo.teamId, args.member) as { session_id: string; status: string; agent: string } | null
17
19
  if (!member) throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`)
18
20
 
21
+ if (args.navigate !== true) {
22
+ return `${args.member}'s session: ${member.session_id} (status: ${member.status}, agent: ${member.agent}). Open it from the session picker (ctrl+p) — or ask to view it and I'll switch for you.`
23
+ }
24
+
19
25
  try {
20
26
  await deps.client.tui.selectSession({ sessionID: member.session_id })
21
- return `Switched to ${args.member}'s session.`
27
+ return `Switched to ${args.member}'s session. Use the session picker (ctrl+p) to return.`
22
28
  } catch {
23
29
  return `Could not switch to ${args.member}'s session (${member.session_id}). The session may not be accessible from the TUI.`
24
30
  }
@@ -0,0 +1,50 @@
1
+ /** Minimal member shape for sidebar rendering. */
2
+ export interface SidebarMember {
3
+ name: string
4
+ status: string
5
+ }
6
+
7
+ /** Rendered sidebar model: counts plus one line per member. */
8
+ export interface SidebarSummary {
9
+ team: string
10
+ working: number
11
+ idle: number
12
+ total: number
13
+ lines: string[]
14
+ }
15
+
16
+ /** Glyph per member status (paired with text elsewhere — never color-only). */
17
+ function glyph(status: string): string {
18
+ switch (status) {
19
+ case "busy":
20
+ return "●"
21
+ case "ready":
22
+ return "○"
23
+ case "shutdown_requested":
24
+ return "◐"
25
+ case "shutdown":
26
+ return "■"
27
+ case "error":
28
+ return "✕"
29
+ default:
30
+ return "?"
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Build the sidebar model for a team. Busy members count as working, ready
36
+ * as idle, terminal states (shutdown/error) as done.
37
+ */
38
+ export function summarizeMembers(team: string, members: SidebarMember[]): SidebarSummary {
39
+ if (members.length === 0) {
40
+ return { team, working: 0, idle: 0, total: 0, lines: ["No teammates"] }
41
+ }
42
+ let working = 0
43
+ let idle = 0
44
+ const lines = members.map((member) => {
45
+ if (member.status === "busy") working += 1
46
+ else if (member.status === "ready") idle += 1
47
+ return `${glyph(member.status)} ${member.name} (${member.status})`
48
+ })
49
+ return { team, working, idle, total: members.length, lines }
50
+ }
package/src/tui.tsx ADDED
@@ -0,0 +1,101 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import "@opentui/solid"
3
+ import { createSignal, onCleanup, onMount, Show } from "solid-js"
4
+ import { Plugin } from "@opencode/plugin/tui"
5
+ import { EnsembleRpc } from "./rpc"
6
+
7
+ /** Team context shape from the server bridge. */
8
+ interface TeamContextData {
9
+ team: string | null
10
+ members: Array<{ name: string; status: string }>
11
+ tasks: { pending: number; done: number }
12
+ }
13
+
14
+ /** Sidebar widget: team members with status plus task counts. */
15
+ function TeamSidebar(props: { fetchContext: () => Promise<TeamContextData | null>; onEvent: (cb: () => void) => () => void }) {
16
+ const [state, setState] = createSignal<TeamContextData | null>(null)
17
+ const load = async (): Promise<void> => {
18
+ try {
19
+ setState(await props.fetchContext())
20
+ } catch {
21
+ // Server unreachable — keep the last known state.
22
+ }
23
+ }
24
+ onMount(() => {
25
+ void load()
26
+ const off = props.onEvent(() => void load())
27
+ onCleanup(off)
28
+ // Heartbeat: events alone go stale (cleanup emits nothing, events can be
29
+ // missed across reconnects). Re-fetch on a slow tick — local RPC read.
30
+ const tick = setInterval(() => void load(), 10_000)
31
+ onCleanup(() => clearInterval(tick))
32
+ })
33
+ return (
34
+ <Show when={state()?.team} fallback={<text>Ensemble: no active team</text>}>
35
+ <box flexDirection="column">
36
+ <text>Ensemble: {state()?.team}</text>
37
+ {(state()?.members ?? []).map((member) => (
38
+ <text>
39
+ {member.status === "busy" ? "●" : member.status === "ready" ? "○" : "■"} {member.name} ({member.status})
40
+ </text>
41
+ ))}
42
+ <text>
43
+ tasks: {(state()?.tasks.pending ?? 0)} open / {(state()?.tasks.done ?? 0)} done
44
+ </text>
45
+ </box>
46
+ </Show>
47
+ )
48
+ }
49
+
50
+ /**
51
+ * Terminal companion for opencode-ensemble (issue #36).
52
+ *
53
+ * Two persistent surfaces only — transient toasts were removed as noise
54
+ * (live state lives in the sidebar; events live in the transcript as
55
+ * synthetic system lines):
56
+ * 1. Sidebar widget: live member status + task counts.
57
+ * 2. Native agent switching: team_view with navigate: true (user opt-in
58
+ * only — agents never hijack the client unprompted).
59
+ *
60
+ * Enhancement only: desktop, web, and companion-less terminals lose nothing
61
+ * functional — tools, approvals, model-context messages, and the dashboard
62
+ * all live server-side.
63
+ */
64
+ export default Plugin.define({
65
+ id: "ensemble.tui",
66
+ setup(context) {
67
+ const rpc = context.client.rpc(EnsembleRpc)
68
+
69
+ // Native agent switching: team_view(navigate: true) emits "view";
70
+ // the companion navigates the TUI to the teammate session (ctrl+p to return).
71
+ const offView = rpc.events.on("view", (event) => {
72
+ const data = event.data as unknown as { sessionID: string }
73
+ if (!data.sessionID) return
74
+ if (context.ui.tabs.enabled()) {
75
+ void context.ui.tabs.focus(data.sessionID)
76
+ } else {
77
+ void context.ui.router.navigate({ type: "session", sessionID: data.sessionID })
78
+ }
79
+ })
80
+
81
+ const onEvent = (cb: () => void): (() => void) => rpc.events.on("member", cb)
82
+
83
+ const offSidebar = context.ui.slot({
84
+ append: "sidebar.content",
85
+ render: (panel: { sessionID?: string }) => (
86
+ <TeamSidebar
87
+ fetchContext={async () => {
88
+ if (!panel.sessionID) return null
89
+ return (await rpc.teamContext({ sessionID: panel.sessionID })) as TeamContextData
90
+ }}
91
+ onEvent={onEvent}
92
+ />
93
+ ),
94
+ })
95
+
96
+ return () => {
97
+ offView()
98
+ offSidebar()
99
+ }
100
+ },
101
+ })
package/src/types.ts CHANGED
@@ -50,6 +50,8 @@ export interface PluginClient {
50
50
  agent?: string
51
51
  tools?: Record<string, boolean>
52
52
  system?: string
53
+ /** V2: deliver as a synthetic (system) message instead of a user prompt. */
54
+ synthetic?: boolean
53
55
  }): Promise<unknown>
54
56
  abort(options: { sessionID: string }): Promise<unknown>
55
57
  status(): Promise<{ data?: Record<string, { type: string }> }>