@hueyexe/opencode-ensemble 0.8.0 → 0.9.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 CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@hueyexe/opencode-ensemble.svg)](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/@hueyexe/opencode-ensemble.svg)](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
5
- [![tests](https://img.shields.io/badge/tests-423%20passing-brightgreen.svg)]()
5
+ [![tests](https://img.shields.io/badge/tests-432%20passing-brightgreen.svg)]()
6
6
  [![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue.svg)]()
7
- [![zero deps](https://img.shields.io/badge/dependencies-0-brightgreen.svg)]()
7
+ [![OpenCode SDK](https://img.shields.io/badge/deps-OpenCode%20SDK%20only-blue.svg)]()
8
8
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
9
9
 
10
10
  Run parallel AI agents in OpenCode. Each agent gets its own session, context window, and task. They coordinate through messaging and a shared task board.
@@ -95,13 +95,13 @@ Two steps: add the plugin, then allowlist worktree paths.
95
95
 
96
96
  ### 1. Add the plugin
97
97
 
98
- Add to your OpenCode config. Project-level or global.
98
+ Add to your OpenCode config with a pinned version. Project-level or global.
99
99
 
100
100
  **Project-level** (`opencode.json` in your project root):
101
101
 
102
102
  ```json
103
103
  {
104
- "plugin": ["@hueyexe/opencode-ensemble"]
104
+ "plugin": ["@hueyexe/opencode-ensemble@0.9.0"]
105
105
  }
106
106
  ```
107
107
 
@@ -109,11 +109,21 @@ Add to your OpenCode config. Project-level or global.
109
109
 
110
110
  ```json
111
111
  {
112
- "plugin": ["@hueyexe/opencode-ensemble"]
112
+ "plugin": ["@hueyexe/opencode-ensemble@0.9.0"]
113
113
  }
114
114
  ```
115
115
 
116
- OpenCode auto-installs npm plugins at startup (cached in `~/.cache/opencode/node_modules/`). No `npm install` needed.
116
+ OpenCode auto-installs npm plugins at startup. To update, bump the version number in your config and restart OpenCode.
117
+
118
+ **Why pin versions?** OpenCode has a [known bug](https://github.com/anomalyco/opencode/issues/6774) where unpinned plugins (e.g., `"@hueyexe/opencode-ensemble"`) get cached on first install and never auto-update, even after restarting. Pinning to a specific version avoids this — when you change the version string, OpenCode sees a new package spec and installs it fresh.
119
+
120
+ If you're stuck on an old version, clear the cache manually:
121
+
122
+ ```bash
123
+ rm -rf ~/.cache/opencode/packages/@hueyexe
124
+ ```
125
+
126
+ Then restart OpenCode.
117
127
 
118
128
  ### 2. Allow worktree directory access
119
129
 
@@ -298,7 +308,7 @@ Same coordination model (shared tasks, peer messaging, lead coordination) with s
298
308
  ```bash
299
309
  bun install
300
310
  bun run typecheck
301
- bun test # 423 tests
311
+ bun test # 432 tests
302
312
  bun run build
303
313
  ```
304
314
 
package/dist/index.js CHANGED
@@ -15741,6 +15741,33 @@ async function notifyWorkingProgress(client3, db, teamId) {
15741
15741
  } catch {}
15742
15742
  }
15743
15743
 
15744
+ // src/result-parser.ts
15745
+ function parseTaskResult(content) {
15746
+ const match = content.match(/<task-result>([\s\S]*?)<\/task-result>/);
15747
+ if (!match)
15748
+ return null;
15749
+ const inner = match[1];
15750
+ const status = inner.match(/<status>([\s\S]*?)<\/status>/)?.[1]?.trim();
15751
+ const summary = inner.match(/<summary>([\s\S]*?)<\/summary>/)?.[1]?.trim();
15752
+ const details = inner.match(/<details>([\s\S]*?)<\/details>/)?.[1]?.trim();
15753
+ const branch = inner.match(/<branch>([\s\S]*?)<\/branch>/)?.[1]?.trim();
15754
+ if (!status || !summary || !details)
15755
+ return null;
15756
+ return { status, summary, details, branch: branch || undefined };
15757
+ }
15758
+ function formatTaskResult(from, result) {
15759
+ const lines = [
15760
+ `[Result from ${from}]:`,
15761
+ ` Status: ${result.status}`,
15762
+ ` Summary: ${result.summary}`,
15763
+ ` Details: ${result.details}`
15764
+ ];
15765
+ if (result.branch)
15766
+ lines.push(` Branch: ${result.branch}`);
15767
+ return lines.join(`
15768
+ `);
15769
+ }
15770
+
15744
15771
  // src/system-prompt.ts
15745
15772
  function truncate(s, maxLen) {
15746
15773
  return s.length > maxLen ? `${s.slice(0, maxLen)}...` : s;
@@ -15791,7 +15818,11 @@ function buildLeadSystemPrompt(db, teamId) {
15791
15818
  lines.push("", "--- Team Messages ---");
15792
15819
  const MAX_MSG = 500;
15793
15820
  for (const msg of pendingMessages) {
15794
- if (msg.content.length > MAX_MSG) {
15821
+ const parsed = parseTaskResult(msg.content);
15822
+ if (parsed) {
15823
+ const truncatedResult = { ...parsed, details: truncate(parsed.details, 500) };
15824
+ lines.push(formatTaskResult(msg.from_name, truncatedResult));
15825
+ } else if (msg.content.length > MAX_MSG) {
15795
15826
  lines.push(`[From ${msg.from_name}]: ${msg.content.slice(0, MAX_MSG)}... (use team_results to read full message)`);
15796
15827
  } else {
15797
15828
  lines.push(`[From ${msg.from_name}]: ${msg.content}`);
@@ -16184,6 +16215,10 @@ async function executeTeamSpawn(deps, args, sessionId) {
16184
16215
  `You are "${args.name}", a teammate in team "${teamInfo.teamName}".`,
16185
16216
  `Your agent type is "${args.agent}".`
16186
16217
  ];
16218
+ const otherMembers = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name != ? AND status NOT IN ('shutdown', 'error')").all(teamInfo.teamId, args.name);
16219
+ if (otherMembers.length > 0) {
16220
+ context.push(`Other teammates: ${otherMembers.map((m) => m.name).join(", ")}`);
16221
+ }
16187
16222
  if (worktreeBranch && worktreeDir && !workspaceId) {
16188
16223
  context.push(`You are working on branch "${worktreeBranch}" in your own worktree at: ${worktreeDir}`, `Your changes are isolated from other teammates.`, `IMPORTANT: All file operations and shell commands MUST target your worktree directory.`, `Before running shell commands, cd to: ${worktreeDir}`);
16189
16224
  } else if (worktreeBranch && worktreeDir) {
@@ -16216,7 +16251,7 @@ async function executeTeamSpawn(deps, args, sessionId) {
16216
16251
  }
16217
16252
  context.push("</task-result>");
16218
16253
  const lastStep = !isReadOnly && worktreeBranch ? "4" : !isReadOnly ? "3" : "2";
16219
- context.push(`${lastStep}. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.`, "", "If you are blocked, send ONE message to the lead describing the specific blocker.", "", "Your plain text output is NOT visible to the team. You MUST use team_message to communicate.");
16254
+ context.push(`${lastStep}. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.`, "", "If you are blocked:", "- Send ONE message to the lead via team_message describing the specific blocker.", "- Do NOT attempt workarounds or make assumptions. Wait for the lead's response.", "", "Your plain text output is NOT visible to the team. You MUST use team_message to communicate.");
16220
16255
  context.push("", "Your task:", args.prompt);
16221
16256
  if (args.claim_task) {
16222
16257
  context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`);
@@ -16622,9 +16657,14 @@ async function executeTeamResults(deps, args, sessionId) {
16622
16657
  const ids = rows.map((r) => r.id);
16623
16658
  const placeholders = ids.map(() => "?").join(", ");
16624
16659
  deps.db.run(`UPDATE team_message SET read = 1 WHERE id IN (${placeholders})`, ids);
16625
- return rows.map((r) => `[Message from ${r.from_name}]:
16626
- ${r.content}
16627
- `).join(`
16660
+ return rows.map((r) => {
16661
+ const parsed = parseTaskResult(r.content);
16662
+ if (parsed)
16663
+ return formatTaskResult(r.from_name, parsed);
16664
+ return `[Message from ${r.from_name}]:
16665
+ ${r.content}`;
16666
+ }).join(`
16667
+
16628
16668
  `);
16629
16669
  }
16630
16670
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",
@@ -0,0 +1,40 @@
1
+ /** Structured result parsed from `<task-result>` XML. */
2
+ export interface TaskResult {
3
+ status: string
4
+ summary: string
5
+ details: string
6
+ branch?: string
7
+ }
8
+
9
+ /**
10
+ * Parse `<task-result>` XML from message content.
11
+ * Returns null if not found or missing required fields.
12
+ */
13
+ export function parseTaskResult(content: string): TaskResult | null {
14
+ const match = content.match(/<task-result>([\s\S]*?)<\/task-result>/)
15
+ if (!match) return null
16
+
17
+ const inner = match[1]!
18
+ const status = inner.match(/<status>([\s\S]*?)<\/status>/)?.[1]?.trim()
19
+ const summary = inner.match(/<summary>([\s\S]*?)<\/summary>/)?.[1]?.trim()
20
+ const details = inner.match(/<details>([\s\S]*?)<\/details>/)?.[1]?.trim()
21
+ const branch = inner.match(/<branch>([\s\S]*?)<\/branch>/)?.[1]?.trim()
22
+
23
+ if (!status || !summary || !details) return null
24
+
25
+ return { status, summary, details, branch: branch || undefined }
26
+ }
27
+
28
+ /**
29
+ * Format a TaskResult as clean readable text.
30
+ */
31
+ export function formatTaskResult(from: string, result: TaskResult): string {
32
+ const lines = [
33
+ `[Result from ${from}]:`,
34
+ ` Status: ${result.status}`,
35
+ ` Summary: ${result.summary}`,
36
+ ` Details: ${result.details}`,
37
+ ]
38
+ if (result.branch) lines.push(` Branch: ${result.branch}`)
39
+ return lines.join("\n")
40
+ }
@@ -1,5 +1,6 @@
1
1
  import type { Database } from "bun:sqlite"
2
2
  import { markDelivered } from "./messaging"
3
+ import { parseTaskResult, formatTaskResult } from "./result-parser"
3
4
 
4
5
  /** Truncate a string to maxLen chars, appending "..." if truncated. */
5
6
  function truncate(s: string, maxLen: number): string {
@@ -82,7 +83,12 @@ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
82
83
  lines.push("", "--- Team Messages ---")
83
84
  const MAX_MSG = 500
84
85
  for (const msg of pendingMessages) {
85
- if (msg.content.length > MAX_MSG) {
86
+ const parsed = parseTaskResult(msg.content)
87
+ if (parsed) {
88
+ // Truncate details for system prompt — full content available via team_results
89
+ const truncatedResult = { ...parsed, details: truncate(parsed.details, 500) }
90
+ lines.push(formatTaskResult(msg.from_name, truncatedResult))
91
+ } else if (msg.content.length > MAX_MSG) {
86
92
  lines.push(`[From ${msg.from_name}]: ${msg.content.slice(0, MAX_MSG)}... (use team_results to read full message)`)
87
93
  } else {
88
94
  lines.push(`[From ${msg.from_name}]: ${msg.content}`)
@@ -1,5 +1,6 @@
1
1
  import type { ToolDeps } from "../types"
2
2
  import { requireTeamMember } from "./shared"
3
+ import { parseTaskResult, formatTaskResult } from "../result-parser"
3
4
 
4
5
  /** Row shape for unread messages query. */
5
6
  interface UnreadMessageRow {
@@ -39,6 +40,10 @@ export async function executeTeamResults(
39
40
  const placeholders = ids.map(() => "?").join(", ")
40
41
  deps.db.run(`UPDATE team_message SET read = 1 WHERE id IN (${placeholders})`, ids)
41
42
 
42
- // Format output
43
- return rows.map((r) => `[Message from ${r.from_name}]:\n${r.content}\n`).join("\n")
43
+ // Format output — parse structured task results when present
44
+ return rows.map((r) => {
45
+ const parsed = parseTaskResult(r.content)
46
+ if (parsed) return formatTaskResult(r.from_name, parsed)
47
+ return `[Message from ${r.from_name}]:\n${r.content}`
48
+ }).join("\n\n")
44
49
  }
@@ -197,6 +197,14 @@ export async function executeTeamSpawn(
197
197
  `Your agent type is "${args.agent}".`,
198
198
  ]
199
199
 
200
+ // Show other teammates so this agent knows who to message
201
+ const otherMembers = deps.db.query(
202
+ "SELECT name FROM team_member WHERE team_id = ? AND name != ? AND status NOT IN ('shutdown', 'error')"
203
+ ).all(teamInfo.teamId, args.name) as Array<{ name: string }>
204
+ if (otherMembers.length > 0) {
205
+ context.push(`Other teammates: ${otherMembers.map(m => m.name).join(", ")}`)
206
+ }
207
+
200
208
  if (worktreeBranch && worktreeDir && !workspaceId) {
201
209
  // Workspace binding failed — fallback to prompt-based CWD instruction
202
210
  context.push(
@@ -276,7 +284,9 @@ export async function executeTeamSpawn(
276
284
  context.push(
277
285
  `${lastStep}. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.`,
278
286
  "",
279
- "If you are blocked, send ONE message to the lead describing the specific blocker.",
287
+ "If you are blocked:",
288
+ "- Send ONE message to the lead via team_message describing the specific blocker.",
289
+ "- Do NOT attempt workarounds or make assumptions. Wait for the lead's response.",
280
290
  "",
281
291
  "Your plain text output is NOT visible to the team. You MUST use team_message to communicate.",
282
292
  )