@hueyexe/opencode-ensemble 0.6.3 → 0.6.4

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
@@ -15446,6 +15446,9 @@ function validateTeamName(name) {
15446
15446
  return "Team name must be lowercase alphanumeric with hyphens only";
15447
15447
  return;
15448
15448
  }
15449
+ function isWorktreeInstance(directory) {
15450
+ return directory.includes("/worktree/") && directory.includes("/ensemble-");
15451
+ }
15449
15452
  function validateMemberName(name) {
15450
15453
  if (name.toLowerCase() === "lead")
15451
15454
  return `Name "lead" is reserved`;
@@ -15480,30 +15483,6 @@ function getUndeliveredMessages(db, teamId) {
15480
15483
  function markDelivered(db, messageId) {
15481
15484
  db.run("UPDATE team_message SET delivered = 1 WHERE id = ?", [messageId]);
15482
15485
  }
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
- }
15507
15486
 
15508
15487
  // src/recovery.ts
15509
15488
  async function recoverStaleMembers(db, client3) {
@@ -15552,7 +15531,7 @@ async function recoverUndeliveredMessages(db, client3, registry2) {
15552
15531
  for (const msg of messages) {
15553
15532
  let recipientSessionId;
15554
15533
  if (msg.to_name === "lead") {
15555
- recipientSessionId = team.lead_session_id;
15534
+ continue;
15556
15535
  } else if (msg.to_name) {
15557
15536
  const entry = registry2.getByName(team.id, msg.to_name);
15558
15537
  recipientSessionId = entry?.sessionId;
@@ -15690,12 +15669,6 @@ function checkToolIsolation(registry2, tracker, toolName, sessionId) {
15690
15669
  throw new Error("Team tools are not available to sub-agents. Report findings to your parent teammate via your normal output.");
15691
15670
  }
15692
15671
  }
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
- }
15699
15672
 
15700
15673
  // src/notify.ts
15701
15674
  var TOAST_CONFIG = {
@@ -15778,16 +15751,27 @@ function buildLeadSystemPrompt(db, teamId) {
15778
15751
  const pending = countMap.pending ?? 0;
15779
15752
  const memberList = members.map((m) => `${m.name} [${STATUS_DISPLAY[m.status] ?? m.status}]`).join(", ");
15780
15753
  const teammateLine = members.length > 0 ? `Teammates: ${memberList}` : "Teammates: none";
15781
- return [
15754
+ const pendingMessages = db.query("SELECT id, from_name, content FROM team_message WHERE team_id = ? AND to_name = 'lead' AND delivered = 0 ORDER BY time_created ASC").all(teamId);
15755
+ const lines = [
15782
15756
  `You are leading team "${team.name}" with ${members.length} active teammates.`,
15783
15757
  teammateLine,
15784
- `Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`,
15785
- "",
15786
- "Teammates work asynchronously and message you when done.",
15787
- "Do NOT poll team_status or team_tasks_list repeatedly \u2014 wait for messages.",
15788
- "After spawning teammates, tell the user what you've set up and wait.",
15789
- "When all teammates finish, summarize results and suggest next steps."
15790
- ].join(`
15758
+ `Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`
15759
+ ];
15760
+ if (pendingMessages.length > 0) {
15761
+ lines.push("", "--- Team Messages ---");
15762
+ const MAX_MSG = 500;
15763
+ for (const msg of pendingMessages) {
15764
+ if (msg.content.length > MAX_MSG) {
15765
+ lines.push(`[From ${msg.from_name}]: ${msg.content.slice(0, MAX_MSG)}... (use team_results to read full message)`);
15766
+ } else {
15767
+ lines.push(`[From ${msg.from_name}]: ${msg.content}`);
15768
+ }
15769
+ markDelivered(db, msg.id);
15770
+ }
15771
+ lines.push("--- End Messages ---");
15772
+ }
15773
+ lines.push("", "CRITICAL: Spawn teammates ONE AT A TIME. Send only ONE team_spawn call per response.", "Wait for the tool result before spawning the next teammate.", "Multiple team_spawn calls in a single response will cause timeouts.", "", "Teammates work asynchronously and message you when done.", "Do NOT poll team_status or team_tasks_list repeatedly \u2014 wait for messages.", "After spawning all teammates, tell the user what you've set up and wait.", "When all teammates finish, summarize results and suggest next steps.");
15774
+ return lines.join(`
15791
15775
  `);
15792
15776
  }
15793
15777
  function buildTeammateSystemPrompt(db, teamId, memberName) {
@@ -15820,6 +15804,17 @@ function buildTeamCompactionContext(db, teamId, role, memberName) {
15820
15804
  `);
15821
15805
  }
15822
15806
 
15807
+ // src/log.ts
15808
+ var _client = null;
15809
+ function initLog(client3) {
15810
+ _client = client3;
15811
+ }
15812
+ function log(msg) {
15813
+ if (!_client)
15814
+ return;
15815
+ _client.app.log({ service: "ensemble", level: "info", message: msg }).catch(() => {});
15816
+ }
15817
+
15823
15818
  // src/types.ts
15824
15819
  function findTeamBySession(db, registry2, sessionId) {
15825
15820
  const entry = registry2.getBySession(sessionId);
@@ -15860,6 +15855,18 @@ async function executeTeamCreate(deps, args, sessionId) {
15860
15855
  }
15861
15856
 
15862
15857
  // src/tools/team-spawn.ts
15858
+ function getSpawnTimeout() {
15859
+ return Number(process.env.SPAWN_TIMEOUT_MS) || 120000;
15860
+ }
15861
+ function withTimeout(promise2, ms, label) {
15862
+ let timer;
15863
+ return Promise.race([
15864
+ promise2.finally(() => clearTimeout(timer)),
15865
+ new Promise((_, reject) => {
15866
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
15867
+ })
15868
+ ]);
15869
+ }
15863
15870
  async function executeTeamSpawn(deps, args, sessionId) {
15864
15871
  const nameError = validateMemberName(args.name);
15865
15872
  if (nameError)
@@ -15874,17 +15881,21 @@ async function executeTeamSpawn(deps, args, sessionId) {
15874
15881
  throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
15875
15882
  const useWorktree = args.worktree !== false;
15876
15883
  const usePlanApproval = args.plan_approval === true;
15884
+ log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`);
15877
15885
  let worktreeDir = null;
15878
15886
  let worktreeBranch = null;
15879
15887
  if (useWorktree) {
15880
15888
  const worktreeName = `ensemble-${teamInfo.teamName}-${args.name}`;
15881
15889
  try {
15882
- const result = await deps.client.worktree.create({ worktreeCreateInput: { name: worktreeName } });
15890
+ log(`spawn:worktree:start name=${args.name}`);
15891
+ const result = await withTimeout(deps.client.worktree.create({ worktreeCreateInput: { name: worktreeName } }), getSpawnTimeout(), `worktree.create for "${args.name}"`);
15883
15892
  if (result.data) {
15884
15893
  worktreeDir = result.data.directory;
15885
15894
  worktreeBranch = result.data.branch;
15886
15895
  }
15887
- } catch {
15896
+ log(`spawn:worktree:done name=${args.name} dir=${worktreeDir}`);
15897
+ } catch (err) {
15898
+ log(`spawn:worktree:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`);
15888
15899
  try {
15889
15900
  await deps.client.tui.showToast({
15890
15901
  title: "Team",
@@ -15904,14 +15915,17 @@ async function executeTeamSpawn(deps, args, sessionId) {
15904
15915
  ] : undefined;
15905
15916
  let childSessionId;
15906
15917
  try {
15907
- const createResult = await deps.client.session.create({
15918
+ log(`spawn:session:start name=${args.name}`);
15919
+ const createResult = await withTimeout(deps.client.session.create({
15908
15920
  parentID: sessionId,
15909
15921
  title: `${args.name} (@${args.agent} teammate)`,
15910
15922
  ...permission ? { permission } : {},
15911
15923
  ...worktreeDir ? { directory: worktreeDir } : {}
15912
- });
15924
+ }), getSpawnTimeout(), `session.create for "${args.name}"`);
15913
15925
  childSessionId = createResult.data?.id;
15926
+ log(`spawn:session:done name=${args.name} sessionId=${childSessionId}`);
15914
15927
  } catch (err) {
15928
+ log(`spawn:session:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`);
15915
15929
  if (worktreeDir) {
15916
15930
  try {
15917
15931
  await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
@@ -15949,11 +15963,13 @@ async function executeTeamSpawn(deps, args, sessionId) {
15949
15963
  }
15950
15964
  const contextStr = context.join(`
15951
15965
  `);
15966
+ log(`spawn:promptAsync:fire name=${args.name} sessionId=${childSessionId}`);
15952
15967
  deps.client.session.promptAsync({
15953
15968
  sessionID: childSessionId,
15954
15969
  parts: [{ type: "text", text: contextStr }],
15955
15970
  agent: args.agent
15956
15971
  }).catch(() => {
15972
+ log(`spawn:promptAsync:failed name=${args.name} \u2014 rolling back`);
15957
15973
  try {
15958
15974
  deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId]);
15959
15975
  deps.registry.unregister(childSessionId);
@@ -15973,14 +15989,11 @@ async function executeTeamSpawn(deps, args, sessionId) {
15973
15989
  to: "lead",
15974
15990
  content: `Teammate "${args.name}" failed to start and was removed. You may retry the spawn.`
15975
15991
  });
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
- }
15980
15992
  } catch {}
15981
15993
  });
15982
15994
  const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : "";
15983
15995
  const planInfo = usePlanApproval ? " [plan mode \u2014 will send plan for approval]" : "";
15996
+ log(`spawn:done name=${args.name} sessionId=${childSessionId}`);
15984
15997
  return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`;
15985
15998
  }
15986
15999
 
@@ -16020,31 +16033,25 @@ async function executeTeamMessage(deps, args, sessionId) {
16020
16033
  content: messageText
16021
16034
  });
16022
16035
  const isToLead = args.to === "lead";
16023
- const MAX_LEAD_MSG = 500;
16024
- let deliveryText;
16025
- if (isToLead && messageText.length > MAX_LEAD_MSG) {
16026
- const truncated = messageText.slice(0, MAX_LEAD_MSG);
16027
- deliveryText = `[Team message from ${senderName}]: ${truncated}... (use team_results to read full message)`;
16028
- } else {
16029
- deliveryText = `[Team message from ${senderName}]: ${messageText}`;
16030
- }
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)`;
16036
+ if (isToLead) {
16037
+ log(`team_message:wake-lead from=${senderName} recipientSession=${recipientSessionId}`);
16038
+ deps.client.session.promptAsync({
16039
+ sessionID: recipientSessionId,
16040
+ parts: [{ type: "text", text: `[System: New team message from ${senderName}]` }]
16041
+ }).catch((err) => {
16042
+ log(`team_message:wake-lead:failed from=${senderName} err=${err instanceof Error ? err.message : String(err)}`);
16043
+ });
16044
+ return `Message sent to ${args.to}.`;
16041
16045
  }
16046
+ const deliveryText = `[Team message from ${senderName}]: ${messageText}`;
16042
16047
  deps.client.session.promptAsync({
16043
16048
  sessionID: recipientSessionId,
16044
16049
  parts: [{ type: "text", text: deliveryText }]
16045
16050
  }).then(() => {
16046
16051
  markDelivered(deps.db, msgId);
16047
- }).catch(() => {});
16052
+ }).catch((err) => {
16053
+ log(`team_message:deliver:failed to=${args.to} err=${err instanceof Error ? err.message : String(err)}`);
16054
+ });
16048
16055
  return `Message sent to ${args.to}.`;
16049
16056
  }
16050
16057
 
@@ -16080,7 +16087,9 @@ async function executeTeamBroadcast(deps, args, sessionId) {
16080
16087
  delivered++;
16081
16088
  if (delivered === 1)
16082
16089
  markDelivered(deps.db, msgId);
16083
- }).catch(() => {});
16090
+ }).catch((err) => {
16091
+ log(`team_broadcast:deliver:failed to=${recipient.name} err=${err instanceof Error ? err.message : String(err)}`);
16092
+ });
16084
16093
  }
16085
16094
  return `Broadcast sent to ${recipients.length} recipient${recipients.length !== 1 ? "s" : ""}.`;
16086
16095
  }
@@ -16498,17 +16507,29 @@ var plugin = async (input) => {
16498
16507
  const tracker = new DescendantTracker;
16499
16508
  const pluginTransport = input.client._client;
16500
16509
  const rawClient = new OpencodeClient({ client: pluginTransport });
16510
+ initLog(rawClient);
16501
16511
  const client3 = wrapThrowingClient(rawClient);
16502
16512
  const deps = { db, registry: registry2, tracker, client: client3, directory: input.directory };
16503
- const recovery = await recoverStaleMembers(db, client3);
16504
- if (recovery.interrupted > 0) {
16505
- const members = db.query("SELECT tm.team_id, tm.name, tm.session_id FROM team_member tm JOIN team t ON tm.team_id = t.id WHERE t.status = 'active'").all();
16506
- for (const m of members) {
16507
- registry2.register(m.team_id, m.name, m.session_id);
16508
- }
16513
+ if (!isWorktreeInstance(input.directory)) {
16514
+ log("init:recovery:start (main instance)");
16515
+ const recovery = await recoverStaleMembers(db, client3);
16516
+ if (recovery.interrupted > 0) {
16517
+ log(`init:recovery:interrupted=${recovery.interrupted}`);
16518
+ const members = db.query("SELECT tm.team_id, tm.name, tm.session_id FROM team_member tm JOIN team t ON tm.team_id = t.id WHERE t.status = 'active'").all();
16519
+ for (const m of members) {
16520
+ registry2.register(m.team_id, m.name, m.session_id);
16521
+ }
16522
+ }
16523
+ recoverUndeliveredMessages(db, client3, registry2).catch((err) => {
16524
+ log(`init:recover-messages:failed err=${err instanceof Error ? err.message : String(err)}`);
16525
+ });
16526
+ recoverOrphanedWorktrees(db, client3).catch((err) => {
16527
+ log(`init:recover-worktrees:failed err=${err instanceof Error ? err.message : String(err)}`);
16528
+ });
16529
+ log("init:recovery:done");
16530
+ } else {
16531
+ log(`init:skip-recovery (worktree instance: ${input.directory})`);
16509
16532
  }
16510
- recoverUndeliveredMessages(db, client3, registry2).catch(() => {});
16511
- recoverOrphanedWorktrees(db, client3).catch(() => {});
16512
16533
  const rateLimitEnv = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT;
16513
16534
  const rateLimitCapacity = rateLimitEnv === "0" ? 0 : parseInt(rateLimitEnv ?? "", 10) || DEFAULT_RATE_LIMIT_CAPACITY;
16514
16535
  const rateLimiter = new TokenBucket({
@@ -16559,7 +16580,19 @@ var plugin = async (input) => {
16559
16580
  await notifyWorkingProgress(client3, db, transition.teamId);
16560
16581
  }
16561
16582
  if (statusType === "idle") {
16562
- handleLeadIdleFlush(db, client3, sessionID).catch(() => {});
16583
+ const team = db.query("SELECT id FROM team WHERE lead_session_id = ? AND status = 'active'").get(sessionID);
16584
+ if (team) {
16585
+ const pending = db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND to_name = 'lead' AND delivered = 0").get(team.id);
16586
+ if (pending.c > 0) {
16587
+ log(`wake-lead: ${pending.c} pending messages, sending promptAsync`);
16588
+ client3.session.promptAsync({
16589
+ sessionID,
16590
+ parts: [{ type: "text", text: `[System: ${pending.c} new team message(s) available]` }]
16591
+ }).catch((err) => {
16592
+ log(`wake-lead:failed err=${err instanceof Error ? err.message : String(err)}`);
16593
+ });
16594
+ }
16595
+ }
16563
16596
  }
16564
16597
  }
16565
16598
  if (event.type === "session.created") {
@@ -16583,7 +16616,9 @@ var plugin = async (input) => {
16583
16616
  const teamInfo = findTeamBySession(db, registry2, input2.sessionID);
16584
16617
  if (!teamInfo)
16585
16618
  return;
16619
+ log(`system-prompt:transform role=${teamInfo.role} session=${input2.sessionID}`);
16586
16620
  const prompt = teamInfo.role === "lead" ? buildLeadSystemPrompt(db, teamInfo.teamId) : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown");
16621
+ log(`system-prompt:injected role=${teamInfo.role} len=${prompt.length}`);
16587
16622
  output.system.push(prompt);
16588
16623
  },
16589
16624
  "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",
3
+ "version": "0.6.4",
4
4
  "description": "Agent teams for OpenCode — parallel agents with peer-to-peer communication, shared tasks, and coordinated execution",
5
5
  "module": "src/index.ts",
6
6
  "main": "dist/index.js",
package/src/hooks.ts CHANGED
@@ -1,7 +1,5 @@
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"
5
3
 
6
4
  const TEAM_TOOL_PREFIX = "team_"
7
5
 
@@ -100,17 +98,3 @@ export function checkToolIsolation(
100
98
  throw new Error("Team tools are not available to sub-agents. Report findings to your parent teammate via your normal output.")
101
99
  }
102
100
  }
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
@@ -7,9 +7,11 @@ 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, handleLeadIdleFlush } from "./hooks"
10
+ import { isWorktreeInstance } from "./util"
11
+ import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation } from "./hooks"
11
12
  import { notifyTeamEvent, notifyWorkingProgress } from "./notify"
12
13
  import { buildLeadSystemPrompt, buildTeammateSystemPrompt, buildTeamCompactionContext } from "./system-prompt"
14
+ import { log, initLog } from "./log"
13
15
  import { findTeamBySession } from "./types"
14
16
  import { executeTeamCreate } from "./tools/team-create"
15
17
  import { executeTeamSpawn } from "./tools/team-spawn"
@@ -55,30 +57,36 @@ const plugin: Plugin = async (input) => {
55
57
  type V2Transport = ConstructorParameters<typeof OpencodeClient>[0] extends { client?: infer C } ? C : never
56
58
  const pluginTransport = (input.client as unknown as { _client: V2Transport })._client
57
59
  const rawClient = new OpencodeClient({ client: pluginTransport })
60
+ initLog(rawClient)
58
61
  const client = wrapThrowingClient(rawClient)
59
62
  const deps: ToolDeps = { db, registry, tracker, client, directory: input.directory }
60
63
 
61
- // Run recovery on init — mark stale busy members as error + abort orphaned sessions
62
- const recovery = await recoverStaleMembers(db, client)
63
- if (recovery.interrupted > 0) {
64
- // Rebuild registry from DB for recovered members
65
- const members = db.query(
66
- "SELECT tm.team_id, tm.name, tm.session_id FROM team_member tm JOIN team t ON tm.team_id = t.id WHERE t.status = 'active'"
67
- ).all() as Array<{ team_id: string; name: string; session_id: string }>
68
- for (const m of members) {
69
- registry.register(m.team_id, m.name, m.session_id)
64
+ // Recovery only runs for the main project instance — NOT for teammate worktree instances.
65
+ // Worktree instances are created during session.create. Running recovery there makes HTTP
66
+ // calls back to the server, which deadlocks because the server is still handling session.create.
67
+ if (!isWorktreeInstance(input.directory)) {
68
+ log("init:recovery:start (main instance)")
69
+ const recovery = await recoverStaleMembers(db, client)
70
+ if (recovery.interrupted > 0) {
71
+ log(`init:recovery:interrupted=${recovery.interrupted}`)
72
+ const members = db.query(
73
+ "SELECT tm.team_id, tm.name, tm.session_id FROM team_member tm JOIN team t ON tm.team_id = t.id WHERE t.status = 'active'"
74
+ ).all() as Array<{ team_id: string; name: string; session_id: string }>
75
+ for (const m of members) {
76
+ registry.register(m.team_id, m.name, m.session_id)
77
+ }
70
78
  }
71
- }
72
79
 
73
- // Redeliver undelivered messages from previous sessions
74
- recoverUndeliveredMessages(db, client, registry).catch(() => {
75
- // Best effort — don't block plugin init
76
- })
77
-
78
- // Clean up orphaned worktrees from crashed teams
79
- recoverOrphanedWorktrees(db, client).catch(() => {
80
- // Best effort — don't block plugin init
81
- })
80
+ recoverUndeliveredMessages(db, client, registry).catch((err) => {
81
+ log(`init:recover-messages:failed err=${err instanceof Error ? err.message : String(err)}`)
82
+ })
83
+ recoverOrphanedWorktrees(db, client).catch((err) => {
84
+ log(`init:recover-worktrees:failed err=${err instanceof Error ? err.message : String(err)}`)
85
+ })
86
+ log("init:recovery:done")
87
+ } else {
88
+ log(`init:skip-recovery (worktree instance: ${input.directory})`)
89
+ }
82
90
 
83
91
  // Initialize rate limiter
84
92
  // OPENCODE_ENSEMBLE_RATE_LIMIT=0 disables it entirely
@@ -141,9 +149,22 @@ const plugin: Plugin = async (input) => {
141
149
  await notifyWorkingProgress(client, db, transition.teamId)
142
150
  }
143
151
 
144
- // Flush pending messages to the lead when their session goes idle
152
+ // Wake the lead when it goes idle and has pending messages.
153
+ // The system prompt transform delivers the actual message content.
145
154
  if (statusType === "idle") {
146
- handleLeadIdleFlush(db, client, sessionID).catch(() => { /* best effort */ })
155
+ const team = db.query("SELECT id FROM team WHERE lead_session_id = ? AND status = 'active'").get(sessionID) as { id: string } | null
156
+ if (team) {
157
+ const pending = db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND to_name = 'lead' AND delivered = 0").get(team.id) as { c: number }
158
+ if (pending.c > 0) {
159
+ log(`wake-lead: ${pending.c} pending messages, sending promptAsync`)
160
+ client.session.promptAsync({
161
+ sessionID,
162
+ parts: [{ type: "text", text: `[System: ${pending.c} new team message(s) available]` }],
163
+ }).catch((err) => {
164
+ log(`wake-lead:failed err=${err instanceof Error ? err.message : String(err)}`)
165
+ })
166
+ }
167
+ }
147
168
  }
148
169
  }
149
170
 
@@ -171,9 +192,11 @@ const plugin: Plugin = async (input) => {
171
192
  if (!input.sessionID) return
172
193
  const teamInfo = findTeamBySession(db, registry, input.sessionID)
173
194
  if (!teamInfo) return
195
+ log(`system-prompt:transform role=${teamInfo.role} session=${input.sessionID}`)
174
196
  const prompt = teamInfo.role === "lead"
175
197
  ? buildLeadSystemPrompt(db, teamInfo.teamId)
176
198
  : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown")
199
+ log(`system-prompt:injected role=${teamInfo.role} len=${prompt.length}`)
177
200
  output.system.push(prompt)
178
201
  },
179
202
 
package/src/log.ts ADDED
@@ -0,0 +1,13 @@
1
+ /** Plugin logger using the v2 SDK's structured logging API (flat params, no body wrapper). */
2
+ let _client: { app: { log: (params: { service: string; level: string; message: string }) => Promise<unknown> } } | null = null
3
+
4
+ /** Set the SDK client for logging. Called once during plugin init with the raw v2 client. */
5
+ export function initLog(client: unknown): void {
6
+ _client = client as typeof _client
7
+ }
8
+
9
+ /** Log a message via the SDK's app.log API so it appears in OpenCode's structured logs. */
10
+ export function log(msg: string): void {
11
+ if (!_client) return
12
+ _client.app.log({ service: "ensemble", level: "info", message: msg }).catch(() => {})
13
+ }
package/src/messaging.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import type { Database } from "bun:sqlite"
2
- import type { PluginClient } from "./types"
3
2
  import { generateId } from "./util"
4
3
 
5
4
  const MAX_CONTENT_BYTES = 10 * 1024 // 10KB
@@ -77,40 +76,3 @@ export function getUndeliveredMessages(db: Database, teamId: string): MessageRow
77
76
  export function markDelivered(db: Database, messageId: string): void {
78
77
  db.run("UPDATE team_message SET delivered = 1 WHERE id = ?", [messageId])
79
78
  }
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/recovery.ts CHANGED
@@ -98,7 +98,8 @@ export async function recoverUndeliveredMessages(
98
98
  let recipientSessionId: string | undefined
99
99
 
100
100
  if (msg.to_name === "lead") {
101
- recipientSessionId = team.lead_session_id
101
+ // Skip lead-bound messages — the system prompt transform delivers them
102
+ continue
102
103
  } else if (msg.to_name) {
103
104
  const entry = registry.getByName(team.id, msg.to_name)
104
105
  recipientSessionId = entry?.sessionId
@@ -1,4 +1,5 @@
1
1
  import type { Database } from "bun:sqlite"
2
+ import { markDelivered } from "./messaging"
2
3
 
3
4
  /** Status display mapping from DB status to human-readable label. */
4
5
  const STATUS_DISPLAY: Record<string, string> = {
@@ -40,16 +41,43 @@ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
40
41
  ? `Teammates: ${memberList}`
41
42
  : "Teammates: none"
42
43
 
43
- return [
44
+ const pendingMessages = db.query(
45
+ "SELECT id, from_name, content FROM team_message WHERE team_id = ? AND to_name = 'lead' AND delivered = 0 ORDER BY time_created ASC"
46
+ ).all(teamId) as Array<{ id: string; from_name: string; content: string }>
47
+
48
+ const lines = [
44
49
  `You are leading team "${team.name}" with ${members.length} active teammates.`,
45
50
  teammateLine,
46
51
  `Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`,
52
+ ]
53
+
54
+ if (pendingMessages.length > 0) {
55
+ lines.push("", "--- Team Messages ---")
56
+ const MAX_MSG = 500
57
+ for (const msg of pendingMessages) {
58
+ if (msg.content.length > MAX_MSG) {
59
+ lines.push(`[From ${msg.from_name}]: ${msg.content.slice(0, MAX_MSG)}... (use team_results to read full message)`)
60
+ } else {
61
+ lines.push(`[From ${msg.from_name}]: ${msg.content}`)
62
+ }
63
+ markDelivered(db, msg.id)
64
+ }
65
+ lines.push("--- End Messages ---")
66
+ }
67
+
68
+ lines.push(
69
+ "",
70
+ "CRITICAL: Spawn teammates ONE AT A TIME. Send only ONE team_spawn call per response.",
71
+ "Wait for the tool result before spawning the next teammate.",
72
+ "Multiple team_spawn calls in a single response will cause timeouts.",
47
73
  "",
48
74
  "Teammates work asynchronously and message you when done.",
49
75
  "Do NOT poll team_status or team_tasks_list repeatedly — wait for messages.",
50
- "After spawning teammates, tell the user what you've set up and wait.",
76
+ "After spawning all teammates, tell the user what you've set up and wait.",
51
77
  "When all teammates finish, summarize results and suggest next steps.",
52
- ].join("\n")
78
+ )
79
+
80
+ return lines.join("\n")
53
81
  }
54
82
 
55
83
  /**
@@ -1,6 +1,7 @@
1
1
  import type { ToolDeps } from "../types"
2
2
  import { findTeamBySession } from "../types"
3
3
  import { broadcastMessage, markDelivered } from "../messaging"
4
+ import { log } from "../log"
4
5
 
5
6
  /**
6
7
  * Execute the team_broadcast tool. Sends a message to all team members + lead (excluding sender).
@@ -48,7 +49,9 @@ export async function executeTeamBroadcast(
48
49
  }).then(() => {
49
50
  delivered++
50
51
  if (delivered === 1) markDelivered(deps.db, msgId)
51
- }).catch(() => { /* partial failure is expected */ })
52
+ }).catch((err) => {
53
+ log(`team_broadcast:deliver:failed to=${recipient.name} err=${err instanceof Error ? err.message : String(err)}`)
54
+ })
52
55
  }
53
56
 
54
57
  return `Broadcast sent to ${recipients.length} recipient${recipients.length !== 1 ? "s" : ""}.`
@@ -1,6 +1,7 @@
1
1
  import type { ToolDeps } from "../types"
2
2
  import { findTeamBySession, resolveRecipientSession } from "../types"
3
3
  import { sendMessage, markDelivered } from "../messaging"
4
+ import { log } from "../log"
4
5
 
5
6
  /**
6
7
  * Execute the team_message tool. Sends a direct message to a teammate or lead.
@@ -56,43 +57,33 @@ export async function executeTeamMessage(
56
57
  content: messageText,
57
58
  })
58
59
 
59
- // Deliver via promptAsync — truncate lead-bound messages over 500 chars
60
60
  const isToLead = args.to === "lead"
61
- const MAX_LEAD_MSG = 500
62
- let deliveryText: string
63
- if (isToLead && messageText.length > MAX_LEAD_MSG) {
64
- const truncated = messageText.slice(0, MAX_LEAD_MSG)
65
- deliveryText = `[Team message from ${senderName}]: ${truncated}... (use team_results to read full message)`
66
- } else {
67
- deliveryText = `[Team message from ${senderName}]: ${messageText}`
68
- }
69
-
70
- // Check if recipient is busy — if so, queue the message for later delivery.
71
- // TOCTOU: status check and promptAsync are not atomic. A recipient can transition
72
- // idle→busy between the check and delivery. This is accepted as benign — the idle-flush
73
- // backstop catches anything that slips through, so no messages are lost.
74
- let recipientBusy = false
75
- try {
76
- const statusResult = await deps.client.session.status()
77
- const sessionStatus = statusResult.data?.[recipientSessionId]
78
- if (sessionStatus?.type === "busy") {
79
- recipientBusy = true
80
- }
81
- } catch { /* status check failed — deliver anyway as best effort */ }
82
61
 
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)`
62
+ // Lead-bound messages: store in DB, then wake the lead with a minimal promptAsync.
63
+ // The system prompt transform delivers the actual message content on the lead's next turn.
64
+ // This runs in the teammate's worktree instance — the event hook can't wake the lead
65
+ // because session.idle events are scoped per-instance.
66
+ if (isToLead) {
67
+ log(`team_message:wake-lead from=${senderName} recipientSession=${recipientSessionId}`)
68
+ deps.client.session.promptAsync({
69
+ sessionID: recipientSessionId,
70
+ parts: [{ type: "text", text: `[System: New team message from ${senderName}]` }],
71
+ }).catch((err) => {
72
+ log(`team_message:wake-lead:failed from=${senderName} err=${err instanceof Error ? err.message : String(err)}`)
73
+ })
74
+ return `Message sent to ${args.to}.`
86
75
  }
87
76
 
88
- // Fire-and-forget: message is already persisted in DB. If delivery fails,
89
- // the idle-flush backstop will redeliver it.
77
+ // For member-to-member messages, fire-and-forget delivery is safe.
78
+ const deliveryText = `[Team message from ${senderName}]: ${messageText}`
90
79
  deps.client.session.promptAsync({
91
80
  sessionID: recipientSessionId,
92
81
  parts: [{ type: "text", text: deliveryText }],
93
82
  }).then(() => {
94
83
  markDelivered(deps.db, msgId)
95
- }).catch(() => { /* message stays delivered=0, idle-flush will retry */ })
84
+ }).catch((err) => {
85
+ log(`team_message:deliver:failed to=${args.to} err=${err instanceof Error ? err.message : String(err)}`)
86
+ })
96
87
 
97
88
  return `Message sent to ${args.to}.`
98
89
  }
@@ -2,7 +2,23 @@ import type { ToolDeps, PermissionRule } from "../types"
2
2
  import { validateMemberName } from "../util"
3
3
  import { findTeamBySession } from "../types"
4
4
  import { sendMessage } from "../messaging"
5
- import { handleLeadIdleFlush } from "../hooks"
5
+ import { log } from "../log"
6
+
7
+ /** Timeout for worktree.create and session.create to prevent hanging on git lock contention. */
8
+ function getSpawnTimeout(): number {
9
+ return Number(process.env.SPAWN_TIMEOUT_MS) || 120_000
10
+ }
11
+
12
+ /** Race a promise against a timeout. Throws if the timeout fires first. Cleans up timer on resolution. */
13
+ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
14
+ let timer: ReturnType<typeof setTimeout>
15
+ return Promise.race([
16
+ promise.finally(() => clearTimeout(timer)),
17
+ new Promise<never>((_, reject) => {
18
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
19
+ }),
20
+ ])
21
+ }
6
22
 
7
23
  /**
8
24
  * Execute the team_spawn tool. Creates a child session and starts a teammate.
@@ -30,6 +46,8 @@ export async function executeTeamSpawn(
30
46
  const useWorktree = args.worktree !== false
31
47
  const usePlanApproval = args.plan_approval === true
32
48
 
49
+ log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`)
50
+
33
51
  // Create worktree if enabled
34
52
  let worktreeDir: string | null = null
35
53
  let worktreeBranch: string | null = null
@@ -37,13 +55,18 @@ export async function executeTeamSpawn(
37
55
  if (useWorktree) {
38
56
  const worktreeName = `ensemble-${teamInfo.teamName}-${args.name}`
39
57
  try {
40
- const result = await deps.client.worktree.create({ worktreeCreateInput: { name: worktreeName } })
58
+ log(`spawn:worktree:start name=${args.name}`)
59
+ const result = await withTimeout(
60
+ deps.client.worktree.create({ worktreeCreateInput: { name: worktreeName } }),
61
+ getSpawnTimeout(), `worktree.create for "${args.name}"`
62
+ )
41
63
  if (result.data) {
42
64
  worktreeDir = result.data.directory
43
65
  worktreeBranch = result.data.branch
44
66
  }
45
- } catch {
46
- // Worktree creation failed — fall back to shared directory with a warning
67
+ log(`spawn:worktree:done name=${args.name} dir=${worktreeDir}`)
68
+ } catch (err) {
69
+ log(`spawn:worktree:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`)
47
70
  try {
48
71
  await deps.client.tui.showToast({
49
72
  title: "Team",
@@ -71,14 +94,20 @@ export async function executeTeamSpawn(
71
94
  // Create child session — use worktree directory if available
72
95
  let childSessionId: string | undefined
73
96
  try {
74
- const createResult = await deps.client.session.create({
75
- parentID: sessionId,
76
- title: `${args.name} (@${args.agent} teammate)`,
77
- ...(permission ? { permission } : {}),
78
- ...(worktreeDir ? { directory: worktreeDir } : {}),
79
- })
97
+ log(`spawn:session:start name=${args.name}`)
98
+ const createResult = await withTimeout(
99
+ deps.client.session.create({
100
+ parentID: sessionId,
101
+ title: `${args.name} (@${args.agent} teammate)`,
102
+ ...(permission ? { permission } : {}),
103
+ ...(worktreeDir ? { directory: worktreeDir } : {}),
104
+ }),
105
+ getSpawnTimeout(), `session.create for "${args.name}"`
106
+ )
80
107
  childSessionId = createResult.data?.id
108
+ log(`spawn:session:done name=${args.name} sessionId=${childSessionId}`)
81
109
  } catch (err) {
110
+ log(`spawn:session:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`)
82
111
  // Rollback worktree if session creation failed
83
112
  if (worktreeDir) {
84
113
  try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
@@ -159,15 +188,13 @@ export async function executeTeamSpawn(
159
188
  const contextStr = context.join("\n")
160
189
 
161
190
  // 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.
191
+ log(`spawn:promptAsync:fire name=${args.name} sessionId=${childSessionId}`)
166
192
  deps.client.session.promptAsync({
167
193
  sessionID: childSessionId,
168
194
  parts: [{ type: "text", text: contextStr }],
169
195
  agent: args.agent,
170
196
  }).catch(() => {
197
+ log(`spawn:promptAsync:failed name=${args.name} — rolling back`)
171
198
  try {
172
199
  // Async rollback: clean up DB (by session_id to avoid deleting a re-spawned member with the same name), registry, session, and worktree
173
200
  deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId])
@@ -184,21 +211,18 @@ export async function executeTeamSpawn(
184
211
  duration: 5000,
185
212
  }).catch(() => { /* TUI may not be available */ })
