@hueyexe/opencode-ensemble 0.2.0 → 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # OpenCode Ensemble
2
2
 
3
- Agent teams for [OpenCode](https://opencode.ai). Run multiple agents in parallel with messaging, shared tasks, and coordinated execution.
3
+ Run a team of parallel AI agents in OpenCode. Each agent gets its own session, context window, and task. They coordinate through messaging and a shared task board.
4
4
 
5
5
  Built as a plugin on the public OpenCode SDK. Zero internal dependencies.
6
6
 
@@ -125,7 +125,6 @@ export { default } from "/path/to/opencode-ensemble/src/index.ts"
125
125
  | `team_spawn` | Start a new teammate with a task. |
126
126
  | `team_shutdown` | Ask a teammate to stop. |
127
127
  | `team_cleanup` | Remove the team when done. |
128
- | `team_approve_plan` | Approve or reject a teammate's plan before they write code. |
129
128
  | `team_status` | See all members, their status, and a task summary. |
130
129
  | `team_view` | Switch the TUI to a teammate's session. |
131
130
 
@@ -135,6 +134,7 @@ export { default } from "/path/to/opencode-ensemble/src/index.ts"
135
134
  |------|-------------|
136
135
  | `team_message` | Send a direct message to a teammate or the lead. |
137
136
  | `team_broadcast` | Message everyone on the team. |
137
+ | `team_results` | Retrieve full message content (messages to lead are truncated on delivery). |
138
138
 
139
139
  **Task board** (everyone)
140
140
 
package/dist/index.js CHANGED
@@ -1,16 +1,12 @@
1
1
  // @bun
2
2
  var __defProp = Object.defineProperty;
3
- var __returnValue = (v) => v;
4
- function __exportSetter(name, newValue) {
5
- this[name] = __returnValue.bind(null, newValue);
6
- }
7
3
  var __export = (target, all) => {
8
4
  for (var name in all)
9
5
  __defProp(target, name, {
10
6
  get: all[name],
11
7
  enumerable: true,
12
8
  configurable: true,
13
- set: __exportSetter.bind(all, name)
9
+ set: (newValue) => all[name] = () => newValue
14
10
  });
15
11
  };
16
12
 
@@ -12407,7 +12403,9 @@ var MIGRATIONS = [
12407
12403
  CREATE INDEX IF NOT EXISTS team_message_to_idx ON team_message(to_name);
12408
12404
  CREATE INDEX IF NOT EXISTS team_message_undelivered_idx ON team_message(team_id, delivered)
12409
12405
  WHERE delivered = 0;
12410
- `
12406
+ `,
12407
+ `ALTER TABLE team_message ADD COLUMN read INTEGER NOT NULL DEFAULT 0;
12408
+ CREATE INDEX IF NOT EXISTS team_message_unread_idx ON team_message(team_id, read) WHERE read = 0;`
12411
12409
  ];
12412
12410
  function applyMigrations(db) {
12413
12411
  const { user_version: current } = db.query("PRAGMA user_version").get();
@@ -12741,7 +12739,7 @@ async function executeTeamCreate(deps, args, sessionId) {
12741
12739
  const id = generateId("team");
12742
12740
  const now = Date.now();
12743
12741
  deps.db.run("INSERT INTO team (id, name, lead_session_id, status, delegate, time_created, time_updated) VALUES (?, ?, ?, 'active', 0, ?, ?)", [id, args.name, sessionId, now, now]);
12744
- return `Team "${args.name}" created (ID: ${id}). You are the lead. Use team_spawn to add teammates. Teammates will message you when done \u2014 do not poll team_status repeatedly.`;
12742
+ return `Team "${args.name}" created. You are the lead. Use team_spawn to add teammates.`;
12745
12743
  }
12746
12744
 
12747
12745
  // src/tools/team-spawn.ts
@@ -12809,7 +12807,7 @@ async function executeTeamSpawn(deps, args, sessionId) {
12809
12807
  } catch {}
12810
12808
  throw new Error(`Failed to send initial prompt to teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`);
12811
12809
  }
12812
- return `Teammate "${args.name}" spawned (session: ${childSessionId}, agent: ${args.agent}). Working on: ${args.prompt.slice(0, 100)}${args.prompt.length > 100 ? "..." : ""} Teammates will message you when done. Do not poll team_status to check.`;
12810
+ return `Teammate "${args.name}" spawned (agent: ${args.agent}). They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`;
12813
12811
  }
12814
12812
 
12815
12813
  // src/tools/team-message.ts
