@hueyexe/opencode-ensemble 0.6.1 → 0.6.3
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 +103 -48
- package/package.json +3 -1
- package/src/client.ts +6 -6
- package/src/db.ts +1 -1
- package/src/hooks.ts +17 -4
- package/src/index.ts +16 -10
- package/src/messaging.ts +38 -0
- package/src/notify.ts +0 -11
- package/src/schema.ts +2 -1
- package/src/state.ts +5 -0
- package/src/system-prompt.ts +6 -8
- package/src/tools/team-broadcast.ts +9 -16
- package/src/tools/team-claim.ts +1 -1
- package/src/tools/team-message.ts +25 -5
- package/src/tools/team-spawn.ts +42 -18
package/dist/index.js
CHANGED
|
@@ -15371,7 +15371,9 @@ var MIGRATIONS = [
|
|
|
15371
15371
|
function applyMigrations(db) {
|
|
15372
15372
|
const { user_version: current } = db.query("PRAGMA user_version").get();
|
|
15373
15373
|
for (let i = current;i < MIGRATIONS.length; i++) {
|
|
15374
|
-
|
|
15374
|
+
const migration = MIGRATIONS[i];
|
|
15375
|
+
if (migration)
|
|
15376
|
+
db.exec(migration);
|
|
15375
15377
|
db.exec(`PRAGMA user_version = ${i + 1}`);
|
|
15376
15378
|
}
|
|
15377
15379
|
}
|
|
@@ -15478,6 +15480,30 @@ function getUndeliveredMessages(db, teamId) {
|
|
|
15478
15480
|
function markDelivered(db, messageId) {
|
|
15479
15481
|
db.run("UPDATE team_message SET delivered = 1 WHERE id = ?", [messageId]);
|
|
15480
15482
|
}
|
|
15483
|
+
async function flushPendingMessages(db, client3, teamId, leadSessionId) {
|
|
15484
|
+
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);
|
|
15485
|
+
if (pending.length === 0)
|
|
15486
|
+
return 0;
|
|
15487
|
+
const MAX_LEAD_MSG = 500;
|
|
15488
|
+
const parts = pending.map((msg) => {
|
|
15489
|
+
if (msg.content.length > MAX_LEAD_MSG) {
|
|
15490
|
+
return `[Team message from ${msg.from_name}]: ${msg.content.slice(0, MAX_LEAD_MSG)}... (use team_results to read full message)`;
|
|
15491
|
+
}
|
|
15492
|
+
return `[Team message from ${msg.from_name}]: ${msg.content}`;
|
|
15493
|
+
});
|
|
15494
|
+
try {
|
|
15495
|
+
await client3.session.promptAsync({
|
|
15496
|
+
sessionID: leadSessionId,
|
|
15497
|
+
parts: [{ type: "text", text: parts.join(`
|
|
15498
|
+
`) }]
|
|
15499
|
+
});
|
|
15500
|
+
for (const msg of pending)
|
|
15501
|
+
markDelivered(db, msg.id);
|
|
15502
|
+
return pending.length;
|
|
15503
|
+
} catch {
|
|
15504
|
+
return 0;
|
|
15505
|
+
}
|
|
15506
|
+
}
|
|
15481
15507
|
|
|
15482
15508
|
// src/recovery.ts
|
|
15483
15509
|
async function recoverStaleMembers(db, client3) {
|
|
@@ -15587,6 +15613,9 @@ class MemberRegistry {
|
|
|
15587
15613
|
isTeamSession(sessionId) {
|
|
15588
15614
|
return this.bySession.has(sessionId);
|
|
15589
15615
|
}
|
|
15616
|
+
allSessionIds() {
|
|
15617
|
+
return new Set(this.bySession.keys());
|
|
15618
|
+
}
|
|
15590
15619
|
}
|
|
15591
15620
|
var DEFAULT_MAX_DEPTH = 10;
|
|
15592
15621
|
|
|
@@ -15656,14 +15685,17 @@ function checkToolIsolation(registry2, tracker, toolName, sessionId) {
|
|
|
15656
15685
|
return;
|
|
15657
15686
|
if (registry2.isTeamSession(sessionId))
|
|
15658
15687
|
return;
|
|
15659
|
-
const allTeamSessions =
|
|
15660
|
-
for (const entry of registry2["bySession"].keys()) {
|
|
15661
|
-
allTeamSessions.add(entry);
|
|
15662
|
-
}
|
|
15688
|
+
const allTeamSessions = registry2.allSessionIds();
|
|
15663
15689
|
if (allTeamSessions.size > 0 && tracker.isDescendantOf(sessionId, allTeamSessions)) {
|
|
15664
15690
|
throw new Error("Team tools are not available to sub-agents. Report findings to your parent teammate via your normal output.");
|
|
15665
15691
|
}
|
|
15666
15692
|
}
|
|
15693
|
+
async function handleLeadIdleFlush(db, client3, sessionId) {
|
|
15694
|
+
const team = db.query("SELECT id FROM team WHERE lead_session_id = ? AND status = 'active'").get(sessionId);
|
|
15695
|
+
if (!team)
|
|
15696
|
+
return 0;
|
|
15697
|
+
return flushPendingMessages(db, client3, team.id, sessionId);
|
|
15698
|
+
}
|
|
15667
15699
|
|
|
15668
15700
|
// src/notify.ts
|
|
15669
15701
|
var TOAST_CONFIG = {
|
|
@@ -15741,9 +15773,9 @@ function buildLeadSystemPrompt(db, teamId) {
|
|
|
15741
15773
|
for (const row of taskCounts) {
|
|
15742
15774
|
countMap[row.status] = row.count;
|
|
15743
15775
|
}
|
|
15744
|
-
const completed = countMap
|
|
15745
|
-
const inProgress = countMap
|
|
15746
|
-
const pending = countMap
|
|
15776
|
+
const completed = countMap.completed ?? 0;
|
|
15777
|
+
const inProgress = countMap.in_progress ?? 0;
|
|
15778
|
+
const pending = countMap.pending ?? 0;
|
|
15747
15779
|
const memberList = members.map((m) => `${m.name} [${STATUS_DISPLAY[m.status] ?? m.status}]`).join(", ");
|
|
15748
15780
|
const teammateLine = members.length > 0 ? `Teammates: ${memberList}` : "Teammates: none";
|
|
15749
15781
|
return [
|
|
@@ -15753,8 +15785,6 @@ function buildLeadSystemPrompt(db, teamId) {
|
|
|
15753
15785
|
"",
|
|
15754
15786
|
"Teammates work asynchronously and message you when done.",
|
|
15755
15787
|
"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
15788
|
"After spawning teammates, tell the user what you've set up and wait.",
|
|
15759
15789
|
"When all teammates finish, summarize results and suggest next steps."
|
|
15760
15790
|
].join(`
|
|
@@ -15776,9 +15806,9 @@ function buildTeamCompactionContext(db, teamId, role, memberName) {
|
|
|
15776
15806
|
for (const row of taskCounts) {
|
|
15777
15807
|
countMap[row.status] = row.count;
|
|
15778
15808
|
}
|
|
15779
|
-
const completed = countMap
|
|
15780
|
-
const inProgress = countMap
|
|
15781
|
-
const pending = countMap
|
|
15809
|
+
const completed = countMap.completed ?? 0;
|
|
15810
|
+
const inProgress = countMap.in_progress ?? 0;
|
|
15811
|
+
const pending = countMap.pending ?? 0;
|
|
15782
15812
|
const roleLine = role === "lead" ? `[Team Context] You are the lead of team "${team.name}".` : `[Team Context] You are a teammate named "${memberName}" in team "${team.name}".`;
|
|
15783
15813
|
const memberList = members.map((m) => `${m.name} (${STATUS_DISPLAY[m.status] ?? m.status})`).join(", ");
|
|
15784
15814
|
const membersLine = members.length > 0 ? `Members: ${memberList}` : "Members: none";
|
|
@@ -15919,25 +15949,36 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15919
15949
|
}
|
|
15920
15950
|
const contextStr = context.join(`
|
|
15921
15951
|
`);
|
|
15922
|
-
|
|
15923
|
-
|
|
15924
|
-
|
|
15925
|
-
|
|
15926
|
-
|
|
15927
|
-
});
|
|
15928
|
-
} catch (err) {
|
|
15929
|
-
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND name = ?", [teamInfo.teamId, args.name]);
|
|
15930
|
-
deps.registry.unregister(childSessionId);
|
|
15952
|
+
deps.client.session.promptAsync({
|
|
15953
|
+
sessionID: childSessionId,
|
|
15954
|
+
parts: [{ type: "text", text: contextStr }],
|
|
15955
|
+
agent: args.agent
|
|
15956
|
+
}).catch(() => {
|
|
15931
15957
|
try {
|
|
15932
|
-
|
|
15958
|
+
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId]);
|
|
15959
|
+
deps.registry.unregister(childSessionId);
|
|
15960
|
+
deps.client.session.abort({ sessionID: childSessionId }).catch(() => {});
|
|
15961
|
+
if (worktreeDir) {
|
|
15962
|
+
deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => {});
|
|
15963
|
+
}
|
|
15964
|
+
deps.client.tui.showToast({
|
|
15965
|
+
title: "Team",
|
|
15966
|
+
message: `Teammate "${args.name}" failed to start and was removed`,
|
|
15967
|
+
variant: "error",
|
|
15968
|
+
duration: 5000
|
|
15969
|
+
}).catch(() => {});
|
|
15970
|
+
sendMessage(deps.db, {
|
|
15971
|
+
teamId: teamInfo.teamId,
|
|
15972
|
+
from: "system",
|
|
15973
|
+
to: "lead",
|
|
15974
|
+
content: `Teammate "${args.name}" failed to start and was removed. You may retry the spawn.`
|
|
15975
|
+
});
|
|
15976
|
+
const team = deps.db.query("SELECT lead_session_id FROM team WHERE id = ?").get(teamInfo.teamId);
|
|
15977
|
+
if (team) {
|
|
15978
|
+
handleLeadIdleFlush(deps.db, deps.client, team.lead_session_id).catch(() => {});
|
|
15979
|
+
}
|
|
15933
15980
|
} catch {}
|
|
15934
|
-
|
|
15935
|
-
try {
|
|
15936
|
-
await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
|
|
15937
|
-
} catch {}
|
|
15938
|
-
}
|
|
15939
|
-
throw new Error(`Failed to send initial prompt to teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
15940
|
-
}
|
|
15981
|
+
});
|
|
15941
15982
|
const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : "";
|
|
15942
15983
|
const planInfo = usePlanApproval ? " [plan mode \u2014 will send plan for approval]" : "";
|
|
15943
15984
|
return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`;
|
|
@@ -15948,7 +15989,7 @@ async function executeTeamMessage(deps, args, sessionId) {
|
|
|
15948
15989
|
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
|
|
15949
15990
|
if (!teamInfo)
|
|
15950
15991
|
throw new Error("This session is not in a team. Use team_create first.");
|
|
15951
|
-
const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName;
|
|
15992
|
+
const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
|
|
15952
15993
|
const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to);
|
|
15953
15994
|
if (!recipientSessionId)
|
|
15954
15995
|
throw new Error(`Recipient "${args.to}" not found in team "${teamInfo.teamName}"`);
|
|
@@ -15987,11 +16028,23 @@ async function executeTeamMessage(deps, args, sessionId) {
|
|
|
15987
16028
|
} else {
|
|
15988
16029
|
deliveryText = `[Team message from ${senderName}]: ${messageText}`;
|
|
15989
16030
|
}
|
|
15990
|
-
|
|
16031
|
+
let recipientBusy = false;
|
|
16032
|
+
try {
|
|
16033
|
+
const statusResult = await deps.client.session.status();
|
|
16034
|
+
const sessionStatus = statusResult.data?.[recipientSessionId];
|
|
16035
|
+
if (sessionStatus?.type === "busy") {
|
|
16036
|
+
recipientBusy = true;
|
|
16037
|
+
}
|
|
16038
|
+
} catch {}
|
|
16039
|
+
if (recipientBusy) {
|
|
16040
|
+
return `Message sent to ${args.to}. (queued \u2014 recipient is busy)`;
|
|
16041
|
+
}
|
|
16042
|
+
deps.client.session.promptAsync({
|
|
15991
16043
|
sessionID: recipientSessionId,
|
|
15992
16044
|
parts: [{ type: "text", text: deliveryText }]
|
|
15993
|
-
})
|
|
15994
|
-
|
|
16045
|
+
}).then(() => {
|
|
16046
|
+
markDelivered(deps.db, msgId);
|
|
16047
|
+
}).catch(() => {});
|
|
15995
16048
|
return `Message sent to ${args.to}.`;
|
|
15996
16049
|
}
|
|
15997
16050
|
|
|
@@ -16000,7 +16053,7 @@ async function executeTeamBroadcast(deps, args, sessionId) {
|
|
|
16000
16053
|
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
|
|
16001
16054
|
if (!teamInfo)
|
|
16002
16055
|
throw new Error("This session is not in a team. Use team_create first.");
|
|
16003
|
-
const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName;
|
|
16056
|
+
const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
|
|
16004
16057
|
const msgId = broadcastMessage(deps.db, {
|
|
16005
16058
|
teamId: teamInfo.teamId,
|
|
16006
16059
|
from: senderName,
|
|
@@ -16020,18 +16073,16 @@ async function executeTeamBroadcast(deps, args, sessionId) {
|
|
|
16020
16073
|
}
|
|
16021
16074
|
let delivered = 0;
|
|
16022
16075
|
for (const recipient of recipients) {
|
|
16023
|
-
|
|
16024
|
-
|
|
16025
|
-
|
|
16026
|
-
|
|
16027
|
-
});
|
|
16076
|
+
deps.client.session.promptAsync({
|
|
16077
|
+
sessionID: recipient.sessionId,
|
|
16078
|
+
parts: [{ type: "text", text: `[Team broadcast from ${senderName}]: ${args.text}` }]
|
|
16079
|
+
}).then(() => {
|
|
16028
16080
|
delivered++;
|
|
16029
|
-
|
|
16081
|
+
if (delivered === 1)
|
|
16082
|
+
markDelivered(deps.db, msgId);
|
|
16083
|
+
}).catch(() => {});
|
|
16030
16084
|
}
|
|
16031
|
-
|
|
16032
|
-
markDelivered(deps.db, msgId);
|
|
16033
|
-
}
|
|
16034
|
-
return `Broadcast sent to ${delivered} recipient${delivered !== 1 ? "s" : ""}.`;
|
|
16085
|
+
return `Broadcast sent to ${recipients.length} recipient${recipients.length !== 1 ? "s" : ""}.`;
|
|
16035
16086
|
}
|
|
16036
16087
|
|
|
16037
16088
|
// src/tools/team-shutdown.ts
|
|
@@ -16201,7 +16252,7 @@ async function executeTeamClaim(deps, args, sessionId) {
|
|
|
16201
16252
|
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
|
|
16202
16253
|
if (!teamInfo)
|
|
16203
16254
|
throw new Error("This session is not in a team.");
|
|
16204
|
-
const claimerName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName;
|
|
16255
|
+
const claimerName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
|
|
16205
16256
|
const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?").get(args.task_id, teamInfo.teamId);
|
|
16206
16257
|
if (!task)
|
|
16207
16258
|
throw new Error(`Task "${args.task_id}" not found`);
|
|
@@ -16445,7 +16496,8 @@ var plugin = async (input) => {
|
|
|
16445
16496
|
const db = createDb(dbPath);
|
|
16446
16497
|
const registry2 = new MemberRegistry;
|
|
16447
16498
|
const tracker = new DescendantTracker;
|
|
16448
|
-
const
|
|
16499
|
+
const pluginTransport = input.client._client;
|
|
16500
|
+
const rawClient = new OpencodeClient({ client: pluginTransport });
|
|
16449
16501
|
const client3 = wrapThrowingClient(rawClient);
|
|
16450
16502
|
const deps = { db, registry: registry2, tracker, client: client3, directory: input.directory };
|
|
16451
16503
|
const recovery = await recoverStaleMembers(db, client3);
|
|
@@ -16506,6 +16558,9 @@ var plugin = async (input) => {
|
|
|
16506
16558
|
}
|
|
16507
16559
|
await notifyWorkingProgress(client3, db, transition.teamId);
|
|
16508
16560
|
}
|
|
16561
|
+
if (statusType === "idle") {
|
|
16562
|
+
handleLeadIdleFlush(db, client3, sessionID).catch(() => {});
|
|
16563
|
+
}
|
|
16509
16564
|
}
|
|
16510
16565
|
if (event.type === "session.created") {
|
|
16511
16566
|
const info = event.properties.info;
|
|
@@ -16528,7 +16583,7 @@ var plugin = async (input) => {
|
|
|
16528
16583
|
const teamInfo = findTeamBySession(db, registry2, input2.sessionID);
|
|
16529
16584
|
if (!teamInfo)
|
|
16530
16585
|
return;
|
|
16531
|
-
const prompt = teamInfo.role === "lead" ? buildLeadSystemPrompt(db, teamInfo.teamId) : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName);
|
|
16586
|
+
const prompt = teamInfo.role === "lead" ? buildLeadSystemPrompt(db, teamInfo.teamId) : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown");
|
|
16532
16587
|
output.system.push(prompt);
|
|
16533
16588
|
},
|
|
16534
16589
|
"experimental.session.compacting": async (input2, output) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hueyexe/opencode-ensemble",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
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",
|
|
@@ -26,12 +26,14 @@
|
|
|
26
26
|
"README.md"
|
|
27
27
|
],
|
|
28
28
|
"scripts": {
|
|
29
|
+
"lint": "bunx biome lint src/",
|
|
29
30
|
"typecheck": "tsc --noEmit",
|
|
30
31
|
"test": "bun test",
|
|
31
32
|
"build": "bun build src/index.ts --outdir dist --target bun",
|
|
32
33
|
"prepublishOnly": "bun run typecheck && bun test && bun run build"
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|
|
36
|
+
"@biomejs/biome": "^2.4.9",
|
|
35
37
|
"@types/bun": "latest"
|
|
36
38
|
},
|
|
37
39
|
"peerDependencies": {
|
package/src/client.ts
CHANGED
|
@@ -6,11 +6,11 @@ function extractError(err: unknown): string {
|
|
|
6
6
|
return String(err)
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
type
|
|
9
|
+
/** Generic async SDK method type. Actual type safety enforced by PluginClient interface. */
|
|
10
|
+
type SdkMethod = (...args: unknown[]) => Promise<unknown>
|
|
11
11
|
|
|
12
12
|
/** Wrap a single async SDK method to throw on { error } responses. */
|
|
13
|
-
function throwing(fn:
|
|
13
|
+
function throwing(fn: SdkMethod): SdkMethod {
|
|
14
14
|
return async (...args: unknown[]) => {
|
|
15
15
|
const result = await fn(...args)
|
|
16
16
|
if (result && typeof result === "object" && "error" in result && result.error !== undefined) {
|
|
@@ -22,9 +22,9 @@ function throwing(fn: AnyFn): AnyFn {
|
|
|
22
22
|
|
|
23
23
|
/** Shape of the raw v2 SDK client — just the methods we wrap. */
|
|
24
24
|
interface RawClient {
|
|
25
|
-
session: { create:
|
|
26
|
-
tui: { showToast:
|
|
27
|
-
worktree: { create:
|
|
25
|
+
session: { create: SdkMethod; promptAsync: SdkMethod; abort: SdkMethod; status: SdkMethod }
|
|
26
|
+
tui: { showToast: SdkMethod; selectSession: SdkMethod }
|
|
27
|
+
worktree: { create: SdkMethod; remove: SdkMethod; list: SdkMethod; reset: SdkMethod }
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
package/src/db.ts
CHANGED
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
|
|
|
@@ -92,12 +94,23 @@ export function checkToolIsolation(
|
|
|
92
94
|
if (registry.isTeamSession(sessionId)) return
|
|
93
95
|
|
|
94
96
|
// Check if this session is a descendant of any team member
|
|
95
|
-
const allTeamSessions =
|
|
96
|
-
for (const entry of registry["bySession"].keys()) {
|
|
97
|
-
allTeamSessions.add(entry)
|
|
98
|
-
}
|
|
97
|
+
const allTeamSessions = registry.allSessionIds()
|
|
99
98
|
|
|
100
99
|
if (allTeamSessions.size > 0 && tracker.isDescendantOf(sessionId, allTeamSessions)) {
|
|
101
100
|
throw new Error("Team tools are not available to sub-agents. Report findings to your parent teammate via your normal output.")
|
|
102
101
|
}
|
|
103
102
|
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Check if a session going idle is a team lead with pending messages.
|
|
106
|
+
* If so, flush them via promptAsync. Called from the event hook on idle transitions.
|
|
107
|
+
*/
|
|
108
|
+
export async function handleLeadIdleFlush(
|
|
109
|
+
db: Database,
|
|
110
|
+
client: PluginClient,
|
|
111
|
+
sessionId: string,
|
|
112
|
+
): Promise<number> {
|
|
113
|
+
const team = db.query("SELECT id FROM team WHERE lead_session_id = ? AND status = 'active'").get(sessionId) as { id: string } | null
|
|
114
|
+
if (!team) return 0
|
|
115
|
+
return flushPendingMessages(db, client, team.id, sessionId)
|
|
116
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
2
|
import { tool } from "@opencode-ai/plugin"
|
|
3
|
-
import { OpencodeClient
|
|
4
|
-
import path from "path"
|
|
3
|
+
import { OpencodeClient } from "@opencode-ai/sdk/v2"
|
|
4
|
+
import path from "node:path"
|
|
5
5
|
import { mkdirSync } from "node:fs"
|
|
6
6
|
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"
|
|
@@ -24,7 +24,7 @@ import { executeTeamClaim } from "./tools/team-claim"
|
|
|
24
24
|
import { executeTeamResults } from "./tools/team-results"
|
|
25
25
|
import { executeTeamStatus } from "./tools/team-status"
|
|
26
26
|
import { executeTeamView } from "./tools/team-view"
|
|
27
|
-
import type { ToolDeps,
|
|
27
|
+
import type { ToolDeps, } from "./types"
|
|
28
28
|
import { TokenBucket } from "./rate-limit"
|
|
29
29
|
import { Watchdog } from "./watchdog"
|
|
30
30
|
|
|
@@ -49,11 +49,12 @@ const plugin: Plugin = async (input) => {
|
|
|
49
49
|
const registry = new MemberRegistry()
|
|
50
50
|
const tracker = new DescendantTracker()
|
|
51
51
|
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
const
|
|
52
|
+
// Extract the working HeyAPI transport from the plugin-provided v1 client and pass it
|
|
53
|
+
// to the v2 OpencodeClient. The plugin framework provides a v1 client which stores its
|
|
54
|
+
// HeyAPI transport as `_client` (underscore). The v2 constructor accepts it as `client`.
|
|
55
|
+
type V2Transport = ConstructorParameters<typeof OpencodeClient>[0] extends { client?: infer C } ? C : never
|
|
56
|
+
const pluginTransport = (input.client as unknown as { _client: V2Transport })._client
|
|
57
|
+
const rawClient = new OpencodeClient({ client: pluginTransport })
|
|
57
58
|
const client = wrapThrowingClient(rawClient)
|
|
58
59
|
const deps: ToolDeps = { db, registry, tracker, client, directory: input.directory }
|
|
59
60
|
|
|
@@ -139,6 +140,11 @@ const plugin: Plugin = async (input) => {
|
|
|
139
140
|
// Show working progress after every transition so the user sees who's still active
|
|
140
141
|
await notifyWorkingProgress(client, db, transition.teamId)
|
|
141
142
|
}
|
|
143
|
+
|
|
144
|
+
// Flush pending messages to the lead when their session goes idle
|
|
145
|
+
if (statusType === "idle") {
|
|
146
|
+
handleLeadIdleFlush(db, client, sessionID).catch(() => { /* best effort */ })
|
|
147
|
+
}
|
|
142
148
|
}
|
|
143
149
|
|
|
144
150
|
if (event.type === "session.created") {
|
|
@@ -167,7 +173,7 @@ const plugin: Plugin = async (input) => {
|
|
|
167
173
|
if (!teamInfo) return
|
|
168
174
|
const prompt = teamInfo.role === "lead"
|
|
169
175
|
? buildLeadSystemPrompt(db, teamInfo.teamId)
|
|
170
|
-
: buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName
|
|
176
|
+
: buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown")
|
|
171
177
|
output.system.push(prompt)
|
|
172
178
|
},
|
|
173
179
|
|
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/notify.ts
CHANGED
|
@@ -3,17 +3,6 @@ import type { PluginClient } from "./types"
|
|
|
3
3
|
|
|
4
4
|
type TeamEventType = "spawn" | "message" | "completed" | "error" | "shutdown"
|
|
5
5
|
|
|
6
|
-
interface SpawnEvent { memberName: string; agent: string }
|
|
7
|
-
interface MessageEvent { from: string; to: string }
|
|
8
|
-
interface MemberEvent { memberName: string }
|
|
9
|
-
|
|
10
|
-
type TeamEventData =
|
|
11
|
-
| { type: "spawn"; data: SpawnEvent }
|
|
12
|
-
| { type: "message"; data: MessageEvent }
|
|
13
|
-
| { type: "completed"; data: MemberEvent }
|
|
14
|
-
| { type: "error"; data: MemberEvent }
|
|
15
|
-
| { type: "shutdown"; data: MemberEvent }
|
|
16
|
-
|
|
17
6
|
const TOAST_CONFIG: Record<TeamEventType, { variant: "info" | "success" | "warning" | "error"; duration: number }> = {
|
|
18
7
|
spawn: { variant: "success", duration: 3000 },
|
|
19
8
|
message: { variant: "info", duration: 3000 },
|
package/src/schema.ts
CHANGED
|
@@ -88,7 +88,8 @@ export function applyMigrations(db: Database): void {
|
|
|
88
88
|
const { user_version: current } = db.query("PRAGMA user_version").get() as { user_version: number }
|
|
89
89
|
|
|
90
90
|
for (let i = current; i < MIGRATIONS.length; i++) {
|
|
91
|
-
|
|
91
|
+
const migration = MIGRATIONS[i]
|
|
92
|
+
if (migration) db.exec(migration)
|
|
92
93
|
db.exec(`PRAGMA user_version = ${i + 1}`)
|
|
93
94
|
}
|
|
94
95
|
}
|
package/src/state.ts
CHANGED
|
@@ -59,6 +59,11 @@ export class MemberRegistry {
|
|
|
59
59
|
isTeamSession(sessionId: string): boolean {
|
|
60
60
|
return this.bySession.has(sessionId)
|
|
61
61
|
}
|
|
62
|
+
|
|
63
|
+
/** Get all registered session IDs. */
|
|
64
|
+
allSessionIds(): Set<string> {
|
|
65
|
+
return new Set(this.bySession.keys())
|
|
66
|
+
}
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
const DEFAULT_MAX_DEPTH = 10
|
package/src/system-prompt.ts
CHANGED
|
@@ -28,9 +28,9 @@ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
|
|
|
28
28
|
countMap[row.status] = row.count
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
const completed = countMap
|
|
32
|
-
const inProgress = countMap
|
|
33
|
-
const pending = countMap
|
|
31
|
+
const completed = countMap.completed ?? 0
|
|
32
|
+
const inProgress = countMap.in_progress ?? 0
|
|
33
|
+
const pending = countMap.pending ?? 0
|
|
34
34
|
|
|
35
35
|
const memberList = members
|
|
36
36
|
.map((m) => `${m.name} [${STATUS_DISPLAY[m.status] ?? m.status}]`)
|
|
@@ -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")
|
|
@@ -89,9 +87,9 @@ export function buildTeamCompactionContext(
|
|
|
89
87
|
countMap[row.status] = row.count
|
|
90
88
|
}
|
|
91
89
|
|
|
92
|
-
const completed = countMap
|
|
93
|
-
const inProgress = countMap
|
|
94
|
-
const pending = countMap
|
|
90
|
+
const completed = countMap.completed ?? 0
|
|
91
|
+
const inProgress = countMap.in_progress ?? 0
|
|
92
|
+
const pending = countMap.pending ?? 0
|
|
95
93
|
|
|
96
94
|
const roleLine = role === "lead"
|
|
97
95
|
? `[Team Context] You are the lead of team "${team.name}".`
|
|
@@ -13,7 +13,7 @@ export async function executeTeamBroadcast(
|
|
|
13
13
|
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
14
14
|
if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
|
|
15
15
|
|
|
16
|
-
const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName
|
|
16
|
+
const senderName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
|
|
17
17
|
|
|
18
18
|
const msgId = broadcastMessage(deps.db, {
|
|
19
19
|
teamId: teamInfo.teamId,
|
|
@@ -39,24 +39,17 @@ export async function executeTeamBroadcast(
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
//
|
|
42
|
+
// Fire-and-forget: deliver to all recipients. Message is already persisted in DB.
|
|
43
43
|
let delivered = 0
|
|
44
44
|
for (const recipient of recipients) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
})
|
|
45
|
+
deps.client.session.promptAsync({
|
|
46
|
+
sessionID: recipient.sessionId,
|
|
47
|
+
parts: [{ type: "text", text: `[Team broadcast from ${senderName}]: ${args.text}` }],
|
|
48
|
+
}).then(() => {
|
|
50
49
|
delivered++
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Only mark delivered if at least one recipient received it
|
|
57
|
-
if (delivered > 0) {
|
|
58
|
-
markDelivered(deps.db, msgId)
|
|
50
|
+
if (delivered === 1) markDelivered(deps.db, msgId)
|
|
51
|
+
}).catch(() => { /* partial failure is expected */ })
|
|
59
52
|
}
|
|
60
53
|
|
|
61
|
-
return `Broadcast sent to ${
|
|
54
|
+
return `Broadcast sent to ${recipients.length} recipient${recipients.length !== 1 ? "s" : ""}.`
|
|
62
55
|
}
|
package/src/tools/team-claim.ts
CHANGED
|
@@ -13,7 +13,7 @@ export async function executeTeamClaim(
|
|
|
13
13
|
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
14
14
|
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
15
15
|
|
|
16
|
-
const claimerName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName
|
|
16
|
+
const claimerName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
|
|
17
17
|
|
|
18
18
|
const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?")
|
|
19
19
|
.get(args.task_id, teamInfo.teamId) as Record<string, unknown> | null
|
|
@@ -14,7 +14,7 @@ export async function executeTeamMessage(
|
|
|
14
14
|
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
15
15
|
if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
|
|
16
16
|
|
|
17
|
-
const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName
|
|
17
|
+
const senderName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
|
|
18
18
|
|
|
19
19
|
const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to)
|
|
20
20
|
if (!recipientSessionId) throw new Error(`Recipient "${args.to}" not found in team "${teamInfo.teamName}"`)
|
|
@@ -67,12 +67,32 @@ export async function executeTeamMessage(
|
|
|
67
67
|
deliveryText = `[Team message from ${senderName}]: ${messageText}`
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
|
|
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
|
+
|
|
88
|
+
// Fire-and-forget: message is already persisted in DB. If delivery fails,
|
|
89
|
+
// the idle-flush backstop will redeliver it.
|
|
90
|
+
deps.client.session.promptAsync({
|
|
71
91
|
sessionID: recipientSessionId,
|
|
72
92
|
parts: [{ type: "text", text: deliveryText }],
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
|
|
93
|
+
}).then(() => {
|
|
94
|
+
markDelivered(deps.db, msgId)
|
|
95
|
+
}).catch(() => { /* message stays delivered=0, idle-flush will retry */ })
|
|
76
96
|
|
|
77
97
|
return `Message sent to ${args.to}.`
|
|
78
98
|
}
|
package/src/tools/team-spawn.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { ToolDeps, PermissionRule } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { validateMemberName } from "../util"
|
|
3
3
|
import { findTeamBySession } from "../types"
|
|
4
|
+
import { sendMessage } from "../messaging"
|
|
5
|
+
import { handleLeadIdleFlush } from "../hooks"
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Execute the team_spawn tool. Creates a child session and starts a teammate.
|
|
@@ -156,23 +158,45 @@ export async function executeTeamSpawn(
|
|
|
156
158
|
|
|
157
159
|
const contextStr = context.join("\n")
|
|
158
160
|
|
|
159
|
-
// Fire-and-forget: send prompt to teammate session
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
161
|
+
// Fire-and-forget: send prompt to teammate session.
|
|
162
|
+
// Do NOT await — promptAsync is HTTP 204 (returns immediately on the server)
|
|
163
|
+
// but awaiting it can block if the transport is slow or broken (e.g. proxy).
|
|
164
|
+
// The child session is already created and registered. If delivery fails,
|
|
165
|
+
// the async .catch() rolls back the member and the watchdog handles stale members.
|
|
166
|
+
deps.client.session.promptAsync({
|
|
167
|
+
sessionID: childSessionId,
|
|
168
|
+
parts: [{ type: "text", text: contextStr }],
|
|
169
|
+
agent: args.agent,
|
|
170
|
+
}).catch(() => {
|
|
171
|
+
try {
|
|
172
|
+
// Async rollback: clean up DB (by session_id to avoid deleting a re-spawned member with the same name), registry, session, and worktree
|
|
173
|
+
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId])
|
|
174
|
+
deps.registry.unregister(childSessionId)
|
|
175
|
+
deps.client.session.abort({ sessionID: childSessionId }).catch(() => { /* best effort */ })
|
|
176
|
+
if (worktreeDir) {
|
|
177
|
+
deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => { /* best effort */ })
|
|
178
|
+
}
|
|
179
|
+
// Notify user that spawn failed so the lead doesn't think the teammate is active
|
|
180
|
+
deps.client.tui.showToast({
|
|
181
|
+
title: "Team",
|
|
182
|
+
message: `Teammate "${args.name}" failed to start and was removed`,
|
|
183
|
+
variant: "error",
|
|
184
|
+
duration: 5000,
|
|
185
|
+
}).catch(() => { /* TUI may not be available */ })
|
|
186
|
+
// Notify the lead model so it can react (retry, adjust plan, etc.)
|
|
187
|
+
sendMessage(deps.db, {
|
|
188
|
+
teamId: teamInfo.teamId,
|
|
189
|
+
from: "system",
|
|
190
|
+
to: "lead",
|
|
191
|
+
content: `Teammate "${args.name}" failed to start and was removed. You may retry the spawn.`,
|
|
192
|
+
})
|
|
193
|
+
const team = deps.db.query("SELECT lead_session_id FROM team WHERE id = ?")
|
|
194
|
+
.get(teamInfo.teamId) as { lead_session_id: string } | null
|
|
195
|
+
if (team) {
|
|
196
|
+
handleLeadIdleFlush(deps.db, deps.client, team.lead_session_id).catch(() => {})
|
|
197
|
+
}
|
|
198
|
+
} catch { /* rollback failed — watchdog will clean up stale member */ }
|
|
199
|
+
})
|
|
176
200
|
|
|
177
201
|
const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : ""
|
|
178
202
|
const planInfo = usePlanApproval ? " [plan mode — will send plan for approval]" : ""
|