@hueyexe/opencode-ensemble 0.6.0 → 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
@@ -1,12 +1,16 @@
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
+ }
3
7
  var __export = (target, all) => {
4
8
  for (var name in all)
5
9
  __defProp(target, name, {
6
10
  get: all[name],
7
11
  enumerable: true,
8
12
  configurable: true,
9
- set: (newValue) => all[name] = () => newValue
13
+ set: __exportSetter.bind(all, name)
10
14
  });
11
15
  };
12
16
 
@@ -15283,7 +15287,7 @@ class OpencodeClient extends HeyApiClient {
15283
15287
  }
15284
15288
  }
15285
15289
  // src/index.ts
15286
- import path3 from "path";
15290
+ import path2 from "path";
15287
15291
  import { mkdirSync } from "fs";
15288
15292
 
15289
15293
  // src/db.ts
@@ -15474,6 +15478,30 @@ function getUndeliveredMessages(db, teamId) {
15474
15478
  function markDelivered(db, messageId) {
15475
15479
  db.run("UPDATE team_message SET delivered = 1 WHERE id = ?", [messageId]);
15476
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
+ }
15477
15505
 
15478
15506
  // src/recovery.ts
15479
15507
  async function recoverStaleMembers(db, client3) {
@@ -15660,6 +15688,12 @@ function checkToolIsolation(registry2, tracker, toolName, sessionId) {
15660
15688
  throw new Error("Team tools are not available to sub-agents. Report findings to your parent teammate via your normal output.");
15661
15689
  }
15662
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
+ }
15663
15697
 
15664
15698
  // src/notify.ts
15665
15699
  var TOAST_CONFIG = {
@@ -15824,7 +15858,6 @@ async function executeTeamCreate(deps, args, sessionId) {
15824
15858
  }
15825
15859
 
15826
15860
  // src/tools/team-spawn.ts
15827
- import path2 from "path";
15828
15861
  async function executeTeamSpawn(deps, args, sessionId) {
15829
15862
  const nameError = validateMemberName(args.name);
15830
15863
  if (nameError)
@@ -15908,16 +15941,6 @@ async function executeTeamSpawn(deps, args, sessionId) {
15908
15941
  context.push("", "IMPORTANT: You are in PLAN MODE.", "Read and explore the codebase, then send your implementation plan to the lead via team_message.", "Do NOT write or modify any files until the lead approves your plan.", "Wait for the lead's approval message before proceeding with implementation.");
15909
15942
  }
15910
15943
  context.push("", "Tools available to you:", "- team_message: send a message to the lead or another teammate", "- team_broadcast: send a message to all team members", "- team_tasks_list: view the shared team task board", "- team_tasks_add: add tasks to the shared board", "- team_tasks_complete: mark a task complete on the shared board", "- team_claim: claim a pending task from the shared board", "", "When you finish your task:", "1. If you claimed a task, mark it complete using team_tasks_complete.", "2. Send ONE message to the lead with your findings using team_message.", "3. 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.");
15911
- try {
15912
- const agentsPath = path2.join(deps.directory, "AGENTS.md");
15913
- const file2 = Bun.file(agentsPath);
15914
- if (await file2.exists()) {
15915
- const content = await file2.text();
15916
- const truncated = content.length > 2000 ? content.slice(0, 2000) + `
15917
- ...(truncated)` : content;
15918
- context.push("", "Project guidelines (from AGENTS.md):", truncated);
15919
- }
15920
- } catch {}
15921
15944
  context.push("", "Your task:", args.prompt);
15922
15945
  if (args.claim_task) {
15923
15946
  context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`);
@@ -15992,6 +16015,17 @@ async function executeTeamMessage(deps, args, sessionId) {
15992
16015
  } else {
15993
16016
  deliveryText = `[Team message from ${senderName}]: ${messageText}`;
15994
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
+ }
15995
16029
  await deps.client.session.promptAsync({
15996
16030
  sessionID: recipientSessionId,
15997
16031
  parts: [{ type: "text", text: deliveryText }]
@@ -16446,7 +16480,7 @@ var DEFAULT_WATCHDOG_TTL_MS = 30 * 60 * 1000;
16446
16480
  var DEFAULT_WATCHDOG_CHECK_MS = 60 * 1000;
16447
16481
  var plugin = async (input) => {
16448
16482
  const dbPath = getDbPath();
16449
- mkdirSync(path3.dirname(dbPath), { recursive: true });
16483
+ mkdirSync(path2.dirname(dbPath), { recursive: true });
16450
16484
  const db = createDb(dbPath);
16451
16485
  const registry2 = new MemberRegistry;
16452
16486
  const tracker = new DescendantTracker;
@@ -16511,6 +16545,9 @@ var plugin = async (input) => {
16511
16545
  }
16512
16546
  await notifyWorkingProgress(client3, db, transition.teamId);
16513
16547
  }
16548
+ if (statusType === "idle") {
16549
+ handleLeadIdleFlush(db, client3, sessionID).catch(() => {});
16550
+ }
16514
16551
  }
16515
16552
  if (event.type === "session.created") {
16516
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.0",
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
+ }
@@ -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 }],
@@ -1,7 +1,6 @@
1
1
  import type { ToolDeps, PermissionRule } from "../types"
2
2
  import { generateId, validateMemberName } from "../util"
3
3
  import { findTeamBySession } from "../types"
4
- import path from "path"
5
4
 
6
5
  /**
7
6
  * Execute the team_spawn tool. Creates a child session and starts a teammate.
@@ -145,17 +144,6 @@ export async function executeTeamSpawn(
145
144
  "Your plain text output is NOT visible to the team. You MUST use team_message to communicate.",
146
145
  )
147
146
 
148
- // Load project AGENTS.md if it exists
149
- try {
150
- const agentsPath = path.join(deps.directory, "AGENTS.md")
151
- const file = Bun.file(agentsPath)
152
- if (await file.exists()) {
153
- const content = await file.text()
154
- const truncated = content.length > 2000 ? content.slice(0, 2000) + "\n...(truncated)" : content
155
- context.push("", "Project guidelines (from AGENTS.md):", truncated)
156
- }
157
- } catch { /* file may not exist or be unreadable */ }
158
-
159
147
  context.push(
160
148
  "",
161
149
  "Your task:",