@hueyexe/opencode-ensemble 0.2.0 → 0.3.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
@@ -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
@@ -12407,7 +12407,9 @@ var MIGRATIONS = [
12407
12407
  CREATE INDEX IF NOT EXISTS team_message_to_idx ON team_message(to_name);
12408
12408
  CREATE INDEX IF NOT EXISTS team_message_undelivered_idx ON team_message(team_id, delivered)
12409
12409
  WHERE delivered = 0;
12410
- `
12410
+ `,
12411
+ `ALTER TABLE team_message ADD COLUMN read INTEGER NOT NULL DEFAULT 0;
12412
+ CREATE INDEX IF NOT EXISTS team_message_unread_idx ON team_message(team_id, read) WHERE read = 0;`
12411
12413
  ];
12412
12414
  function applyMigrations(db) {
12413
12415
  const { user_version: current } = db.query("PRAGMA user_version").get();
@@ -12741,7 +12743,7 @@ async function executeTeamCreate(deps, args, sessionId) {
12741
12743
  const id = generateId("team");
12742
12744
  const now = Date.now();
12743
12745
  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.`;
12746
+ 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. IMPORTANT: After spawning all teammates, do NOT call any tools. Tell the user what you've set up and STOP. You will be woken automatically.`;
12745
12747
  }
12746
12748
 
12747
12749
  // src/tools/team-spawn.ts
@@ -12809,7 +12811,7 @@ async function executeTeamSpawn(deps, args, sessionId) {
12809
12811
  } catch {}
12810
12812
  throw new Error(`Failed to send initial prompt to teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`);
12811
12813
  }
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.`;
12814
+ return `Teammate "${args.name}" spawned (agent: ${args.agent}). IMPORTANT: Do NOT call any tools after this. Tell the user what you've set up and STOP. You will be woken automatically when teammates message you.`;
12813
12815
  }
12814
12816
 
12815
12817
  // src/tools/team-message.ts
@@ -12827,9 +12829,18 @@ async function executeTeamMessage(deps, args, sessionId) {
12827
12829
  to: args.to,
12828
12830
  content: args.text
12829
12831
  });
12832
+ const isToLead = args.to === "lead";
12833
+ const MAX_LEAD_MSG = 500;
12834
+ let deliveryText;
12835
+ if (isToLead && args.text.length > MAX_LEAD_MSG) {
12836
+ const truncated = args.text.slice(0, MAX_LEAD_MSG);
12837
+ deliveryText = `[Team message from ${senderName}]: ${truncated}... (use team_results to read full message)`;
12838
+ } else {
12839
+ deliveryText = `[Team message from ${senderName}]: ${args.text}`;
12840
+ }
12830
12841
  await deps.client.session.promptAsync({
12831
12842
  path: { id: recipientSessionId },
12832
- body: { parts: [{ type: "text", text: `[Team message from ${senderName}]: ${args.text}` }] }
12843
+ body: { parts: [{ type: "text", text: deliveryText }] }
12833
12844
  });
12834
12845
  markDelivered(deps.db, msgId);
12835
12846
  return `Message sent to ${args.to}.`;
@@ -13027,36 +13038,36 @@ async function executeTeamClaim(deps, args, sessionId) {
13027
13038
  return `Claimed task: ${task.content}`;
13028
13039
  }
13029
13040
 
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}` : ""}`;
13041
+ // src/tools/team-results.ts
13042
+ async function executeTeamResults(deps, args, sessionId) {
13043
+ const team = findTeamBySession(deps.db, deps.registry, sessionId);
13044
+ if (!team)
13045
+ throw new Error("not in a team");
13046
+ 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);
13047
+ if (rows.length === 0)
13048
+ return "No unread messages.";
13049
+ const ids = rows.map((r) => r.id);
13050
+ const placeholders = ids.map(() => "?").join(", ");
13051
+ deps.db.run(`UPDATE team_message SET read = 1 WHERE id IN (${placeholders})`, ids);
13052
+ return rows.map((r) => `[Message from ${r.from_name}]:
13053
+ ${r.content}
13054
+ `).join(`
13055
+ `);
13053
13056
  }
13054
13057
 
13055
13058
  // src/tools/team-status.ts
