@hueyexe/opencode-ensemble 0.6.3 → 0.6.5

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
@@ -15366,7 +15366,8 @@ var MIGRATIONS = [
15366
15366
  ALTER TABLE team_member ADD COLUMN worktree_branch TEXT;`,
15367
15367
  `ALTER TABLE team_member ADD COLUMN plan_approval TEXT NOT NULL DEFAULT 'none'
15368
15368
  CHECK(plan_approval IN ('none', 'pending', 'approved', 'rejected'));`,
15369
- `ALTER TABLE team ADD COLUMN lead_agent TEXT;`
15369
+ `ALTER TABLE team ADD COLUMN lead_agent TEXT;`,
15370
+ `ALTER TABLE team_member ADD COLUMN workspace_id TEXT;`
15370
15371
  ];
15371
15372
  function applyMigrations(db) {
15372
15373
  const { user_version: current } = db.query("PRAGMA user_version").get();
@@ -15426,6 +15427,11 @@ function wrapThrowingClient(raw) {
15426
15427
  remove: throwing(r.worktree.remove.bind(r.worktree)),
15427
15428
  list: throwing(r.worktree.list.bind(r.worktree)),
15428
15429
  reset: throwing(r.worktree.reset.bind(r.worktree))
15430
+ },
15431
+ workspace: {
15432
+ create: throwing(r.experimental.workspace.create.bind(r.experimental.workspace)),
15433
+ remove: throwing(r.experimental.workspace.remove.bind(r.experimental.workspace)),
15434
+ list: throwing(r.experimental.workspace.list.bind(r.experimental.workspace))
15429
15435
  }
15430
15436
  };
15431
15437
  }
@@ -15446,6 +15452,9 @@ function validateTeamName(name) {
15446
15452
  return "Team name must be lowercase alphanumeric with hyphens only";
15447
15453
  return;
15448
15454
  }
15455
+ function isWorktreeInstance(directory) {
15456
+ return directory.includes("/worktree/") && directory.includes("/ensemble-");
15457
+ }
15449
15458
  function validateMemberName(name) {
15450
15459
  if (name.toLowerCase() === "lead")
15451
15460
  return `Name "lead" is reserved`;
@@ -15480,30 +15489,6 @@ function getUndeliveredMessages(db, teamId) {
15480
15489
  function markDelivered(db, messageId) {
15481
15490
  db.run("UPDATE team_message SET delivered = 1 WHERE id = ?", [messageId]);
15482
15491
  }
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
15492
 
15508
15493
  // src/recovery.ts
15509
15494
  async function recoverStaleMembers(db, client3) {
@@ -15552,7 +15537,7 @@ async function recoverUndeliveredMessages(db, client3, registry2) {
15552
15537
  for (const msg of messages) {
15553
15538
  let recipientSessionId;
15554
15539
  if (msg.to_name === "lead") {
15555
- recipientSessionId = team.lead_session_id;
15540
+ continue;
15556
15541
  } else if (msg.to_name) {
15557
15542
  const entry = registry2.getByName(team.id, msg.to_name);
15558
15543
  recipientSessionId = entry?.sessionId;
@@ -15690,12 +15675,6 @@ function checkToolIsolation(registry2, tracker, toolName, sessionId) {
15690
15675
  throw new Error("Team tools are not available to sub-agents. Report findings to your parent teammate via your normal output.");
15691
15676
  }
15692
15677
  }
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
15678
 
15700
15679
  // src/notify.ts
15701
15680
  var TOAST_CONFIG = {
@@ -15778,16 +15757,27 @@ function buildLeadSystemPrompt(db, teamId) {
15778
15757
  const pending = countMap.pending ?? 0;
15779
15758
  const memberList = members.map((m) => `${m.name} [${STATUS_DISPLAY[m.status] ?? m.status}]`).join(", ");
15780
15759
  const teammateLine = members.length > 0 ? `Teammates: ${memberList}` : "Teammates: none";
15781
- return [
15760
+ 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);
15761
+ const lines = [
15782
15762
  `You are leading team "${team.name}" with ${members.length} active teammates.`,
15783
15763
  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(`
15764
+ `Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`
15765
+ ];
15766
+ if (pendingMessages.length > 0) {
15767
+ lines.push("", "--- Team Messages ---");
15768
+ const MAX_MSG = 500;
15769
+ for (const msg of pendingMessages) {
15770
+ if (msg.content.length > MAX_MSG) {
15771
+ lines.push(`[From ${msg.from_name}]: ${msg.content.slice(0, MAX_MSG)}... (use team_results to read full message)`);
15772
+ } else {
15773
+ lines.push(`[From ${msg.from_name}]: ${msg.content}`);
15774
+ }
15775
+ markDelivered(db, msg.id);
15776
+ }
15777
+ lines.push("--- End Messages ---");
15778
+ }
15779
+ 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.");
15780
+ return lines.join(`
15791
15781
  `);