186
213
  // Notify the lead model so it can react (retry, adjust plan, etc.)
214
+ // Message delivered via system prompt transform on the lead's next turn.
187
215
  sendMessage(deps.db, {
188
216
  teamId: teamInfo.teamId,
189
217
  from: "system",
190
218
  to: "lead",
191
219
  content: `Teammate "${args.name}" failed to start and was removed. You may retry the spawn.`,
192
220
  })
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
221
  } catch { /* rollback failed — watchdog will clean up stale member */ }
199
222
  })
200
223
 
201
224
  const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : ""
202
225
  const planInfo = usePlanApproval ? " [plan mode — will send plan for approval]" : ""
226
+ log(`spawn:done name=${args.name} sessionId=${childSessionId}`)
203
227
  return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
204
228
  }
package/src/util.ts CHANGED
@@ -23,6 +23,14 @@ export function validateTeamName(name: string): string | undefined {
23
23
  return undefined
24
24
  }
25
25
 
26
+ /**
27
+ * Detect if the plugin is loading inside a teammate's worktree instance.
28
+ * Worktree instances should skip recovery to avoid deadlocking the server.
29
+ */
30
+ export function isWorktreeInstance(directory: string): boolean {
31
+ return directory.includes("/worktree/") && directory.includes("/ensemble-")
32
+ }
33
+
26
34
  /**
27
35
  * Validate a member name. Same rules as team name, plus "lead" is reserved.
28
36
  * Returns an error message string if invalid, undefined if valid.