@hueyexe/opencode-ensemble 0.6.2 → 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
  }
@@ -15611,6 +15613,9 @@ class MemberRegistry {
15611
15613
  isTeamSession(sessionId) {
15612
15614
  return this.bySession.has(sessionId);
15613
15615
  }
15616
+ allSessionIds() {
15617
+ return new Set(this.bySession.keys());
15618
+ }
15614
15619
  }
15615
15620
  var DEFAULT_MAX_DEPTH = 10;
15616
15621
 
@@ -15680,10 +15685,7 @@ function checkToolIsolation(registry2, tracker, toolName, sessionId) {
15680
15685
  return;
15681
15686
  if (registry2.isTeamSession(sessionId))
15682
15687
  return;
15683
- const allTeamSessions = new Set;
15684
- for (const entry of registry2["bySession"].keys()) {
15685
- allTeamSessions.add(entry);
15686
- }
15688
+ const allTeamSessions = registry2.allSessionIds();
15687
15689
  if (allTeamSessions.size > 0 && tracker.isDescendantOf(sessionId, allTeamSessions)) {
15688
15690
  throw new Error("Team tools are not available to sub-agents. Report findings to your parent teammate via your normal output.");
15689
15691
  }
@@ -15771,9 +15773,9 @@ function buildLeadSystemPrompt(db, teamId) {
15771
15773
  for (const row of taskCounts) {
15772
15774
  countMap[row.status] = row.count;
15773
15775
  }
15774
- const completed = countMap["completed"] ?? 0;
15775
- const inProgress = countMap["in_progress"] ?? 0;
15776
- const pending = countMap["pending"] ?? 0;
15776
+ const completed = countMap.completed ?? 0;
15777
+ const inProgress = countMap.in_progress ?? 0;
15778
+ const pending = countMap.pending ?? 0;
15777
15779
  const memberList = members.map((m) => `${m.name} [${STATUS_DISPLAY[m.status] ?? m.status}]`).join(", ");
15778
15780
  const teammateLine = members.length > 0 ? `Teammates: ${memberList}` : "Teammates: none";
15779
15781
  return [
@@ -15804,9 +15806,9 @@ function buildTeamCompactionContext(db, teamId, role, memberName) {
15804
15806
  for (const row of taskCounts) {
15805
15807
  countMap[row.status] = row.count;
15806
15808
  }
15807
- const completed = countMap["completed"] ?? 0;
15808
- const inProgress = countMap["in_progress"] ?? 0;
15809
- const pending = countMap["pending"] ?? 0;
15809
+ const completed = countMap.completed ?? 0;
15810
+ const inProgress = countMap.in_progress ?? 0;
15811
+ const pending = countMap.pending ?? 0;
15810
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}".`;
15811
15813
  const memberList = members.map((m) => `${m.name} (${STATUS_DISPLAY[m.status] ?? m.status})`).join(", ");
15812
15814
  const membersLine = members.length > 0 ? `Members: ${memberList}` : "Members: none";
@@ -15947,25 +15949,36 @@ async function executeTeamSpawn(deps, args, sessionId) {
15947
15949
  }
15948
15950
  const contextStr = context.join(`
15949
15951
  `);
15950
- try {
15951
- await deps.client.session.promptAsync({
15952
- sessionID: childSessionId,
15953
- parts: [{ type: "text", text: contextStr }],
15954
- agent: args.agent
15955
- });
15956
- } catch (err) {
15957
- deps.db.run("DELETE FROM team_member WHERE team_id = ? AND name = ?", [teamInfo.teamId, args.name]);
15958
- deps.registry.unregister(childSessionId);
15952
+ deps.client.session.promptAsync({
15953
+ sessionID: childSessionId,
15954
+ parts: [{ type: "text", text: contextStr }],
15955
+ agent: args.agent
15956
+ }).catch(() => {
15959
15957
  try {
15960
- 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
+ }
15961
15980
  } catch {}
15962
- if (worktreeDir) {
15963
- try {
15964
- await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
15965
- } catch {}
15966
- }
15967
- throw new Error(`Failed to send initial prompt to teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`);
15968
- }
15981
+ });
15969
15982
  const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : "";
15970
15983
  const planInfo = usePlanApproval ? " [plan mode \u2014 will send plan for approval]" : "";
15971
15984
  return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`;
@@ -15976,7 +15989,7 @@ async function executeTeamMessage(deps, args, sessionId) {
15976
15989
  const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
15977
15990
  if (!teamInfo)
15978
15991
  throw new Error("This session is not in a team. Use team_create first.");
15979
- const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName;
15992
+ const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
15980
15993
  const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to);
15981
15994
  if (!recipientSessionId)
15982
15995
  throw new Error(`Recipient "${args.to}" not found in team "${teamInfo.teamName}"`);
@@ -16026,11 +16039,12 @@ async function executeTeamMessage(deps, args, sessionId) {
16026
16039
  if (recipientBusy) {
16027
16040
  return `Message sent to ${args.to}. (queued \u2014 recipient is busy)`;
16028
16041
  }
16029
- await deps.client.session.promptAsync({
16042
+ deps.client.session.promptAsync({
16030
16043
  sessionID: recipientSessionId,
16031
16044
  parts: [{ type: "text", text: deliveryText }]
16032
- });
16033
- markDelivered(deps.db, msgId);
16045
+ }).then(() => {
16046
+ markDelivered(deps.db, msgId);
16047
+ }).catch(() => {});
16034
16048
  return `Message sent to ${args.to}.`;
16035
16049
  }
16036
16050
 
@@ -16039,7 +16053,7 @@ async function executeTeamBroadcast(deps, args, sessionId) {
16039
16053
  const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
16040
16054
  if (!teamInfo)
16041
16055
  throw new Error("This session is not in a team. Use team_create first.");
16042
- const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName;
16056
+ const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
16043
16057
  const msgId = broadcastMessage(deps.db, {
16044
16058
  teamId: teamInfo.teamId,
16045
16059
  from: senderName,
@@ -16059,18 +16073,16 @@ async function executeTeamBroadcast(deps, args, sessionId) {
16059
16073
  }
16060
16074
  let delivered = 0;
16061
16075
  for (const recipient of recipients) {
16062
- try {
16063
- await deps.client.session.promptAsync({
16064
- sessionID: recipient.sessionId,
16065
- parts: [{ type: "text", text: `[Team broadcast from ${senderName}]: ${args.text}` }]
16066
- });
16076
+ deps.client.session.promptAsync({
16077
+ sessionID: recipient.sessionId,
16078
+ parts: [{ type: "text", text: `[Team broadcast from ${senderName}]: ${args.text}` }]
16079
+ }).then(() => {
16067
16080
  delivered++;
16068
- } catch {}
16069
- }
16070
- if (delivered > 0) {
16071
- markDelivered(deps.db, msgId);
16081
+ if (delivered === 1)
16082
+ markDelivered(deps.db, msgId);
16083
+ }).catch(() => {});
16072
16084
  }
16073
- return `Broadcast sent to ${delivered} recipient${delivered !== 1 ? "s" : ""}.`;
16085
+ return `Broadcast sent to ${recipients.length} recipient${recipients.length !== 1 ? "s" : ""}.`;
16074
16086
  }
16075
16087
 
16076
16088
  // src/tools/team-shutdown.ts
@@ -16240,7 +16252,7 @@ async function executeTeamClaim(deps, args, sessionId) {
16240
16252
  const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
16241
16253
  if (!teamInfo)
16242
16254
  throw new Error("This session is not in a team.");
16243
- const claimerName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName;
16255
+ const claimerName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
16244
16256
  const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?").get(args.task_id, teamInfo.teamId);
16245
16257
  if (!task)
16246
16258
  throw new Error(`Task "${args.task_id}" not found`);
@@ -16484,7 +16496,8 @@ var plugin = async (input) => {
16484
16496
  const db = createDb(dbPath);
16485
16497
  const registry2 = new MemberRegistry;
16486
16498
  const tracker = new DescendantTracker;
16487
- const rawClient = new OpencodeClient({ client: input.client._client });
16499
+ const pluginTransport = input.client._client;
16500
+ const rawClient = new OpencodeClient({ client: pluginTransport });
16488
16501
  const client3 = wrapThrowingClient(rawClient);
16489
16502
  const deps = { db, registry: registry2, tracker, client: client3, directory: input.directory };
16490
16503
  const recovery = await recoverStaleMembers(db, client3);
@@ -16570,7 +16583,7 @@ var plugin = async (input) => {
16570
16583
  const teamInfo = findTeamBySession(db, registry2, input2.sessionID);
16571
16584
  if (!teamInfo)
16572
16585
  return;
16573
- 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");
16574
16587
  output.system.push(prompt);
16575
16588
  },
16576
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.2",
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
@@ -94,10 +94,7 @@ export function checkToolIsolation(
94
94
  if (registry.isTeamSession(sessionId)) return
95
95
 
96
96
  // Check if this session is a descendant of any team member
97
- const allTeamSessions = new Set<string>()
98
- for (const entry of registry["bySession"].keys()) {
99
- allTeamSessions.add(entry)
100
- }
97
+ const allTeamSessions = registry.allSessionIds()
101
98
 
102
99
  if (allTeamSessions.size > 0 && tracker.isDescendantOf(sessionId, allTeamSessions)) {
103
100
  throw new Error("Team tools are not available to sub-agents. Report findings to your parent teammate via your normal output.")
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
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"
@@ -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
 
@@ -172,7 +173,7 @@ const plugin: Plugin = async (input) => {
172
173
  if (!teamInfo) return
173
174
  const prompt = teamInfo.role === "lead"
174
175
  ? buildLeadSystemPrompt(db, teamInfo.teamId)
175
- : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName!)
176
+ : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown")
176
177
  output.system.push(prompt)
177
178
  },
178
179
 
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}]`)
@@ -87,9 +87,9 @@ export function buildTeamCompactionContext(
87
87
  countMap[row.status] = row.count
88
88
  }
89
89
 
90
- const completed = countMap["completed"] ?? 0
91
- const inProgress = countMap["in_progress"] ?? 0
92
- const pending = countMap["pending"] ?? 0
90
+ const completed = countMap.completed ?? 0
91
+ const inProgress = countMap.in_progress ?? 0
92
+ const pending = countMap.pending ?? 0
93
93
 
94
94
  const roleLine = role === "lead"
95
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}"`)
@@ -85,12 +85,14 @@ export async function executeTeamMessage(
85
85
  return `Message sent to ${args.to}. (queued — recipient is busy)`
86
86
  }
87
87
 
88
- await deps.client.session.promptAsync({
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({
89
91
  sessionID: recipientSessionId,
90
92
  parts: [{ type: "text", text: deliveryText }],
91
- })
92
-
93
- markDelivered(deps.db, msgId)
93
+ }).then(() => {
94
+ markDelivered(deps.db, msgId)
95
+ }).catch(() => { /* message stays delivered=0, idle-flush will retry */ })
94
96
 
95
97
  return `Message sent to ${args.to}.`
96
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]" : ""