15792
15782
  }
15793
15783
  function buildTeammateSystemPrompt(db, teamId, memberName) {
@@ -15820,6 +15810,17 @@ function buildTeamCompactionContext(db, teamId, role, memberName) {
15820
15810
  `);
15821
15811
  }
15822
15812
 
15813
+ // src/log.ts
15814
+ var _client = null;
15815
+ function initLog(client3) {
15816
+ _client = client3;
15817
+ }
15818
+ function log(msg) {
15819
+ if (!_client)
15820
+ return;
15821
+ _client.app.log({ service: "ensemble", level: "info", message: msg }).catch(() => {});
15822
+ }
15823
+
15823
15824
  // src/types.ts
15824
15825
  function findTeamBySession(db, registry2, sessionId) {
15825
15826
  const entry = registry2.getBySession(sessionId);
@@ -15860,6 +15861,18 @@ async function executeTeamCreate(deps, args, sessionId) {
15860
15861
  }
15861
15862
 
15862
15863
  // src/tools/team-spawn.ts
15864
+ function getSpawnTimeout() {
15865
+ return Number(process.env.SPAWN_TIMEOUT_MS) || 120000;
15866
+ }
15867
+ function withTimeout(promise2, ms, label) {
15868
+ let timer;
15869
+ return Promise.race([
15870
+ promise2.finally(() => clearTimeout(timer)),
15871
+ new Promise((_, reject) => {
15872
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
15873
+ })
15874
+ ]);
15875
+ }
15863
15876
  async function executeTeamSpawn(deps, args, sessionId) {
15864
15877
  const nameError = validateMemberName(args.name);
15865
15878
  if (nameError)
@@ -15874,17 +15887,21 @@ async function executeTeamSpawn(deps, args, sessionId) {
15874
15887
  throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
15875
15888
  const useWorktree = args.worktree !== false;
15876
15889
  const usePlanApproval = args.plan_approval === true;
15890
+ log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`);
15877
15891
  let worktreeDir = null;
15878
15892
  let worktreeBranch = null;
15879
15893
  if (useWorktree) {
15880
15894
  const worktreeName = `ensemble-${teamInfo.teamName}-${args.name}`;
15881
15895
  try {
15882
- const result = await deps.client.worktree.create({ worktreeCreateInput: { name: worktreeName } });
15896
+ log(`spawn:worktree:start name=${args.name}`);
15897
+ const result = await withTimeout(deps.client.worktree.create({ worktreeCreateInput: { name: worktreeName } }), getSpawnTimeout(), `worktree.create for "${args.name}"`);
15883
15898
  if (result.data) {
15884
15899
  worktreeDir = result.data.directory;
15885
15900
  worktreeBranch = result.data.branch;
15886
15901
  }
15887
- } catch {
15902
+ log(`spawn:worktree:done name=${args.name} dir=${worktreeDir}`);
15903
+ } catch (err) {
15904
+ log(`spawn:worktree:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`);
15888
15905
  try {
15889
15906
  await deps.client.tui.showToast({
15890
15907
  title: "Team",
@@ -15895,6 +15912,19 @@ async function executeTeamSpawn(deps, args, sessionId) {
15895
15912
  } catch {}
15896
15913
  }
15897
15914
  }
15915
+ let workspaceId = null;
15916
+ if (worktreeDir && worktreeBranch) {
15917
+ try {
15918
+ log(`spawn:workspace:start name=${args.name}`);
15919
+ const wsResult = await withTimeout(deps.client.workspace.create({ branch: worktreeBranch }), getSpawnTimeout(), `workspace.create for "${args.name}"`);
15920
+ if (wsResult.data) {
15921
+ workspaceId = wsResult.data.id;
15922
+ }
15923
+ log(`spawn:workspace:done name=${args.name} id=${workspaceId}`);
15924
+ } catch (err) {
15925
+ log(`spawn:workspace:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`);
15926
+ }
15927
+ }
15898
15928
  const isReadOnly = args.agent === "plan" || args.agent === "explore";
15899
15929
  const TEAM_TOOLS = ["team_message", "team_broadcast", "team_tasks_list", "team_tasks_add", "team_tasks_complete", "team_claim"];
15900
15930
  const permission = isReadOnly ? [
@@ -15904,14 +15934,22 @@ async function executeTeamSpawn(deps, args, sessionId) {
15904
15934
  ] : undefined;
15905
15935
  let childSessionId;
15906
15936
  try {
15907
- const createResult = await deps.client.session.create({
15937
+ log(`spawn:session:start name=${args.name}`);
15938
+ const createResult = await withTimeout(deps.client.session.create({
15908
15939
  parentID: sessionId,
15909
15940
  title: `${args.name} (@${args.agent} teammate)`,
15910
15941
  ...permission ? { permission } : {},
15911
- ...worktreeDir ? { directory: worktreeDir } : {}
15912
- });
15942
+ ...workspaceId ? { workspaceID: workspaceId } : {}
15943
+ }), getSpawnTimeout(), `session.create for "${args.name}"`);
15913
15944
  childSessionId = createResult.data?.id;
15945
+ log(`spawn:session:done name=${args.name} sessionId=${childSessionId}`);
15914
15946
  } catch (err) {
15947
+ log(`spawn:session:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`);
15948
+ if (workspaceId) {
15949
+ try {
15950
+ await deps.client.workspace.remove({ id: workspaceId });
15951
+ } catch {}
15952
+ }
15915
15953
  if (worktreeDir) {
15916
15954
  try {
15917
15955
  await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
@@ -15920,6 +15958,11 @@ async function executeTeamSpawn(deps, args, sessionId) {
15920
15958
  throw new Error(`Failed to create session for teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`);
15921
15959
  }
15922
15960
  if (!childSessionId) {
15961
+ if (workspaceId) {
15962
+ try {
15963
+ await deps.client.workspace.remove({ id: workspaceId });
15964
+ } catch {}
15965
+ }
15923
15966
  if (worktreeDir) {
15924
15967
  try {
15925
15968
  await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
@@ -15929,15 +15972,19 @@ async function executeTeamSpawn(deps, args, sessionId) {
15929
15972
  }
15930
15973
  const planApproval = usePlanApproval ? "pending" : "none";
15931
15974
  const now = Date.now();
15932
- deps.db.run(`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, plan_approval, time_created, time_updated)
15933
- VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, planApproval, now, now]);
15975
+ deps.db.run(`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, workspace_id, plan_approval, time_created, time_updated)
15976
+ VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]);
15934
15977
  deps.registry.register(teamInfo.teamId, args.name, childSessionId);
15935
15978
  const context = [
15936
15979
  `You are "${args.name}", a teammate in team "${teamInfo.teamName}".`,
15937
15980
  `Your agent type is "${args.agent}".`
15938
15981
  ];
15939
- if (worktreeBranch) {
15940
- context.push(`You are working on branch "${worktreeBranch}" in your own worktree. Your changes are isolated from other teammates.`);
15982
+ if (worktreeBranch && worktreeDir && !workspaceId) {
15983
+ context.push(`You are working on branch "${worktreeBranch}" in your own worktree at: ${worktreeDir}`, `Your changes are isolated from other teammates.`, `IMPORTANT: All file operations and shell commands MUST target your worktree directory.`, `Before running shell commands, cd to: ${worktreeDir}`);
15984
+ } else if (worktreeBranch && worktreeDir) {
15985
+ context.push(`You are working on branch "${worktreeBranch}" in your own isolated worktree.`, `Your changes are isolated from other teammates.`);
15986
+ } else if (worktreeBranch) {
15987
+ context.push(`You are working on branch "${worktreeBranch}". Your changes are isolated from other teammates.`);
15941
15988
  }
15942
15989
  if (usePlanApproval) {
15943
15990
  context.push("", "IMPORTANT: You are in PLAN MODE.", "Read and explore the codebase, then send your implementation plan to the lead via team_message.", "Do NOT write or modify any files until the lead approves your plan.", "Wait for the lead's approval message before proceeding with implementation.");
@@ -15949,15 +15996,20 @@ async function executeTeamSpawn(deps, args, sessionId) {
15949
15996
  }
15950
15997
  const contextStr = context.join(`
15951
15998
  `);
15999
+ log(`spawn:promptAsync:fire name=${args.name} sessionId=${childSessionId}`);
15952
16000
  deps.client.session.promptAsync({
15953
16001
  sessionID: childSessionId,
15954
16002
  parts: [{ type: "text", text: contextStr }],
15955
16003
  agent: args.agent
15956
16004
  }).catch(() => {
16005
+ log(`spawn:promptAsync:failed name=${args.name} \u2014 rolling back`);
15957
16006
  try {
15958
16007
  deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId]);
15959
16008
  deps.registry.unregister(childSessionId);
15960
16009
  deps.client.session.abort({ sessionID: childSessionId }).catch(() => {});
16010
+ if (workspaceId) {
16011
+ deps.client.workspace.remove({ id: workspaceId }).catch(() => {});
16012
+ }
15961
16013
  if (worktreeDir) {
15962
16014
  deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => {});
15963
16015
  }
@@ -15973,14 +16025,11 @@ async function executeTeamSpawn(deps, args, sessionId) {
15973
16025
  to: "lead",
15974
16026
  content: `Teammate "${args.name}" failed to start and was removed. You may retry the spawn.`
15975
16027
  });
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
16028
  } catch {}
15981
16029
  });
15982
16030
  const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : "";
15983
16031
  const planInfo = usePlanApproval ? " [plan mode \u2014 will send plan for approval]" : "";
16032
+ log(`spawn:done name=${args.name} sessionId=${childSessionId}`);
15984
16033
  return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`;
15985
16034
  }
15986
16035
 
@@ -16020,31 +16069,25 @@ async function executeTeamMessage(deps, args, sessionId) {
16020
16069
  content: messageText
16021
16070
  });
16022
16071
  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)`;
16072
+ if (isToLead) {
16073
+ log(`team_message:wake-lead from=${senderName} recipientSession=${recipientSessionId}`);
16074
+ deps.client.session.promptAsync({
16075
+ sessionID: recipientSessionId,
16076
+ parts: [{ type: "text", text: `[System: New team message from ${senderName}]` }]
16077
+ }).catch((err) => {
16078
+ log(`team_message:wake-lead:failed from=${senderName} err=${err instanceof Error ? err.message : String(err)}`);
16079
+ });
16080
+ return `Message sent to ${args.to}.`;
16041
16081
  }
16082
+ const deliveryText = `[Team message from ${senderName}]: ${messageText}`;
16042
16083
  deps.client.session.promptAsync({
16043
16084
  sessionID: recipientSessionId,
16044
16085
  parts: [{ type: "text", text: deliveryText }]
16045
16086
  }).then(() => {
16046
16087
  markDelivered(deps.db, msgId);
16047
- }).catch(() => {});
16088
+ }).catch((err) => {
16089
+ log(`team_message:deliver:failed to=${args.to} err=${err instanceof Error ? err.message : String(err)}`);
16090
+ });
16048
16091
  return `Message sent to ${args.to}.`;
16049
16092
  }
16050
16093
 
@@ -16080,7 +16123,9 @@ async function executeTeamBroadcast(deps, args, sessionId) {
16080
16123
  delivered++;
16081
16124
  if (delivered === 1)
16082
16125
  markDelivered(deps.db, msgId);
16083
- }).catch(() => {});
16126
+ }).catch((err) => {
16127
+ log(`team_broadcast:deliver:failed to=${recipient.name} err=${err instanceof Error ? err.message : String(err)}`);
16128
+ });
16084
16129
  }
