@hueyexe/opencode-ensemble 0.6.2 → 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
@@ -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
  }
@@ -15444,6 +15446,9 @@ function validateTeamName(name) {
15444
15446
  return "Team name must be lowercase alphanumeric with hyphens only";
15445
15447
  return;
15446
15448
  }
15449
+ function isWorktreeInstance(directory) {
15450
+ return directory.includes("/worktree/") && directory.includes("/ensemble-");
15451
+ }
15447
15452
  function validateMemberName(name) {
15448
15453
  if (name.toLowerCase() === "lead")
15449
15454
  return `Name "lead" is reserved`;
@@ -15478,30 +15483,6 @@ function getUndeliveredMessages(db, teamId) {
15478
15483
  function markDelivered(db, messageId) {
15479
15484
  db.run("UPDATE team_message SET delivered = 1 WHERE id = ?", [messageId]);
15480
15485
  }
15481
- async function flushPendingMessages(db, client3, teamId, leadSessionId) {
15482
- const pending = db.query("SELECT * FROM team_message WHERE team_id = ? AND delivered = 0 AND to_name = 'lead' ORDER BY time_created ASC").all(teamId);
15483
- if (pending.length === 0)
15484
- return 0;
15485
- const MAX_LEAD_MSG = 500;
15486
- const parts = pending.map((msg) => {
15487
- if (msg.content.length > MAX_LEAD_MSG) {
15488
- return `[Team message from ${msg.from_name}]: ${msg.content.slice(0, MAX_LEAD_MSG)}... (use team_results to read full message)`;
15489
- }
15490
- return `[Team message from ${msg.from_name}]: ${msg.content}`;
15491
- });
15492
- try {
15493
- await client3.session.promptAsync({
15494
- sessionID: leadSessionId,
15495
- parts: [{ type: "text", text: parts.join(`
15496
- `) }]
15497
- });
15498
- for (const msg of pending)
15499
- markDelivered(db, msg.id);
15500
- return pending.length;
15501
- } catch {
15502
- return 0;
15503
- }
15504
- }
15505
15486
 
15506
15487
  // src/recovery.ts
15507
15488
  async function recoverStaleMembers(db, client3) {
@@ -15550,7 +15531,7 @@ async function recoverUndeliveredMessages(db, client3, registry2) {
15550
15531
  for (const msg of messages) {
15551
15532
  let recipientSessionId;
15552
15533
  if (msg.to_name === "lead") {
15553
- recipientSessionId = team.lead_session_id;
15534
+ continue;
15554
15535
  } else if (msg.to_name) {
15555
15536
  const entry = registry2.getByName(team.id, msg.to_name);
15556
15537
  recipientSessionId = entry?.sessionId;
@@ -15611,6 +15592,9 @@ class MemberRegistry {
15611
15592
  isTeamSession(sessionId) {
15612
15593
  return this.bySession.has(sessionId);
15613
15594
  }
15595
+ allSessionIds() {
15596
+ return new Set(this.bySession.keys());
15597
+ }
15614
15598
  }
15615
15599
  var DEFAULT_MAX_DEPTH = 10;
15616
15600
 
@@ -15680,20 +15664,11 @@ function checkToolIsolation(registry2, tracker, toolName, sessionId) {
15680
15664
  return;
15681
15665
  if (registry2.isTeamSession(sessionId))
15682
15666
  return;
15683
- const allTeamSessions = new Set;
15684
- for (const entry of registry2["bySession"].keys()) {
15685
- allTeamSessions.add(entry);
15686
- }
15667
+ const allTeamSessions = registry2.allSessionIds();
15687
15668
  if (allTeamSessions.size > 0 && tracker.isDescendantOf(sessionId, allTeamSessions)) {
15688
15669
  throw new Error("Team tools are not available to sub-agents. Report findings to your parent teammate via your normal output.");
15689
15670
  }
15690
15671
  }
15691
- async function handleLeadIdleFlush(db, client3, sessionId) {
15692
- const team = db.query("SELECT id FROM team WHERE lead_session_id = ? AND status = 'active'").get(sessionId);
15693
- if (!team)
15694
- return 0;
15695
- return flushPendingMessages(db, client3, team.id, sessionId);
15696
- }
15697
15672
 
15698
15673
  // src/notify.ts
15699
15674
  var TOAST_CONFIG = {
@@ -15771,21 +15746,32 @@ function buildLeadSystemPrompt(db, teamId) {
15771
15746
  for (const row of taskCounts) {
15772
15747
  countMap[row.status] = row.count;
15773
15748
  }
15774
- const completed = countMap["completed"] ?? 0;
15775
- const inProgress = countMap["in_progress"] ?? 0;
15776
- const pending = countMap["pending"] ?? 0;
15749
+ const completed = countMap.completed ?? 0;
15750
+ const inProgress = countMap.in_progress ?? 0;
15751
+ const pending = countMap.pending ?? 0;
15777
15752
  const memberList = members.map((m) => `${m.name} [${STATUS_DISPLAY[m.status] ?? m.status}]`).join(", ");
15778
15753
  const teammateLine = members.length > 0 ? `Teammates: ${memberList}` : "Teammates: none";
15779
- 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 = [
15780
15756
  `You are leading team "${team.name}" with ${members.length} active teammates.`,
15781
15757
  teammateLine,
15782
- `Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`,
15783
- "",
15784
- "Teammates work asynchronously and message you when done.",
15785
- "Do NOT poll team_status or team_tasks_list repeatedly \u2014 wait for messages.",
15786
- "After spawning teammates, tell the user what you've set up and wait.",
15787
- "When all teammates finish, summarize results and suggest next steps."
15788
- ].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(`
15789
15775
  `);
15790
15776
  }
15791
15777
  function buildTeammateSystemPrompt(db, teamId, memberName) {
@@ -15804,9 +15790,9 @@ function buildTeamCompactionContext(db, teamId, role, memberName) {
15804
15790
  for (const row of taskCounts) {
15805
15791
  countMap[row.status] = row.count;
15806
15792
  }
15807
- const completed = countMap["completed"] ?? 0;
15808
- const inProgress = countMap["in_progress"] ?? 0;
15809
- const pending = countMap["pending"] ?? 0;
15793
+ const completed = countMap.completed ?? 0;
15794
+ const inProgress = countMap.in_progress ?? 0;
15795
+ const pending = countMap.pending ?? 0;
15810
15796
  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
15797
  const memberList = members.map((m) => `${m.name} (${STATUS_DISPLAY[m.status] ?? m.status})`).join(", ");
15812
15798
  const membersLine = members.length > 0 ? `Members: ${memberList}` : "Members: none";
@@ -15818,6 +15804,17 @@ function buildTeamCompactionContext(db, teamId, role, memberName) {
15818
15804
  `);
15819
15805
  }
15820
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
+
15821
15818
  // src/types.ts
15822
15819
  function findTeamBySession(db, registry2, sessionId) {
15823
15820
  const entry = registry2.getBySession(sessionId);
@@ -15858,6 +15855,18 @@ async function executeTeamCreate(deps, args, sessionId) {
15858
15855
  }
15859
15856
 
15860
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
+ }
15861
15870
  async function executeTeamSpawn(deps, args, sessionId) {
15862
15871
  const nameError = validateMemberName(args.name);
15863
15872
  if (nameError)
@@ -15872,17 +15881,21 @@ async function executeTeamSpawn(deps, args, sessionId) {
15872
15881
  throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
15873
15882
  const useWorktree = args.worktree !== false;
15874
15883
  const usePlanApproval = args.plan_approval === true;
15884
+ log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`);
15875
15885
  let worktreeDir = null;
15876
15886
  let worktreeBranch = null;
15877
15887
  if (useWorktree) {
15878
15888
  const worktreeName = `ensemble-${teamInfo.teamName}-${args.name}`;
15879
15889
  try {
15880
- 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}"`);
15881
15892
  if (result.data) {
15882
15893
  worktreeDir = result.data.directory;
15883
15894
  worktreeBranch = result.data.branch;
15884
15895
  }
15885
- } 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)}`);
15886
15899
  try {
15887
15900
  await deps.client.tui.showToast({
15888
15901
  title: "Team",
@@ -15902,14 +15915,17 @@ async function executeTeamSpawn(deps, args, sessionId) {
15902
15915
  ] : undefined;
15903
15916
  let childSessionId;
15904
15917
  try {
15905
- const createResult = await deps.client.session.create({
15918
+ log(`spawn:session:start name=${args.name}`);
15919
+ const createResult = await withTimeout(deps.client.session.create({
15906
15920
  parentID: sessionId,
15907
15921
  title: `${args.name} (@${args.agent} teammate)`,
15908
15922
  ...permission ? { permission } : {},
15909
15923
  ...worktreeDir ? { directory: worktreeDir } : {}
15910
- });
15924
+ }), getSpawnTimeout(), `session.create for "${args.name}"`);
15911
15925
  childSessionId = createResult.data?.id;
15926
+ log(`spawn:session:done name=${args.name} sessionId=${childSessionId}`);
15912
15927
  } catch (err) {
15928
+ log(`spawn:session:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`);
15913
15929
  if (worktreeDir) {
15914
15930
  try {
15915
15931
  await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
@@ -15947,27 +15963,37 @@ async function executeTeamSpawn(deps, args, sessionId) {
15947
15963
  }
15948
15964
  const contextStr = context.join(`
15949
15965
  `);
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);
15966
+ log(`spawn:promptAsync:fire name=${args.name} sessionId=${childSessionId}`);
15967
+ deps.client.session.promptAsync({
15968
+ sessionID: childSessionId,
15969
+ parts: [{ type: "text", text: contextStr }],
15970
+ agent: args.agent
15971
+ }).catch(() => {
15972
+ log(`spawn:promptAsync:failed name=${args.name} \u2014 rolling back`);
15959
15973
  try {
15960
- await deps.client.session.abort({ sessionID: childSessionId });
15974
+ deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId]);
15975
+ deps.registry.unregister(childSessionId);
15976
+ deps.client.session.abort({ sessionID: childSessionId }).catch(() => {});
15977
+ if (worktreeDir) {
15978
+ deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => {});
15979
+ }
15980
+ deps.client.tui.showToast({
15981
+ title: "Team",
15982
+ message: `Teammate "${args.name}" failed to start and was removed`,
15983
+ variant: "error",
15984
+ duration: 5000
15985
+ }).catch(() => {});
15986
+ sendMessage(deps.db, {
15987
+ teamId: teamInfo.teamId,
15988
+ from: "system",
15989
+ to: "lead",
15990
+ content: `Teammate "${args.name}" failed to start and was removed. You may retry the spawn.`
15991
+ });
15961
15992
  } 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
- }
15993
+ });
15969
15994
  const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : "";
15970
15995
  const planInfo = usePlanApproval ? " [plan mode \u2014 will send plan for approval]" : "";
15996
+ log(`spawn:done name=${args.name} sessionId=${childSessionId}`);
15971
15997
  return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`;
15972
15998
  }
15973
15999
 
@@ -15976,7 +16002,7 @@ async function executeTeamMessage(deps, args, sessionId) {
15976
16002
  const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
15977
16003
  if (!teamInfo)
15978
16004
  throw new Error("This session is not in a team. Use team_create first.");
15979
- const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName;
16005
+ const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
15980
16006
  const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to);
15981
16007
  if (!recipientSessionId)
15982
16008
  throw new Error(`Recipient "${args.to}" not found in team "${teamInfo.teamName}"`);
@@ -16007,30 +16033,25 @@ async function executeTeamMessage(deps, args, sessionId) {
16007
16033
  content: messageText
16008
16034
  });
16009
16035
  const isToLead = args.to === "lead";
16010
- const MAX_LEAD_MSG = 500;
16011
- let deliveryText;
16012
- if (isToLead && messageText.length > MAX_LEAD_MSG) {
16013
- const truncated = messageText.slice(0, MAX_LEAD_MSG);
16014
- deliveryText = `[Team message from ${senderName}]: ${truncated}... (use team_results to read full message)`;
16015
- } else {
16016
- deliveryText = `[Team message from ${senderName}]: ${messageText}`;
16017
- }
16018
- let recipientBusy = false;
16019
- try {
16020
- const statusResult = await deps.client.session.status();
16021
- const sessionStatus = statusResult.data?.[recipientSessionId];
16022
- if (sessionStatus?.type === "busy") {
16023
- recipientBusy = true;
16024
- }
16025
- } catch {}
16026
- if (recipientBusy) {
16027
- return `Message sent to ${args.to}. (queued \u2014 recipient is busy)`;
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}.`;
16028
16045
  }
16029
- await deps.client.session.promptAsync({
16046
+ const deliveryText = `[Team message from ${senderName}]: ${messageText}`;
16047
+ deps.client.session.promptAsync({
16030
16048
  sessionID: recipientSessionId,
16031
16049
  parts: [{ type: "text", text: deliveryText }]
16050
+ }).then(() => {
16051
+ markDelivered(deps.db, msgId);
16052
+ }).catch((err) => {
16053
+ log(`team_message:deliver:failed to=${args.to} err=${err instanceof Error ? err.message : String(err)}`);
16032
16054
  });
16033
- markDelivered(deps.db, msgId);
16034
16055
  return `Message sent to ${args.to}.`;
16035
16056
  }
16036
16057
 
@@ -16039,7 +16060,7 @@ async function executeTeamBroadcast(deps, args, sessionId) {
16039
16060
  const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
16040
16061
  if (!teamInfo)
16041
16062
  throw new Error("This session is not in a team. Use team_create first.");
16042
- const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName;
16063
+ const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
16043
16064
  const msgId = broadcastMessage(deps.db, {
16044
16065
  teamId: teamInfo.teamId,
16045
16066
  from: senderName,
@@ -16059,18 +16080,18 @@ async function executeTeamBroadcast(deps, args, sessionId) {
16059
16080
  }
16060
16081
  let delivered = 0;
16061
16082
  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
- });
16083
+ deps.client.session.promptAsync({
16084
+ sessionID: recipient.sessionId,
16085
+ parts: [{ type: "text", text: `[Team broadcast from ${senderName}]: ${args.text}` }]
16086
+ }).then(() => {
16067
16087
  delivered++;
16068
- } catch {}
16069
- }
16070
- if (delivered > 0) {
16071
- markDelivered(deps.db, msgId);
16088
+ if (delivered === 1)
16089
+ markDelivered(deps.db, msgId);
16090
+ }).catch((err) => {
16091
+ log(`team_broadcast:deliver:failed to=${recipient.name} err=${err instanceof Error ? err.message : String(err)}`);
16092
+ });
16072
16093
  }
16073
- return `Broadcast sent to ${delivered} recipient${delivered !== 1 ? "s" : ""}.`;
16094
+ return `Broadcast sent to ${recipients.length} recipient${recipients.length !== 1 ? "s" : ""}.`;
16074
16095
  }
16075
16096
 
16076
16097
  // src/tools/team-shutdown.ts
@@ -16240,7 +16261,7 @@ async function executeTeamClaim(deps, args, sessionId) {
16240
16261
  const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
16241
16262
  if (!teamInfo)
16242
16263
  throw new Error("This session is not in a team.");
16243
- const claimerName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName;
16264
+ const claimerName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
16244
16265
  const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?").get(args.task_id, teamInfo.teamId);
16245
16266
  if (!task)
16246
16267
  throw new Error(`Task "${args.task_id}" not found`);
@@ -16484,18 +16505,31 @@ var plugin = async (input) => {
16484
16505
  const db = createDb(dbPath);
16485
16506
  const registry2 = new MemberRegistry;
16486
16507
  const tracker = new DescendantTracker;
16487
- const rawClient = new OpencodeClient({ client: input.client._client });
16508
+ const pluginTransport = input.client._client;
16509
+ const rawClient = new OpencodeClient({ client: pluginTransport });
16510
+ initLog(rawClient);
16488
16511
  const client3 = wrapThrowingClient(rawClient);
16489
16512
  const deps = { db, registry: registry2, tracker, client: client3, directory: input.directory };
16490
- const recovery = await recoverStaleMembers(db, client3);
16491
- if (recovery.interrupted > 0) {
16492
- 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();
16493
- for (const m of members) {
16494
- registry2.register(m.team_id, m.name, m.session_id);
16495
- }
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})`);
16496
16532
  }
16497
- recoverUndeliveredMessages(db, client3, registry2).catch(() => {});
16498
- recoverOrphanedWorktrees(db, client3).catch(() => {});
16499
16533
  const rateLimitEnv = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT;
16500
16534
  const rateLimitCapacity = rateLimitEnv === "0" ? 0 : parseInt(rateLimitEnv ?? "", 10) || DEFAULT_RATE_LIMIT_CAPACITY;
16501
16535
  const rateLimiter = new TokenBucket({
@@ -16546,7 +16580,19 @@ var plugin = async (input) => {
16546
16580
  await notifyWorkingProgress(client3, db, transition.teamId);
16547
16581
  }
16548
16582
  if (statusType === "idle") {
16549
- 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
+ }
16550
16596
  }
16551
16597
  }
16552
16598
  if (event.type === "session.created") {
@@ -16570,7 +16616,9 @@ var plugin = async (input) => {
16570
16616
  const teamInfo = findTeamBySession(db, registry2, input2.sessionID);
16571
16617
  if (!teamInfo)
16572
16618
  return;
16573
- const prompt = teamInfo.role === "lead" ? buildLeadSystemPrompt(db, teamInfo.teamId) : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName);
16619
+ log(`system-prompt:transform role=${teamInfo.role} session=${input2.sessionID}`);
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}`);
16574
16622
  output.system.push(prompt);
16575
16623
  },
16576
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.2",
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",
@@ -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,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
 
@@ -94,26 +92,9 @@ export function checkToolIsolation(
94
92
  if (registry.isTeamSession(sessionId)) return
95
93
 
96
94
  // 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
- }
95
+ const allTeamSessions = registry.allSessionIds()
101
96
 
102
97
  if (allTeamSessions.size > 0 && tracker.isDescendantOf(sessionId, allTeamSessions)) {
103
98
  throw new Error("Team tools are not available to sub-agents. Report findings to your parent teammate via your normal output.")
104
99
  }
105
100
  }
106
-
107
- /**
108
- * Check if a session going idle is a team lead with pending messages.
109
- * If so, flush them via promptAsync. Called from the event hook on idle transitions.
110
- */
111
- export async function handleLeadIdleFlush(
112
- db: Database,
113
- client: PluginClient,
114
- sessionId: string,
115
- ): Promise<number> {
116
- const team = db.query("SELECT id FROM team WHERE lead_session_id = ? AND status = 'active'").get(sessionId) as { id: string } | null
117
- if (!team) return 0
118
- return flushPendingMessages(db, client, team.id, sessionId)
119
- }
package/src/index.ts CHANGED
@@ -1,15 +1,17 @@
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, 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"
@@ -24,7 +26,7 @@ import { executeTeamClaim } from "./tools/team-claim"
24
26
  import { executeTeamResults } from "./tools/team-results"
25
27
  import { executeTeamStatus } from "./tools/team-status"
26
28
  import { executeTeamView } from "./tools/team-view"
27
- import type { ToolDeps, PluginClient } from "./types"
29
+ import type { ToolDeps, } from "./types"
28
30
  import { TokenBucket } from "./rate-limit"
29
31
  import { Watchdog } from "./watchdog"
30
32
 
@@ -49,35 +51,42 @@ const plugin: Plugin = async (input) => {
49
51
  const registry = new MemberRegistry()
50
52
  const tracker = new DescendantTracker()
51
53
 
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 })
54
+ // Extract the working HeyAPI transport from the plugin-provided v1 client and pass it
55
+ // to the v2 OpencodeClient. The plugin framework provides a v1 client which stores its
56
+ // HeyAPI transport as `_client` (underscore). The v2 constructor accepts it as `client`.
57
+ type V2Transport = ConstructorParameters<typeof OpencodeClient>[0] extends { client?: infer C } ? C : never
58
+ const pluginTransport = (input.client as unknown as { _client: V2Transport })._client
59
+ const rawClient = new OpencodeClient({ client: pluginTransport })
60
+ initLog(rawClient)
57
61
  const client = wrapThrowingClient(rawClient)
58
62
  const deps: ToolDeps = { db, registry, tracker, client, directory: input.directory }
59
63
 
60
- // Run recovery on init — mark stale busy members as error + abort orphaned sessions
61
- const recovery = await recoverStaleMembers(db, client)
62
- if (recovery.interrupted > 0) {
63
- // Rebuild registry from DB for recovered members
64
- const members = db.query(
65
- "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'"
66
- ).all() as Array<{ team_id: string; name: string; session_id: string }>
67
- for (const m of members) {
68
- 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
+ }
69
78
  }
70
- }
71
79
 
72
- // Redeliver undelivered messages from previous sessions
73
- recoverUndeliveredMessages(db, client, registry).catch(() => {
74
- // Best effort — don't block plugin init
75
- })
76
-
77
- // Clean up orphaned worktrees from crashed teams
78
- recoverOrphanedWorktrees(db, client).catch(() => {
79
- // Best effort — don't block plugin init
80
- })
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
+ }
81
90
 
82
91
  // Initialize rate limiter
83
92
  // OPENCODE_ENSEMBLE_RATE_LIMIT=0 disables it entirely
@@ -140,9 +149,22 @@ const plugin: Plugin = async (input) => {
140
149
  await notifyWorkingProgress(client, db, transition.teamId)
141
150
  }
142
151
 
143
- // 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.
144
154
  if (statusType === "idle") {
145
- 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
+ }
146
168
  }
147
169
  }
148
170
 
@@ -170,9 +192,11 @@ const plugin: Plugin = async (input) => {
170
192
  if (!input.sessionID) return
171
193
  const teamInfo = findTeamBySession(db, registry, input.sessionID)
172
194
  if (!teamInfo) return
195
+ log(`system-prompt:transform role=${teamInfo.role} session=${input.sessionID}`)
173
196
  const prompt = teamInfo.role === "lead"
174
197
  ? buildLeadSystemPrompt(db, teamInfo.teamId)
175
- : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName!)
198
+ : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown")
199
+ log(`system-prompt:injected role=${teamInfo.role} len=${prompt.length}`)
176
200
  output.system.push(prompt)
177
201
  },
178
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/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/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
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
@@ -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> = {
@@ -28,9 +29,9 @@ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
28
29
  countMap[row.status] = row.count
29
30
  }
30
31
 
31
- const completed = countMap["completed"] ?? 0
32
- const inProgress = countMap["in_progress"] ?? 0
33
- const pending = countMap["pending"] ?? 0
32
+ const completed = countMap.completed ?? 0
33
+ const inProgress = countMap.in_progress ?? 0
34
+ const pending = countMap.pending ?? 0
34
35
 
35
36
  const memberList = members
36
37
  .map((m) => `${m.name} [${STATUS_DISPLAY[m.status] ?? m.status}]`)
@@ -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
  /**
@@ -87,9 +115,9 @@ export function buildTeamCompactionContext(
87
115
  countMap[row.status] = row.count
88
116
  }
89
117
 
90
- const completed = countMap["completed"] ?? 0
91
- const inProgress = countMap["in_progress"] ?? 0
92
- const pending = countMap["pending"] ?? 0
118
+ const completed = countMap.completed ?? 0
119
+ const inProgress = countMap.in_progress ?? 0
120
+ const pending = countMap.pending ?? 0
93
121
 
94
122
  const roleLine = role === "lead"
95
123
  ? `[Team Context] You are the lead of team "${team.name}".`
@@ -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).
@@ -13,7 +14,7 @@ export async function executeTeamBroadcast(
13
14
  const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
14
15
  if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
15
16
 
16
- const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName!
17
+ const senderName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
17
18
 
18
19
  const msgId = broadcastMessage(deps.db, {
19
20
  teamId: teamInfo.teamId,
@@ -39,24 +40,19 @@ export async function executeTeamBroadcast(
39
40
  }
40
41
  }
41
42
 
42
- // Deliver to all recipients — partial failures logged but don't fail the broadcast
43
+ // Fire-and-forget: deliver to all recipients. Message is already persisted in DB.
43
44
  let delivered = 0
44
45
  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
- })
46
+ deps.client.session.promptAsync({
47
+ sessionID: recipient.sessionId,
48
+ parts: [{ type: "text", text: `[Team broadcast from ${senderName}]: ${args.text}` }],
49
+ }).then(() => {
50
50
  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)
51
+ if (delivered === 1) markDelivered(deps.db, msgId)
52
+ }).catch((err) => {
53
+ log(`team_broadcast:deliver:failed to=${recipient.name} err=${err instanceof Error ? err.message : String(err)}`)
54
+ })
59
55
  }
60
56
 
61
- return `Broadcast sent to ${delivered} recipient${delivered !== 1 ? "s" : ""}.`
57
+ return `Broadcast sent to ${recipients.length} recipient${recipients.length !== 1 ? "s" : ""}.`
62
58
  }
@@ -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
@@ -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.
@@ -14,7 +15,7 @@ export async function executeTeamMessage(
14
15
  const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
15
16
  if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
16
17
 
17
- const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName!
18
+ const senderName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
18
19
 
19
20
  const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to)
20
21
  if (!recipientSessionId) throw new Error(`Recipient "${args.to}" not found in team "${teamInfo.teamName}"`)
@@ -56,41 +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
- await deps.client.session.promptAsync({
77
+ // For member-to-member messages, fire-and-forget delivery is safe.
78
+ const deliveryText = `[Team message from ${senderName}]: ${messageText}`
79
+ deps.client.session.promptAsync({
89
80
  sessionID: recipientSessionId,
90
81
  parts: [{ type: "text", text: deliveryText }],
82
+ }).then(() => {
83
+ markDelivered(deps.db, msgId)
84
+ }).catch((err) => {
85
+ log(`team_message:deliver:failed to=${args.to} err=${err instanceof Error ? err.message : String(err)}`)
91
86
  })
92
87
 
93
- markDelivered(deps.db, msgId)
94
-
95
88
  return `Message sent to ${args.to}.`
96
89
  }
@@ -1,6 +1,24 @@
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 { 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
+ }
4
22
 
5
23
  /**
6
24
  * Execute the team_spawn tool. Creates a child session and starts a teammate.
@@ -28,6 +46,8 @@ export async function executeTeamSpawn(
28
46
  const useWorktree = args.worktree !== false
29
47
  const usePlanApproval = args.plan_approval === true
30
48
 
49
+ log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`)
50
+
31
51
  // Create worktree if enabled
32
52
  let worktreeDir: string | null = null
33
53
  let worktreeBranch: string | null = null
@@ -35,13 +55,18 @@ export async function executeTeamSpawn(
35
55
  if (useWorktree) {
36
56
  const worktreeName = `ensemble-${teamInfo.teamName}-${args.name}`
37
57
  try {
38
- 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
+ )
39
63
  if (result.data) {
40
64
  worktreeDir = result.data.directory
41
65
  worktreeBranch = result.data.branch
42
66
  }
43
- } catch {
44
- // 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)}`)
45
70
  try {
46
71
  await deps.client.tui.showToast({
47
72
  title: "Team",
@@ -69,14 +94,20 @@ export async function executeTeamSpawn(
69
94
  // Create child session — use worktree directory if available
70
95
  let childSessionId: string | undefined
71
96
  try {
72
- const createResult = await deps.client.session.create({
73
- parentID: sessionId,
74
- title: `${args.name} (@${args.agent} teammate)`,
75
- ...(permission ? { permission } : {}),
76
- ...(worktreeDir ? { directory: worktreeDir } : {}),
77
- })
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
+ )
78
107
  childSessionId = createResult.data?.id
108
+ log(`spawn:session:done name=${args.name} sessionId=${childSessionId}`)
79
109
  } catch (err) {
110
+ log(`spawn:session:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`)
80
111
  // Rollback worktree if session creation failed
81
112
  if (worktreeDir) {
82
113
  try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
@@ -156,25 +187,42 @@ export async function executeTeamSpawn(
156
187
 
157
188
  const contextStr = context.join("\n")
158
189
 
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
- }
190
+ // Fire-and-forget: send prompt to teammate session.
191
+ log(`spawn:promptAsync:fire name=${args.name} sessionId=${childSessionId}`)
192
+ deps.client.session.promptAsync({
193
+ sessionID: childSessionId,
194
+ parts: [{ type: "text", text: contextStr }],
195
+ agent: args.agent,
196
+ }).catch(() => {
197
+ log(`spawn:promptAsync:failed name=${args.name} — rolling back`)
198
+ try {
199
+ // Async rollback: clean up DB (by session_id to avoid deleting a re-spawned member with the same name), registry, session, and worktree
200
+ deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId])
201
+ deps.registry.unregister(childSessionId)
202
+ deps.client.session.abort({ sessionID: childSessionId }).catch(() => { /* best effort */ })
203
+ if (worktreeDir) {
204
+ deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => { /* best effort */ })
205
+ }
206
+ // Notify user that spawn failed so the lead doesn't think the teammate is active
207
+ deps.client.tui.showToast({
208
+ title: "Team",
209
+ message: `Teammate "${args.name}" failed to start and was removed`,
210
+ variant: "error",
211
+ duration: 5000,
212
+ }).catch(() => { /* TUI may not be available */ })
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.
215
+ sendMessage(deps.db, {
216
+ teamId: teamInfo.teamId,
217
+ from: "system",
218
+ to: "lead",
219
+ content: `Teammate "${args.name}" failed to start and was removed. You may retry the spawn.`,
220
+ })
221
+ } catch { /* rollback failed — watchdog will clean up stale member */ }
222
+ })
176
223
 
177
224
  const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : ""
178
225
  const planInfo = usePlanApproval ? " [plan mode — will send plan for approval]" : ""
226
+ log(`spawn:done name=${args.name} sessionId=${childSessionId}`)
179
227
  return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
180
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.