@hueyexe/opencode-ensemble 0.6.1 → 0.6.2

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/dist/index.js CHANGED
@@ -15478,6 +15478,30 @@ function getUndeliveredMessages(db, teamId) {
15478
15478
  function markDelivered(db, messageId) {
15479
15479
  db.run("UPDATE team_message SET delivered = 1 WHERE id = ?", [messageId]);
15480
15480
  }
15481
+ async function flushPendingMessages(db, client3, teamId, leadSessionId) {
15482
+ const pending = db.query("SELECT * FROM team_message WHERE team_id = ? AND delivered = 0 AND to_name = 'lead' ORDER BY time_created ASC").all(teamId);
15483
+ if (pending.length === 0)
15484
+ return 0;
15485
+ const MAX_LEAD_MSG = 500;
15486
+ const parts = pending.map((msg) => {
15487
+ if (msg.content.length > MAX_LEAD_MSG) {
15488
+ return `[Team message from ${msg.from_name}]: ${msg.content.slice(0, MAX_LEAD_MSG)}... (use team_results to read full message)`;
15489
+ }
15490
+ return `[Team message from ${msg.from_name}]: ${msg.content}`;
15491
+ });
15492
+ try {
15493
+ await client3.session.promptAsync({
15494
+ sessionID: leadSessionId,
15495
+ parts: [{ type: "text", text: parts.join(`
15496
+ `) }]
15497
+ });
15498
+ for (const msg of pending)
15499
+ markDelivered(db, msg.id);
15500
+ return pending.length;
15501
+ } catch {
15502
+ return 0;
15503
+ }
15504
+ }
15481
15505
 
15482
15506
  // src/recovery.ts
15483
15507
  async function recoverStaleMembers(db, client3) {
@@ -15664,6 +15688,12 @@ function checkToolIsolation(registry2, tracker, toolName, sessionId) {
15664
15688
  throw new Error("Team tools are not available to sub-agents. Report findings to your parent teammate via your normal output.");
15665
15689
  }
15666
15690
  }
15691
+ async function handleLeadIdleFlush(db, client3, sessionId) {
15692
+ const team = db.query("SELECT id FROM team WHERE lead_session_id = ? AND status = 'active'").get(sessionId);
15693
+ if (!team)
15694
+ return 0;
15695
+ return flushPendingMessages(db, client3, team.id, sessionId);
15696
+ }
15667
15697
 
15668
15698
  // src/notify.ts
15669
15699
  var TOAST_CONFIG = {
@@ -15753,8 +15783,6 @@ function buildLeadSystemPrompt(db, teamId) {
15753
15783
  "",
15754
15784
  "Teammates work asynchronously and message you when done.",
15755
15785
  "Do NOT poll team_status or team_tasks_list repeatedly \u2014 wait for messages.",
15756
- "Spawn teammates ONE at a time \u2014 do not send multiple team_spawn calls in a single response.",
15757
- "After each team_spawn, verify the output confirms the spawn succeeded before spawning the next.",
15758
15786
  "After spawning teammates, tell the user what you've set up and wait.",
15759
15787
  "When all teammates finish, summarize results and suggest next steps."
15760
15788
  ].join(`
@@ -15987,6 +16015,17 @@ async function executeTeamMessage(deps, args, sessionId) {
15987
16015
  } else {
15988
16016
  deliveryText = `[Team message from ${senderName}]: ${messageText}`;
15989
16017
  }
16018
+ let recipientBusy = false;
16019
+ try {
16020
+ const statusResult = await deps.client.session.status();
16021
+ const sessionStatus = statusResult.data?.[recipientSessionId];
16022
+ if (sessionStatus?.type === "busy") {
16023
+ recipientBusy = true;
16024
+ }
16025
+ } catch {}
16026
+ if (recipientBusy) {
16027
+ return `Message sent to ${args.to}. (queued \u2014 recipient is busy)`;
16028
+ }
15990
16029
  await deps.client.session.promptAsync({
15991
16030
  sessionID: recipientSessionId,
15992
16031
  parts: [{ type: "text", text: deliveryText }]
@@ -16506,6 +16545,9 @@ var plugin = async (input) => {
16506
16545
  }
16507
16546
  await notifyWorkingProgress(client3, db, transition.teamId);
16508
16547
  }
16548
+ if (statusType === "idle") {
16549
+ handleLeadIdleFlush(db, client3, sessionID).catch(() => {});
16550
+ }
16509
16551
  }
16510
16552
  if (event.type === "session.created") {
16511
16553
  const info = event.properties.info;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
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/hooks.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { Database } from "bun:sqlite"
2
2
  import type { MemberRegistry, DescendantTracker } from "./state"
3
+ import type { PluginClient } from "./types"
4
+ import { flushPendingMessages } from "./messaging"
3
5
 
4
6
  const TEAM_TOOL_PREFIX = "team_"
5
7
 
@@ -101,3 +103,17 @@ export function checkToolIsolation(
101
103
  throw new Error("Team tools are not available to sub-agents. Report findings to your parent teammate via your normal output.")
102
104
  }
103
105
  }
106
+
107
+ /**
108
+ * Check if a session going idle is a team lead with pending messages.
109
+ * If so, flush them via promptAsync. Called from the event hook on idle transitions.
110
+ */
111
+ export async function handleLeadIdleFlush(
112
+ db: Database,
113
+ client: PluginClient,
114
+ sessionId: string,
115
+ ): Promise<number> {
116
+ const team = db.query("SELECT id FROM team WHERE lead_session_id = ? AND status = 'active'").get(sessionId) as { id: string } | null
117
+ if (!team) return 0
118
+ return flushPendingMessages(db, client, team.id, sessionId)
119
+ }
package/src/index.ts CHANGED
@@ -7,7 +7,7 @@ import { createDb, getDbPath } from "./db"
7
7
  import { wrapThrowingClient } from "./client"
8
8
  import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktrees } from "./recovery"
9
9
  import { MemberRegistry, DescendantTracker } from "./state"
10
- import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation } from "./hooks"
10
+ import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation, handleLeadIdleFlush } from "./hooks"
11
11
  import { notifyTeamEvent, notifyWorkingProgress } from "./notify"
12
12
  import { buildLeadSystemPrompt, buildTeammateSystemPrompt, buildTeamCompactionContext } from "./system-prompt"
13
13
  import { findTeamBySession } from "./types"
@@ -139,6 +139,11 @@ const plugin: Plugin = async (input) => {
139
139
  // Show working progress after every transition so the user sees who's still active
140
140
  await notifyWorkingProgress(client, db, transition.teamId)
141
141
  }
142
+
143
+ // Flush pending messages to the lead when their session goes idle
144
+ if (statusType === "idle") {
145
+ handleLeadIdleFlush(db, client, sessionID).catch(() => { /* best effort */ })
146
+ }
142
147
  }
143
148
 
144
149
  if (event.type === "session.created") {
package/src/messaging.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Database } from "bun:sqlite"
2
+ import type { PluginClient } from "./types"
2
3
  import { generateId } from "./util"
3
4
 
4
5
  const MAX_CONTENT_BYTES = 10 * 1024 // 10KB
@@ -76,3 +77,40 @@ export function getUndeliveredMessages(db: Database, teamId: string): MessageRow
76
77
  export function markDelivered(db: Database, messageId: string): void {
77
78
  db.run("UPDATE team_message SET delivered = 1 WHERE id = ?", [messageId])
78
79
  }
80
+
81
+ /**
82
+ * Flush undelivered messages addressed to the lead for a given team.
83
+ * Batches all pending messages into a single promptAsync call to avoid
84
+ * interrupting the lead with multiple deliveries. Returns count of delivered messages.
85
+ */
86
+ export async function flushPendingMessages(
87
+ db: Database,
88
+ client: PluginClient,
89
+ teamId: string,
90
+ leadSessionId: string,
91
+ ): Promise<number> {
92
+ const pending = db.query(
93
+ "SELECT * FROM team_message WHERE team_id = ? AND delivered = 0 AND to_name = 'lead' ORDER BY time_created ASC"
94
+ ).all(teamId) as MessageRow[]
95
+
96
+ if (pending.length === 0) return 0
97
+
98
+ const MAX_LEAD_MSG = 500
99
+ const parts = pending.map((msg) => {
100
+ if (msg.content.length > MAX_LEAD_MSG) {
101
+ return `[Team message from ${msg.from_name}]: ${msg.content.slice(0, MAX_LEAD_MSG)}... (use team_results to read full message)`
102
+ }
103
+ return `[Team message from ${msg.from_name}]: ${msg.content}`
104
+ })
105
+
106
+ try {
107
+ await client.session.promptAsync({
108
+ sessionID: leadSessionId,
109
+ parts: [{ type: "text", text: parts.join("\n") }],
110
+ })
111
+ for (const msg of pending) markDelivered(db, msg.id)
112
+ return pending.length
113
+ } catch {
114
+ return 0
115
+ }
116
+ }
@@ -47,8 +47,6 @@ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
47
47
  "",
48
48
  "Teammates work asynchronously and message you when done.",
49
49
  "Do NOT poll team_status or team_tasks_list repeatedly — wait for messages.",
50
- "Spawn teammates ONE at a time — do not send multiple team_spawn calls in a single response.",
51
- "After each team_spawn, verify the output confirms the spawn succeeded before spawning the next.",
52
50
  "After spawning teammates, tell the user what you've set up and wait.",
53
51
  "When all teammates finish, summarize results and suggest next steps.",
54
52
  ].join("\n")
@@ -67,6 +67,24 @@ export async function executeTeamMessage(
67
67
  deliveryText = `[Team message from ${senderName}]: ${messageText}`
68
68
  }
69
69
 
70
+ // Check if recipient is busy — if so, queue the message for later delivery.
71
+ // TOCTOU: status check and promptAsync are not atomic. A recipient can transition
72
+ // idle→busy between the check and delivery. This is accepted as benign — the idle-flush
73
+ // backstop catches anything that slips through, so no messages are lost.
74
+ let recipientBusy = false
75
+ try {
76
+ const statusResult = await deps.client.session.status()
77
+ const sessionStatus = statusResult.data?.[recipientSessionId]
78
+ if (sessionStatus?.type === "busy") {
79
+ recipientBusy = true
80
+ }
81
+ } catch { /* status check failed — deliver anyway as best effort */ }
82
+
83
+ if (recipientBusy) {
84
+ // Message is stored in DB with delivered=0, will be flushed when recipient goes idle
85
+ return `Message sent to ${args.to}. (queued — recipient is busy)`
86
+ }
87
+
70
88
  await deps.client.session.promptAsync({
71
89
  sessionID: recipientSessionId,
72
90
  parts: [{ type: "text", text: deliveryText }],