@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 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
- db.exec(MIGRATIONS[i]);
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 = new Set;
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["completed"] ?? 0;
15745
- const inProgress = countMap["in_progress"] ?? 0;
15746
- const pending = countMap["pending"] ?? 0;
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["completed"] ?? 0;
15780
- const inProgress = countMap["in_progress"] ?? 0;
15781
- const pending = countMap["pending"] ?? 0;
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
- try {
15923
- await deps.client.session.promptAsync({
15924
- sessionID: childSessionId,
15925
- parts: [{ type: "text", text: contextStr }],
15926
- agent: args.agent
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
- await deps.client.session.abort({ sessionID: childSessionId });
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
- if (worktreeDir) {
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
- await deps.client.session.promptAsync({
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
- markDelivered(deps.db, msgId);
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
- try {
16024
- await deps.client.session.promptAsync({
16025
- sessionID: recipient.sessionId,
16026
- parts: [{ type: "text", text: `[Team broadcast from ${senderName}]: ${args.text}` }]
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
- } catch {}
16081
+ if (delivered === 1)
16082
+ markDelivered(deps.db, msgId);
16083
+ }).catch(() => {});
16030
16084
  }
16031
- if (delivered > 0) {
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 rawClient = new OpencodeClient({ client: input.client._client });
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.1",
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
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
- type AnyFn = (...args: any[]) => Promise<any>
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: AnyFn): AnyFn {
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: AnyFn; promptAsync: AnyFn; abort: AnyFn; status: AnyFn }
26
- tui: { showToast: AnyFn; selectSession: AnyFn }
27
- worktree: { create: AnyFn; remove: AnyFn; list: AnyFn; reset: AnyFn }
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
@@ -1,5 +1,5 @@
1
1
  import { Database } from "bun:sqlite"
2
- import path from "path"
2
+ import path from "node:path"
3
3
  import { applyMigrations } from "./schema"
4
4
 
5
5
  let instance: Database | undefined
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 = new Set<string>()
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 as OpencodeClientV2 } from "@opencode-ai/sdk/v2"
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, PluginClient } from "./types"
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
- // Reuse the v1 client's working HTTP transport with v2's method signatures.
53
- // The v1 client (input.client) has the correct connection (Unix socket/auth),
54
- // while v2 gives us flat params + permission on create + agent/tools on promptAsync.
55
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
56
- const rawClient = new OpencodeClientV2({ client: (input.client as any)._client })
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
- db.exec(MIGRATIONS[i]!)
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
@@ -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["completed"] ?? 0
32
- const inProgress = countMap["in_progress"] ?? 0
33
- const pending = countMap["pending"] ?? 0
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["completed"] ?? 0
93
- const inProgress = countMap["in_progress"] ?? 0
94
- const pending = countMap["pending"] ?? 0
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
- // Deliver to all recipients — partial failures logged but don't fail the broadcast
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
- try {
46
- await deps.client.session.promptAsync({
47
- sessionID: recipient.sessionId,
48
- parts: [{ type: "text", text: `[Team broadcast from ${senderName}]: ${args.text}` }],
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
- } catch {
52
- // Log but don't fail
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 ${delivered} recipient${delivered !== 1 ? "s" : ""}.`
54
+ return `Broadcast sent to ${recipients.length} recipient${recipients.length !== 1 ? "s" : ""}.`
62
55
  }
@@ -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
- await deps.client.session.promptAsync({
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
- markDelivered(deps.db, msgId)
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
  }
@@ -1,6 +1,8 @@
1
1
  import type { ToolDeps, PermissionRule } from "../types"
2
- import { generateId, validateMemberName } from "../util"
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
- try {
161
- await deps.client.session.promptAsync({
162
- sessionID: childSessionId,
163
- parts: [{ type: "text", text: contextStr }],
164
- agent: args.agent,
165
- })
166
- } catch (err) {
167
- // Rollback: clean up DB, registry, session, and worktree
168
- deps.db.run("DELETE FROM team_member WHERE team_id = ? AND name = ?", [teamInfo.teamId, args.name])
169
- deps.registry.unregister(childSessionId)
170
- try { await deps.client.session.abort({ sessionID: childSessionId }) } catch { /* best effort */ }
171
- if (worktreeDir) {
172
- try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
173
- }
174
- throw new Error(`Failed to send initial prompt to teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`)
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]" : ""