16085
16130
  return `Broadcast sent to ${recipients.length} recipient${recipients.length !== 1 ? "s" : ""}.`;
16086
16131
  }
@@ -16140,7 +16185,7 @@ async function executeTeamCleanup(deps, args, sessionId) {
16140
16185
  throw new Error("This session is not in a team.");
16141
16186
  if (teamInfo.role !== "lead")
16142
16187
  throw new Error("Only the team lead can clean up the team.");
16143
- const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch FROM team_member WHERE team_id = ?").all(teamInfo.teamId);
16188
+ const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch, workspace_id FROM team_member WHERE team_id = ?").all(teamInfo.teamId);
16144
16189
  const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
16145
16190
  if (active.length > 0 && !args.force) {
16146
16191
  const names = active.map((m) => m.name).join(", ");
@@ -16155,9 +16200,16 @@ async function executeTeamCleanup(deps, args, sessionId) {
16155
16200
  }
16156
16201
  const branches = [];
16157
16202
  for (const member of members) {
16203
+ if (member.workspace_id) {
16204
+ try {
16205
+ await deps.client.workspace.remove({ id: member.workspace_id });
16206
+ deps.db.run("UPDATE team_member SET workspace_id = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name]);
16207
+ } catch {}
16208
+ }
16158
16209
  if (member.worktree_dir) {
16159
16210
  try {
16160
16211
  await deps.client.worktree.remove({ worktreeRemoveInput: { directory: member.worktree_dir } });
16212
+ deps.db.run("UPDATE team_member SET worktree_dir = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name]);
16161
16213
  } catch {}
16162
16214
  }
16163
16215
  if (member.worktree_branch) {
@@ -16498,17 +16550,29 @@ var plugin = async (input) => {
16498
16550
  const tracker = new DescendantTracker;
16499
16551
  const pluginTransport = input.client._client;
16500
16552
  const rawClient = new OpencodeClient({ client: pluginTransport });
16553
+ initLog(rawClient);
16501
16554
  const client3 = wrapThrowingClient(rawClient);
16502
16555
  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
- }
16556
+ if (!isWorktreeInstance(input.directory)) {
16557
+ log("init:recovery:start (main instance)");
16558
+ const recovery = await recoverStaleMembers(db, client3);
16559
+ if (recovery.interrupted > 0) {
16560
+ log(`init:recovery:interrupted=${recovery.interrupted}`);
16561
+ 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();
16562
+ for (const m of members) {
16563
+ registry2.register(m.team_id, m.name, m.session_id);
16564
+ }
16565
+ }
16566
+ recoverUndeliveredMessages(db, client3, registry2).catch((err) => {
16567
+ log(`init:recover-messages:failed err=${err instanceof Error ? err.message : String(err)}`);
16568
+ });
16569
+ recoverOrphanedWorktrees(db, client3).catch((err) => {
16570
+ log(`init:recover-worktrees:failed err=${err instanceof Error ? err.message : String(err)}`);
16571
+ });
16572
+ log("init:recovery:done");
16573
+ } else {
16574
+ log(`init:skip-recovery (worktree instance: ${input.directory})`);
16509
16575
  }
16510
- recoverUndeliveredMessages(db, client3, registry2).catch(() => {});
16511
- recoverOrphanedWorktrees(db, client3).catch(() => {});
16512
16576
  const rateLimitEnv = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT;
16513
16577
  const rateLimitCapacity = rateLimitEnv === "0" ? 0 : parseInt(rateLimitEnv ?? "", 10) || DEFAULT_RATE_LIMIT_CAPACITY;
16514
16578
  const rateLimiter = new TokenBucket({
@@ -16559,7 +16623,19 @@ var plugin = async (input) => {
16559
16623
  await notifyWorkingProgress(client3, db, transition.teamId);
16560
16624
  }
16561
16625
  if (statusType === "idle") {
16562
- handleLeadIdleFlush(db, client3, sessionID).catch(() => {});
16626
+ const team = db.query("SELECT id FROM team WHERE lead_session_id = ? AND status = 'active'").get(sessionID);
16627
+ if (team) {
16628
+ const pending = db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND to_name = 'lead' AND delivered = 0").get(team.id);
16629
+ if (pending.c > 0) {
16630
+ log(`wake-lead: ${pending.c} pending messages, sending promptAsync`);
16631
+ client3.session.promptAsync({
16632
+ sessionID,
16633
+ parts: [{ type: "text", text: `[System: ${pending.c} new team message(s) available]` }]
16634
+ }).catch((err) => {
16635
+ log(`wake-lead:failed err=${err instanceof Error ? err.message : String(err)}`);
16636
+ });
16637
+ }
16638
+ }
16563
16639
  }
16564
16640
  }
16565
16641
  if (event.type === "session.created") {
@@ -16583,7 +16659,9 @@ var plugin = async (input) => {
16583
16659
  const teamInfo = findTeamBySession(db, registry2, input2.sessionID);
16584
16660
  if (!teamInfo)
16585
16661
  return;
16662
+ log(`system-prompt:transform role=${teamInfo.role} session=${input2.sessionID}`);
16586
16663
  const prompt = teamInfo.role === "lead" ? buildLeadSystemPrompt(db, teamInfo.teamId) : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown");
16664
+ log(`system-prompt:injected role=${teamInfo.role} len=${prompt.length}`);
16587
16665
  output.system.push(prompt);
16588
16666
  },
16589
16667
  "experimental.session.compacting": async (input2, output) => {
@@ -16603,10 +16681,13 @@ var plugin = async (input) => {
16603
16681
  output.env.ENSEMBLE_ROLE = teamInfo.role;
16604
16682
  if (teamInfo.memberName) {
16605
16683
  output.env.ENSEMBLE_MEMBER = teamInfo.memberName;
16606
- const member = db.query("SELECT worktree_branch FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, teamInfo.memberName);
16684
+ const member = db.query("SELECT worktree_branch, worktree_dir FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, teamInfo.memberName);
16607
16685
  if (member?.worktree_branch) {
16608
16686
  output.env.ENSEMBLE_BRANCH = member.worktree_branch;
16609
16687
  }
16688
+ if (member?.worktree_dir) {
16689
+ output.env.ENSEMBLE_WORKTREE_DIR = member.worktree_dir;
16690
+ }
16610
16691
  }
16611
16692
  },
16612
16693
  tool: {
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.5",
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/client.ts CHANGED
@@ -25,6 +25,7 @@ interface RawClient {
25
25
  session: { create: SdkMethod; promptAsync: SdkMethod; abort: SdkMethod; status: SdkMethod }
26
26
  tui: { showToast: SdkMethod; selectSession: SdkMethod }
27
27
  worktree: { create: SdkMethod; remove: SdkMethod; list: SdkMethod; reset: SdkMethod }
28
+ experimental: { workspace: { create: SdkMethod; remove: SdkMethod; list: SdkMethod } }
28
29
  }
29
30
 
30
31
  /**
@@ -50,5 +51,10 @@ export function wrapThrowingClient(raw: unknown): PluginClient {
50
51
  list: throwing(r.worktree.list.bind(r.worktree)),
51
52
  reset: throwing(r.worktree.reset.bind(r.worktree)),
52
53
  },
54
+ workspace: {
55
+ create: throwing(r.experimental.workspace.create.bind(r.experimental.workspace)),
56
+ remove: throwing(r.experimental.workspace.remove.bind(r.experimental.workspace)),
57
+ list: throwing(r.experimental.workspace.list.bind(r.experimental.workspace)),
58
+ },
53
59
  } as PluginClient
54
60
  }
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
 
@@ -194,11 +217,14 @@ const plugin: Plugin = async (input) => {
194
217
  output.env.ENSEMBLE_ROLE = teamInfo.role
195
218
  if (teamInfo.memberName) {
196
219
  output.env.ENSEMBLE_MEMBER = teamInfo.memberName
197
- const member = db.query("SELECT worktree_branch FROM team_member WHERE team_id = ? AND name = ?")
198
- .get(teamInfo.teamId, teamInfo.memberName) as { worktree_branch: string | null } | null
220
+ const member = db.query("SELECT worktree_branch, worktree_dir FROM team_member WHERE team_id = ? AND name = ?")
221
+ .get(teamInfo.teamId, teamInfo.memberName) as { worktree_branch: string | null; worktree_dir: string | null } | null
199
222
  if (member?.worktree_branch) {
200
223
  output.env.ENSEMBLE_BRANCH = member.worktree_branch
201
224
  }
225
+ if (member?.worktree_dir) {
226
+ output.env.ENSEMBLE_WORKTREE_DIR = member.worktree_dir
227
+ }
202
228
  }
203
229
  },
204
230
 
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
package/src/schema.ts CHANGED
@@ -78,6 +78,8 @@ export const MIGRATIONS: string[] = [
78
78
  CHECK(plan_approval IN ('none', 'pending', 'approved', 'rejected'));`,
79
79
  // Migration 5: Track lead's agent mode so message delivery preserves it
80
80
  `ALTER TABLE team ADD COLUMN lead_agent TEXT;`,
81
+ // Migration 6: Track workspace ID for worktree-session binding
82
+ `ALTER TABLE team_member ADD COLUMN workspace_id TEXT;`,
81
83
  ]
82
84
 
83
85
  /**
@@ -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" : ""}.`
@@ -15,8 +15,8 @@ export async function executeTeamCleanup(
15
15
  if (!teamInfo) throw new Error("This session is not in a team.")
16
16
  if (teamInfo.role !== "lead") throw new Error("Only the team lead can clean up the team.")
17
17
 
18
- const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch FROM team_member WHERE team_id = ?")
19
- .all(teamInfo.teamId) as Array<{ name: string; session_id: string; status: string; worktree_dir: string | null; worktree_branch: string | null }>
18
+ const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch, workspace_id FROM team_member WHERE team_id = ?")
19
+ .all(teamInfo.teamId) as Array<{ name: string; session_id: string; status: string; worktree_dir: string | null; worktree_branch: string | null; workspace_id: string | null }>
20
20
 
21
21
  const active = members.filter(m => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error")
22
22
 
@@ -34,12 +34,19 @@ export async function executeTeamCleanup(
34
34
  }
35
35
  }
36
36
 
37
- // Remove worktrees and collect branches for merging
37
+ // Remove workspaces, worktrees, and collect branches for merging
38
38
  const branches: string[] = []
39
39
  for (const member of members) {
40
+ if (member.workspace_id) {
41
+ try {
42
+ await deps.client.workspace.remove({ id: member.workspace_id })
43
+ deps.db.run("UPDATE team_member SET workspace_id = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name])
44
+ } catch { /* best effort — workspace may already be gone */ }
45
+ }
40
46
  if (member.worktree_dir) {
41
47
  try {
42
48
  await deps.client.worktree.remove({ worktreeRemoveInput: { directory: member.worktree_dir } })
49
+ deps.db.run("UPDATE team_member SET worktree_dir = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name])
43
50
  } catch { /* best effort — worktree may already be gone */ }
44
51
  }
45
52
  if (member.worktree_branch) {
@@ -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",
@@ -55,6 +78,26 @@ export async function executeTeamSpawn(
55
78
  }
56
79
  }
57
80
 
81
+ // Create workspace from worktree branch — links session to worktree directory.
82
+ // OQ-workspace: assumes workspace.create({ branch }) auto-links to the worktree at that branch.
83
+ let workspaceId: string | null = null
84
+ if (worktreeDir && worktreeBranch) {
85
+ try {
86
+ log(`spawn:workspace:start name=${args.name}`)
87
+ const wsResult = await withTimeout(
88
+ deps.client.workspace.create({ branch: worktreeBranch }),
89
+ getSpawnTimeout(), `workspace.create for "${args.name}"`
90
+ )
91
+ if (wsResult.data) {
92
+ workspaceId = wsResult.data.id
93
+ }
94
+ log(`spawn:workspace:done name=${args.name} id=${workspaceId}`)
95
+ } catch (err) {
96
+ log(`spawn:workspace:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`)
97
+ // Non-fatal — prompt-based CWD instruction is the fallback
98
+ }
99
+ }
100
+
58
101
  // Permission rules on session.create are the hard gate (server-enforced).
59
102
  // For read-only agents, deny write tools and explicitly allow team tools
60
103
  // so the agent's built-in restrictive permissions don't block them.
@@ -68,18 +111,28 @@ export async function executeTeamSpawn(
68
111
  ]
69
112
  : undefined
70
113
 
71
- // Create child session — use worktree directory if available
114
+ // Create child session — bind to workspace if available (server-enforced CWD isolation).
115
+ // Falls back to no workspace binding if workspace.create failed.
72
116
  let childSessionId: string | undefined
73
117
  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
- })
118
+ log(`spawn:session:start name=${args.name}`)
119
+ const createResult = await withTimeout(
120
+ deps.client.session.create({
121
+ parentID: sessionId,
122
+ title: `${args.name} (@${args.agent} teammate)`,
123
+ ...(permission ? { permission } : {}),
124
+ ...(workspaceId ? { workspaceID: workspaceId } : {}),
125
+ }),
126
+ getSpawnTimeout(), `session.create for "${args.name}"`
127
+ )
80
128
  childSessionId = createResult.data?.id
129
+ log(`spawn:session:done name=${args.name} sessionId=${childSessionId}`)
81
130
  } catch (err) {
82
- // Rollback worktree if session creation failed
131
+ log(`spawn:session:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`)
132
+ // Rollback workspace and worktree if session creation failed
133
+ if (workspaceId) {
134
+ try { await deps.client.workspace.remove({ id: workspaceId }) } catch { /* best effort */ }
135
+ }
83
136
  if (worktreeDir) {
84
137
  try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
85
138
  }
@@ -87,6 +140,9 @@ export async function executeTeamSpawn(
87
140
  }
88
141
 
89
142
  if (!childSessionId) {
143
+ if (workspaceId) {
144
+ try { await deps.client.workspace.remove({ id: workspaceId }) } catch { /* best effort */ }
145
+ }
90
146
  if (worktreeDir) {
91
147
  try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
92
148
  }
@@ -97,9 +153,9 @@ export async function executeTeamSpawn(
97
153
  const planApproval = usePlanApproval ? "pending" : "none"
98
154
  const now = Date.now()
99
155
  deps.db.run(
100
- `INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, plan_approval, time_created, time_updated)
101
- VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?)`,
102
- [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, planApproval, now, now]
156
+ `INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, workspace_id, plan_approval, time_created, time_updated)
157
+ VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?, ?)`,
158
+ [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]
103
159
  )
104
160
 
105
161
  // Register in memory
@@ -111,8 +167,22 @@ export async function executeTeamSpawn(
111
167
  `Your agent type is "${args.agent}".`,
112
168
  ]
113
169
 
114
- if (worktreeBranch) {
115
- context.push(`You are working on branch "${worktreeBranch}" in your own worktree. Your changes are isolated from other teammates.`)
170
+ if (worktreeBranch && worktreeDir && !workspaceId) {
171
+ // Workspace binding failed — fallback to prompt-based CWD instruction
172
+ context.push(
173
+ `You are working on branch "${worktreeBranch}" in your own worktree at: ${worktreeDir}`,
174
+ `Your changes are isolated from other teammates.`,
175
+ `IMPORTANT: All file operations and shell commands MUST target your worktree directory.`,
176
+ `Before running shell commands, cd to: ${worktreeDir}`,
177
+ )
178
+ } else if (worktreeBranch && worktreeDir) {
179
+ // Workspace binding active — server handles CWD
180
+ context.push(
181
+ `You are working on branch "${worktreeBranch}" in your own isolated worktree.`,
182
+ `Your changes are isolated from other teammates.`,
183
+ )
184
+ } else if (worktreeBranch) {
185
+ context.push(`You are working on branch "${worktreeBranch}". Your changes are isolated from other teammates.`)
116
186
  }
117
187
 
118
188
  // Plan approval mode — teammate must send plan before writing
@@ -159,20 +229,21 @@ export async function executeTeamSpawn(
159
229
  const contextStr = context.join("\n")
160
230
 
161
231
  // 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.
232
+ log(`spawn:promptAsync:fire name=${args.name} sessionId=${childSessionId}`)
166
233
  deps.client.session.promptAsync({
167
234
  sessionID: childSessionId,
168
235
  parts: [{ type: "text", text: contextStr }],
169
236
  agent: args.agent,
170
237
  }).catch(() => {
238
+ log(`spawn:promptAsync:failed name=${args.name} — rolling back`)
171
239
  try {
172
240
  // Async rollback: clean up DB (by session_id to avoid deleting a re-spawned member with the same name), registry, session, and worktree
173
241
  deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId])
174
242
  deps.registry.unregister(childSessionId)
175
243
  deps.client.session.abort({ sessionID: childSessionId }).catch(() => { /* best effort */ })
244
+ if (workspaceId) {
245
+ deps.client.workspace.remove({ id: workspaceId }).catch(() => { /* best effort */ })
246
+ }
176
247
  if (worktreeDir) {
177
248
  deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => { /* best effort */ })
178
249
  }
@@ -184,21 +255,18 @@ export async function executeTeamSpawn(
184
255
  duration: 5000,
185
256
  }).catch(() => { /* TUI may not be available */ })
186
257
  // Notify the lead model so it can react (retry, adjust plan, etc.)
258
+ // Message delivered via system prompt transform on the lead's next turn.
187
259
  sendMessage(deps.db, {
188
260
  teamId: teamInfo.teamId,
189
261
  from: "system",
190
262
  to: "lead",
191
263
  content: `Teammate "${args.name}" failed to start and was removed. You may retry the spawn.`,
192
264
  })
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
265
  } catch { /* rollback failed — watchdog will clean up stale member */ }
199
266
  })
200
267
 
201
268
  const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : ""
202
269
  const planInfo = usePlanApproval ? " [plan mode — will send plan for approval]" : ""
270
+ log(`spawn:done name=${args.name} sessionId=${childSessionId}`)
203
271
  return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
204
272
  }
package/src/types.ts CHANGED
@@ -62,6 +62,11 @@ export interface PluginClient {
62
62
  list(): Promise<{ data?: Array<{ name: string; branch: string; directory: string }> }>
63
63
  reset(options: { worktreeResetInput?: { directory: string } }): Promise<unknown>
64
64
  }
65
+ workspace: {
66
+ create(options: { id?: string; type?: string; branch?: string | null; extra?: unknown | null }): Promise<{ data?: { id: string; type: string; branch: string | null; directory: string | null; projectID: string } }>
67
+ remove(options: { id: string }): Promise<unknown>
68
+ list(): Promise<{ data?: Array<{ id: string; type: string; branch: string | null; directory: string | null; projectID: string }> }>
69
+ }
65
70
  }
66
71
 
67
72
  /**
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.