13059
+ var lastCallTime = new Map;
13060
+ var RATE_LIMIT_MS = 30000;
13056
13061
  async function executeTeamStatus(deps, sessionId) {
13057
13062
  const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
13058
13063
  if (!teamInfo)
13059
13064
  throw new Error("This session is not in a team.");
13065
+ const now = Date.now();
13066
+ const last = lastCallTime.get(teamInfo.teamId);
13067
+ if (last !== undefined && now - last < RATE_LIMIT_MS) {
13068
+ return "Status unchanged. STOP \u2014 do not call any more tools. Tell the user you are waiting for teammates to finish. You will be woken automatically when they message you.";
13069
+ }
13070
+ lastCallTime.set(teamInfo.teamId, now);
13060
13071
  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
13072
  const tasks = deps.db.query("SELECT status FROM team_task WHERE team_id = ?").all(teamInfo.teamId);
13062
13073
  const lines = [];
@@ -13099,7 +13110,9 @@ async function executeTeamStatus(deps, sessionId) {
13099
13110
  });
13100
13111
  } catch {}
13101
13112
  }
13102
- return lines.join(`
13113
+ return `[Relay this status to the user in your next message:]
13114
+
13115
+ ` + lines.join(`
13103
13116
  `);
13104
13117
  }
13105
13118
 
@@ -13111,8 +13124,12 @@ async function executeTeamView(deps, args, sessionId) {
13111
13124
  const member = deps.db.query("SELECT session_id, status, agent FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
13112
13125
  if (!member)
13113
13126
  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.`;
13127
+ try {
13128
+ if (typeof deps.client.tui.selectSession === "function") {
13129
+ await deps.client.tui.selectSession({ sessionID: member.session_id });
13130
+ }
13131
+ } catch {}
13132
+ return `View ${args.member}'s session: ${member.session_id}. Use the session picker (ctrl+p) to navigate to their session.`;
13116
13133
  }
13117
13134
 
13118
13135
  // src/rate-limit.ts
@@ -13421,16 +13438,14 @@ var plugin = async (input) => {
13421
13438
  return result;
13422
13439
  }
13423
13440
  }),
13424
- team_approve_plan: tool({
13425
- description: "Approve or reject a teammate's implementation plan. Only the team lead can use this.",
13441
+ team_results: tool({
13442
+ description: "Retrieve full message content from teammates. Returns unread messages and marks them as read. Use this after receiving a truncated message notification.",
13426
13443
  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)")
13444
+ from: tool.schema.string().optional().describe("Filter messages by sender name (optional, returns all if omitted)")
13430
13445
  },
13431
13446
  async execute(args, ctx) {
13432
- const result = await executeTeamApprovePlan(deps, args, ctx.sessionID);
13433
- ctx.metadata({ title: `Plan ${args.approved ? "approved" : "rejected"}: ${args.member}` });
13447
+ const result = await executeTeamResults(deps, args, ctx.sessionID);
13448
+ ctx.metadata({ title: `Results${args.from ? ` from ${args.from}` : ""}` });
13434
13449
  return result;
13435
13450
  }
13436
13451
  }),
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.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",
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 (ID: ${id}). You are the lead. Use team_spawn to add teammates. Teammates will message you when done — do not poll team_status repeatedly. IMPORTANT: After spawning all teammates, do NOT call any tools. Tell the user what you've set up and STOP. You will be woken automatically.`
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}). IMPORTANT: Do NOT call any tools after this. Tell the user what you've set up and STOP. You will be woken automatically when teammates message you.`
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 "Status unchanged. STOP — do not call any more tools. Tell the user you are waiting for teammates to finish. You will be woken automatically when they message you."
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<{
@@ -68,5 +80,5 @@ export async function executeTeamStatus(
68
80
  }
69
81
  }
70
82
 
71
- return lines.join("\n")
83
+ return "[Relay this status to the user in your next message:]\n\n" + lines.join("\n")
72
84
  }
@@ -17,7 +17,14 @@ 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 })
20
+ // TUI session navigation — try selectSession if available, otherwise guide the user
21
+ try {
22
+ if (typeof deps.client.tui.selectSession === "function") {
23
+ await deps.client.tui.selectSession({ sessionID: member.session_id })
24
+ }
25
+ } catch {
26
+ // selectSession may not be available in all SDK versions — fall through to manual guidance
27
+ }
21
28
 
22
- return `Switched view to ${args.member}'s session (${member.session_id}). Use the session picker to return to the lead session.`
29
+ return `View ${args.member}'s session: ${member.session_id}. Use the session picker (ctrl+p) to navigate to their session.`
23
30
  }
@@ -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
- }