@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 +44 -2
- package/package.json +1 -1
- package/src/hooks.ts +16 -0
- package/src/index.ts +6 -1
- package/src/messaging.ts +38 -0
- package/src/system-prompt.ts +0 -2
- package/src/tools/team-message.ts +18 -0
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.
|
|
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
|
+
}
|
package/src/system-prompt.ts
CHANGED
|
@@ -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 }],
|