@@ -12827,9 +12825,18 @@ async function executeTeamMessage(deps, args, sessionId) {
12827
12825
  to: args.to,
12828
12826
  content: args.text
12829
12827
  });
12828
+ const isToLead = args.to === "lead";
12829
+ const MAX_LEAD_MSG = 500;
12830
+ let deliveryText;
12831
+ if (isToLead && args.text.length > MAX_LEAD_MSG) {
12832
+ const truncated = args.text.slice(0, MAX_LEAD_MSG);
12833
+ deliveryText = `[Team message from ${senderName}]: ${truncated}... (use team_results to read full message)`;
12834
+ } else {
12835
+ deliveryText = `[Team message from ${senderName}]: ${args.text}`;
12836
+ }
12830
12837
  await deps.client.session.promptAsync({
12831
12838
  path: { id: recipientSessionId },
12832
- body: { parts: [{ type: "text", text: `[Team message from ${senderName}]: ${args.text}` }] }
12839
+ body: { parts: [{ type: "text", text: deliveryText }] }
12833
12840
  });
12834
12841
  markDelivered(deps.db, msgId);
12835
12842
  return `Message sent to ${args.to}.`;
@@ -13027,36 +13034,36 @@ async function executeTeamClaim(deps, args, sessionId) {
13027
13034
  return `Claimed task: ${task.content}`;
13028
13035
  }
13029
13036
 
