@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 +51 -14
- 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/tools/team-message.ts +18 -0
- package/src/tools/team-spawn.ts +0 -12
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: (
|
|
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
|
|
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(
|
|
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.
|
|
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 }],
|
package/src/tools/team-spawn.ts
CHANGED
|
@@ -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:",
|