13030
- // src/tools/team-approve-plan.ts
13031
- async function executeTeamApprovePlan(deps, args, sessionId) {
13032
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
13033
- if (!teamInfo)
13034
- throw new Error("This session is not in a team.");
13035
- if (teamInfo.role !== "lead")
13036
- throw new Error("Only the team lead can approve plans.");
13037
- const member = deps.db.query("SELECT session_id, status FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
13038
- if (!member)
13039
- throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
13040
- const text = args.approved ? `Your plan has been APPROVED.${args.feedback ? ` Feedback: ${args.feedback}` : " Proceed with implementation."}` : `Your plan has been REJECTED. Please revise and resubmit.${args.feedback ? ` Feedback: ${args.feedback}` : ""}`;
13041
- const msgId = sendMessage(deps.db, {
13042
- teamId: teamInfo.teamId,
13043
- from: "lead",
13044
- to: args.member,
13045
- content: text
13046
- });
13047
- await deps.client.session.promptAsync({
13048
- path: { id: member.session_id },
13049
- body: { parts: [{ type: "text", text: `[Plan ${args.approved ? "approved" : "rejected"} by lead]: ${text}` }] }
13050
- });
13051
- markDelivered(deps.db, msgId);
13052
- return `Plan ${args.approved ? "approved" : "rejected"} for ${args.member}.${args.feedback ? ` Feedback: ${args.feedback}` : ""}`;
13037
+ // src/tools/team-results.ts
13038
+ async function executeTeamResults(deps, args, sessionId) {
13039
+ const team = findTeamBySession(deps.db, deps.registry, sessionId);
13040
+ if (!team)
13041
+ throw new Error("not in a team");
13042
+ const rows = args.from ? deps.db.query("SELECT id, from_name, content, time_created FROM team_message WHERE team_id = ? AND read = 0 AND from_name = ? ORDER BY time_created ASC").all(team.teamId, args.from) : deps.db.query("SELECT id, from_name, content, time_created FROM team_message WHERE team_id = ? AND read = 0 ORDER BY time_created ASC").all(team.teamId);
13043
+ if (rows.length === 0)
13044
+ return "No unread messages.";
13045
+ const ids = rows.map((r) => r.id);
13046
+ const placeholders = ids.map(() => "?").join(", ");
13047
+ deps.db.run(`UPDATE team_message SET read = 1 WHERE id IN (${placeholders})`, ids);
13048
+ return rows.map((r) => `[Message from ${r.from_name}]:
13049
+ ${r.content}
13050
+ `).join(`
13051
+ `);
13053
13052
  }
13054
13053
 
13055
13054
  // src/tools/team-status.ts
13055
+ var lastCallTime = new Map;
13056
+ var RATE_LIMIT_MS = 30000;
13056
13057
  async function executeTeamStatus(deps, sessionId) {
13057
13058
  const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
13058
13059
  if (!teamInfo)
13059
13060
  throw new Error("This session is not in a team.");
13061
+ const now = Date.now();
13062
+ const last = lastCallTime.get(teamInfo.teamId);
13063
+ if (last !== undefined && now - last < RATE_LIMIT_MS) {
13064
+ return "No changes since last check.";
13065
+ }
13066
+ lastCallTime.set(teamInfo.teamId, now);
13060
13067
  const members = deps.db.query("SELECT name, session_id, agent, status, execution_status FROM team_member WHERE team_id = ? ORDER BY time_created ASC").all(teamInfo.teamId);
13061
13068
  const tasks = deps.db.query("SELECT status FROM team_task WHERE team_id = ?").all(teamInfo.teamId);
13062
13069
  const lines = [];
@@ -13111,8 +13118,12 @@ async function executeTeamView(deps, args, sessionId) {
13111
13118
  const member = deps.db.query("SELECT session_id, status, agent FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
13112
13119
  if (!member)
13113
13120
  throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
13114
- await deps.client.tui.selectSession({ sessionID: member.session_id });
13115
- return `Switched view to ${args.member}'s session (${member.session_id}). Use the session picker to return to the lead session.`;
13121
+ try {
13122
+ await deps.client.tui.selectSession({ sessionID: member.session_id });
13123
+ return `Switched to ${args.member}'s session.`;
13124
+ } catch {
13125
+ return `Could not switch to ${args.member}'s session (${member.session_id}). The session may not be accessible from the TUI.`;
13126
+ }
13116
13127
  }
13117
13128
 
13118
13129
  // src/rate-limit.ts
@@ -13421,16 +13432,14 @@ var plugin = async (input) => {
13421
13432
  return result;
13422
13433
  }
13423
13434
  }),
13424
- team_approve_plan: tool({
13425
- description: "Approve or reject a teammate's implementation plan. Only the team lead can use this.",
13435
+ team_results: tool({
13436
+ description: "Retrieve full message content from teammates. Returns unread messages and marks them as read. Use this after receiving a truncated message notification.",
13426
13437
  args: {
13427
- member: tool.schema.string().describe("Teammate name"),
13428
- approved: tool.schema.boolean().describe("true to approve, false to reject"),
13429
- feedback: tool.schema.string().optional().describe("Feedback message (optional)")
13438
+ from: tool.schema.string().optional().describe("Filter messages by sender name (optional, returns all if omitted)")
13430
13439
  },
13431
13440
  async execute(args, ctx) {
13432
- const result = await executeTeamApprovePlan(deps, args, ctx.sessionID);
13433
- ctx.metadata({ title: `Plan ${args.approved ? "approved" : "rejected"}: ${args.member}` });
13441
+ const result = await executeTeamResults(deps, args, ctx.sessionID);
13442
+ ctx.metadata({ title: `Results${args.from ? ` from ${args.from}` : ""}` });
13434
13443
  return result;
13435
13444
  }
13436
13445
  }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
package/src/index.ts CHANGED
@@ -17,7 +17,7 @@ import { executeTeamTasksList } from "./tools/team-tasks-list"
17
17
  import { executeTeamTasksAdd } from "./tools/team-tasks-add"
18
18
  import { executeTeamTasksComplete } from "./tools/team-tasks-complete"
19
19
  import { executeTeamClaim } from "./tools/team-claim"
20
- import { executeTeamApprovePlan } from "./tools/team-approve-plan"
20
+ import { executeTeamResults } from "./tools/team-results"
21
21
  import { executeTeamStatus } from "./tools/team-status"
22
22
  import { executeTeamView } from "./tools/team-view"
23
23
  import type { ToolDeps, PluginClient } from "./types"
@@ -254,16 +254,14 @@ const plugin: Plugin = async (input) => {
254
254
  },
255
255
  }),
256
256
 
257
- team_approve_plan: tool({
258
- description: "Approve or reject a teammate's implementation plan. Only the team lead can use this.",
257
+ team_results: tool({
258
+ description: "Retrieve full message content from teammates. Returns unread messages and marks them as read. Use this after receiving a truncated message notification.",
259
259
  args: {
260
- member: tool.schema.string().describe("Teammate name"),
261
- approved: tool.schema.boolean().describe("true to approve, false to reject"),
262
- feedback: tool.schema.string().optional().describe("Feedback message (optional)"),
260
+ from: tool.schema.string().optional().describe("Filter messages by sender name (optional, returns all if omitted)"),
263
261
  },
264
262
  async execute(args, ctx) {
265
- const result = await executeTeamApprovePlan(deps, args, ctx.sessionID)
266
- ctx.metadata({ title: `Plan ${args.approved ? "approved" : "rejected"}: ${args.member}` })
263
+ const result = await executeTeamResults(deps, args, ctx.sessionID)
264
+ ctx.metadata({ title: `Results${args.from ? ` from ${args.from}` : ""}` })
267
265
  return result
268
266
  },
269
267
  }),
package/src/schema.ts CHANGED
@@ -67,6 +67,9 @@ export const MIGRATIONS: string[] = [
67
67
  CREATE INDEX IF NOT EXISTS team_message_undelivered_idx ON team_message(team_id, delivered)
68
68
  WHERE delivered = 0;
69
69
  `,
70
+ // Migration 2: Add read column to team_message for team_results tracking
71
+ `ALTER TABLE team_message ADD COLUMN read INTEGER NOT NULL DEFAULT 0;
72
+ CREATE INDEX IF NOT EXISTS team_message_unread_idx ON team_message(team_id, read) WHERE read = 0;`,
70
73
  ]
71
74
 
72
75
  /**
@@ -28,5 +28,5 @@ export async function executeTeamCreate(
28
28
  [id, args.name, sessionId, now, now]
29
29
  )
30
30
 
31
- return `Team "${args.name}" created (ID: ${id}). You are the lead. Use team_spawn to add teammates. Teammates will message you when done — do not poll team_status repeatedly.`
31
+ return `Team "${args.name}" created. You are the lead. Use team_spawn to add teammates.`
32
32
  }
@@ -25,10 +25,20 @@ export async function executeTeamMessage(
25
25
  content: args.text,
26
26
  })
27
27
 
28
- // Deliver via promptAsync
28
+ // Deliver via promptAsync — truncate lead-bound messages over 500 chars
29
+ const isToLead = args.to === "lead"
30
+ const MAX_LEAD_MSG = 500
31
+ let deliveryText: string
32
+ if (isToLead && args.text.length > MAX_LEAD_MSG) {
33
+ const truncated = args.text.slice(0, MAX_LEAD_MSG)
34
+ deliveryText = `[Team message from ${senderName}]: ${truncated}... (use team_results to read full message)`
35
+ } else {
36
+ deliveryText = `[Team message from ${senderName}]: ${args.text}`
37
+ }
38
+
29
39
  await deps.client.session.promptAsync({
30
40
  path: { id: recipientSessionId },
31
- body: { parts: [{ type: "text", text: `[Team message from ${senderName}]: ${args.text}` }] },
41
+ body: { parts: [{ type: "text", text: deliveryText }] },
32
42
  })
33
43
 
34
44
  markDelivered(deps.db, msgId)
@@ -0,0 +1,45 @@
1
+ import type { ToolDeps } from "../types"
2
+ import { findTeamBySession } from "../types"
3
+
4
+ /** Row shape for unread messages query. */
5
+ interface UnreadMessageRow {
6
+ id: string
7
+ from_name: string
8
+ content: string
9
+ time_created: number
10
+ }
11
+
12
+ /**
13
+ * Retrieve unread messages from the team message store.
14
+ * Optionally filter by sender name. Marks returned messages as read.
15
+ */
16
+ export async function executeTeamResults(
17
+ deps: ToolDeps,
18
+ args: { from?: string },
19
+ sessionId: string,
20
+ ): Promise<string> {
21
+ const team = findTeamBySession(deps.db, deps.registry, sessionId)
22
+ if (!team) throw new Error("not in a team")
23
+
24
+ const rows = args.from
25
+ ? (deps.db
26
+ .query(
27
+ "SELECT id, from_name, content, time_created FROM team_message WHERE team_id = ? AND read = 0 AND from_name = ? ORDER BY time_created ASC",
28
+ )
29
+ .all(team.teamId, args.from) as UnreadMessageRow[])
30
+ : (deps.db
31
+ .query(
32
+ "SELECT id, from_name, content, time_created FROM team_message WHERE team_id = ? AND read = 0 ORDER BY time_created ASC",
33
+ )
34
+ .all(team.teamId) as UnreadMessageRow[])
35
+
36
+ if (rows.length === 0) return "No unread messages."
37
+
38
+ // Mark all returned messages as read
39
+ const ids = rows.map((r) => r.id)
40
+ const placeholders = ids.map(() => "?").join(", ")
41
+ deps.db.run(`UPDATE team_message SET read = 1 WHERE id IN (${placeholders})`, ids)
42
+
43
+ // Format output
44
+ return rows.map((r) => `[Message from ${r.from_name}]:\n${r.content}\n`).join("\n")
45
+ }
@@ -88,5 +88,5 @@ export async function executeTeamSpawn(
88
88
  throw new Error(`Failed to send initial prompt to teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`)
89
89
  }
90
90
 
91
- return `Teammate "${args.name}" spawned (session: ${childSessionId}, agent: ${args.agent}). Working on: ${args.prompt.slice(0, 100)}${args.prompt.length > 100 ? "..." : ""} Teammates will message you when done. Do not poll team_status to check.`
91
+ return `Teammate "${args.name}" spawned (agent: ${args.agent}). They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
92
92
  }
@@ -1,6 +1,11 @@
1
1
  import type { ToolDeps } from "../types"
2
2
  import { findTeamBySession } from "../types"
3
3
 
4
+ /** Tracks the last time team_status was called per team ID (epoch ms). */
5
+ export const lastCallTime = new Map<string, number>()
6
+
7
+ const RATE_LIMIT_MS = 30_000
8
+
4
9
  /**
5
10
  * Execute the team_status tool. Shows team overview with member statuses and task summary.
6
11
  */
@@ -11,6 +16,13 @@ export async function executeTeamStatus(
11
16
  const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
12
17
  if (!teamInfo) throw new Error("This session is not in a team.")
13
18
 
19
+ const now = Date.now()
20
+ const last = lastCallTime.get(teamInfo.teamId)
21
+ if (last !== undefined && (now - last) < RATE_LIMIT_MS) {
22
+ return "No changes since last check."
23
+ }
24
+ lastCallTime.set(teamInfo.teamId, now)
25
+
14
26
  const members = deps.db.query(
15
27
  "SELECT name, session_id, agent, status, execution_status FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
16
28
  ).all(teamInfo.teamId) as Array<{
@@ -17,7 +17,10 @@ export async function executeTeamView(
17
17
  .get(teamInfo.teamId, args.member) as { session_id: string; status: string; agent: string } | null
18
18
  if (!member) throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`)
19
19
 
20
- await deps.client.tui.selectSession({ sessionID: member.session_id })
21
-
22
- return `Switched view to ${args.member}'s session (${member.session_id}). Use the session picker to return to the lead session.`
20
+ try {
21
+ await deps.client.tui.selectSession({ sessionID: member.session_id })
22
+ return `Switched to ${args.member}'s session.`
23
+ } catch {
24
+ return `Could not switch to ${args.member}'s session (${member.session_id}). The session may not be accessible from the TUI.`
25
+ }
23
26
  }
@@ -1,42 +0,0 @@
1
- import type { ToolDeps } from "../types"
2
- import { findTeamBySession, resolveRecipientSession } from "../types"
3
- import { sendMessage, markDelivered } from "../messaging"
4
-
5
- /**
6
- * Execute the team_approve_plan tool. Approves or rejects a teammate's plan
7
- * and notifies them via message.
8
- */
9
- export async function executeTeamApprovePlan(
10
- deps: ToolDeps,
11
- args: { member: string; approved: boolean; feedback?: string },
12
- sessionId: string,
13
- ): Promise<string> {
14
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
15
- if (!teamInfo) throw new Error("This session is not in a team.")
16
- if (teamInfo.role !== "lead") throw new Error("Only the team lead can approve plans.")
17
-
18
- const member = deps.db.query("SELECT session_id, status FROM team_member WHERE team_id = ? AND name = ?")
19
- .get(teamInfo.teamId, args.member) as { session_id: string; status: string } | null
20
- if (!member) throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`)
21
-
22
- const text = args.approved
23
- ? `Your plan has been APPROVED.${args.feedback ? ` Feedback: ${args.feedback}` : " Proceed with implementation."}`
24
- : `Your plan has been REJECTED. Please revise and resubmit.${args.feedback ? ` Feedback: ${args.feedback}` : ""}`
25
-
26
- // Send approval/rejection message to teammate
27
- const msgId = sendMessage(deps.db, {
28
- teamId: teamInfo.teamId,
29
- from: "lead",
30
- to: args.member,
31
- content: text,
32
- })
33
-
34
- await deps.client.session.promptAsync({
35
- path: { id: member.session_id },
36
- body: { parts: [{ type: "text", text: `[Plan ${args.approved ? "approved" : "rejected"} by lead]: ${text}` }] },
37
- })
38
-
39
- markDelivered(deps.db, msgId)
40
-
41
- return `Plan ${args.approved ? "approved" : "rejected"} for ${args.member}.${args.feedback ? ` Feedback: ${args.feedback}` : ""}`
42
- }