@hueyexe/opencode-ensemble 0.9.0 → 0.9.1

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@hueyexe/opencode-ensemble.svg)](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/@hueyexe/opencode-ensemble.svg)](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
5
- [![tests](https://img.shields.io/badge/tests-432%20passing-brightgreen.svg)]()
5
+ [![tests](https://img.shields.io/badge/tests-452%20passing-brightgreen.svg)]()
6
6
  [![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue.svg)]()
7
7
  [![OpenCode SDK](https://img.shields.io/badge/deps-OpenCode%20SDK%20only-blue.svg)]()
8
8
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
@@ -101,7 +101,7 @@ Add to your OpenCode config with a pinned version. Project-level or global.
101
101
 
102
102
  ```json
103
103
  {
104
- "plugin": ["@hueyexe/opencode-ensemble@0.9.0"]
104
+ "plugin": ["@hueyexe/opencode-ensemble@0.9.1"]
105
105
  }
106
106
  ```
107
107
 
@@ -109,7 +109,7 @@ Add to your OpenCode config with a pinned version. Project-level or global.
109
109
 
110
110
  ```json
111
111
  {
112
- "plugin": ["@hueyexe/opencode-ensemble@0.9.0"]
112
+ "plugin": ["@hueyexe/opencode-ensemble@0.9.1"]
113
113
  }
114
114
  ```
115
115
 
@@ -159,7 +159,7 @@ export { default } from "/path/to/opencode-ensemble/src/index.ts"
159
159
 
160
160
  ## Tools
161
161
 
162
- 13 tools. The lead has all of them. Teammates get 6 (messaging + tasks).
162
+ 14 tools. The lead has all of them. Teammates get 6 (messaging + tasks).
163
163
 
164
164
  **Team lifecycle** (lead only)
165
165
 
@@ -167,8 +167,9 @@ export { default } from "/path/to/opencode-ensemble/src/index.ts"
167
167
  |------|-------------|
168
168
  | `team_create` | Create a team. Caller becomes the lead. |
169
169
  | `team_spawn` | Start a new teammate with a task. Supports `plan_approval` mode. |
170
- | `team_shutdown` | Ask a teammate to stop. Supports `force` flag for immediate abort. |
171
- | `team_cleanup` | Remove the team when done. |
170
+ | `team_shutdown` | Ask a teammate to stop. Preserves their branch before aborting. Supports `force` flag. |
171
+ | `team_merge` | Merge a shutdown teammate's branch into working directory (unstaged). |
172
+ | `team_cleanup` | Remove the team when done. Safety-net merges any forgotten branches. |
172
173
  | `team_status` | See all members, their status, and a task summary. |
173
174
  | `team_view` | Switch the TUI to a teammate's session. |
174
175
 
@@ -308,7 +309,7 @@ Same coordination model (shared tasks, peer messaging, lead coordination) with s
308
309
  ```bash
309
310
  bun install
310
311
  bun run typecheck
311
- bun test # 432 tests
312
+ bun test # 452 tests
312
313
  bun run build
313
314
  ```
314
315
 
package/dist/index.js CHANGED
@@ -13,6 +13,109 @@ var __export = (target, all) => {
13
13
  set: __exportSetter.bind(all, name)
14
14
  });
15
15
  };
16
+ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
17
+
18
+ // src/log.ts
19
+ function initLog(client3) {
20
+ _client = client3;
21
+ }
22
+ function log(msg) {
23
+ if (!_client)
24
+ return;
25
+ _client.app.log({ service: "ensemble", level: "info", message: msg }).catch(() => {});
26
+ }
27
+ var _client = null;
28
+
29
+ // src/tools/merge-helper.ts
30
+ var exports_merge_helper = {};
31
+ __export(exports_merge_helper, {
32
+ preservedBranchName: () => preservedBranchName,
33
+ preserveBranch: () => preserveBranch,
34
+ mergeBranchRaw: () => mergeBranchRaw,
35
+ mergeBranch: () => mergeBranch,
36
+ gitStashPop: () => gitStashPop,
37
+ gitStash: () => gitStash,
38
+ gitReset: () => gitReset,
39
+ deleteBranch: () => deleteBranch
40
+ });
41
+ async function preserveBranch(sourceBranch, targetBranch, cwd) {
42
+ const proc = Bun.spawn(["git", "branch", targetBranch, sourceBranch], { cwd, stdout: "pipe", stderr: "pipe" });
43
+ const exit = await proc.exited;
44
+ if (exit !== 0) {
45
+ const stderr = await new Response(proc.stderr).text();
46
+ log(`merge-helper:preserve:failed src=${sourceBranch} target=${targetBranch} err=${stderr.trim()}`);
47
+ return false;
48
+ }
49
+ return true;
50
+ }
51
+ async function deleteBranch(branch, cwd) {
52
+ const proc = Bun.spawn(["git", "branch", "-D", branch], { cwd, stdout: "pipe", stderr: "pipe" });
53
+ const exit = await proc.exited;
54
+ if (exit !== 0) {
55
+ log(`merge-helper:delete:failed branch=${branch}`);
56
+ return false;
57
+ }
58
+ return true;
59
+ }
60
+ async function mergeBranchRaw(branch, cwd) {
61
+ const merge2 = Bun.spawn(["git", "merge", "--squash", branch], { cwd, stdout: "pipe", stderr: "pipe" });
62
+ const stderrPromise = new Response(merge2.stderr).text();
63
+ const mergeExit = await merge2.exited;
64
+ if (mergeExit !== 0) {
65
+ const stderr = await stderrPromise;
66
+ log(`merge-helper:merge:conflict branch=${branch} err=${stderr.trim()}`);
67
+ const abort = Bun.spawn(["git", "merge", "--abort"], { cwd, stdout: "pipe", stderr: "pipe" });
68
+ await abort.exited;
69
+ return { ok: false, error: stderr.trim() || `merge exited with code ${mergeExit}` };
70
+ }
71
+ return { ok: true };
72
+ }
73
+ async function gitStash(cwd) {
74
+ const stash = Bun.spawn(["git", "stash", "--include-untracked"], { cwd, stdout: "pipe", stderr: "pipe" });
75
+ const stashOut = await new Response(stash.stdout).text();
76
+ const stashExit = await stash.exited;
77
+ return stashExit === 0 && !stashOut.includes("No local changes");
78
+ }
79
+ async function gitReset(cwd) {
80
+ const reset = Bun.spawn(["git", "reset", "HEAD"], { cwd, stdout: "pipe", stderr: "pipe" });
81
+ await reset.exited;
82
+ }
83
+ async function gitStashPop(cwd) {
84
+ const pop = Bun.spawn(["git", "stash", "pop", "--index"], { cwd, stdout: "pipe", stderr: "pipe" });
85
+ const exit = await pop.exited;
86
+ if (exit !== 0) {
87
+ const fallback = Bun.spawn(["git", "stash", "pop"], { cwd, stdout: "pipe", stderr: "pipe" });
88
+ const fallbackExit = await fallback.exited;
89
+ if (fallbackExit !== 0) {
90
+ log("merge-helper:stash-pop:failed \u2014 stashed work remains in git stash list");
91
+ return false;
92
+ }
93
+ }
94
+ return true;
95
+ }
96
+ async function mergeBranch(branch, cwd) {
97
+ const didStash = await gitStash(cwd);
98
+ const result = await mergeBranchRaw(branch, cwd);
99
+ if (!result.ok) {
100
+ if (didStash) {
101
+ const restored = await gitStashPop(cwd);
102
+ if (!restored)
103
+ return { ok: false, error: `${result.error}. WARNING: stashed work could not be restored \u2014 check git stash list` };
104
+ }
105
+ return result;
106
+ }
107
+ await gitReset(cwd);
108
+ if (didStash) {
109
+ const restored = await gitStashPop(cwd);
110
+ if (!restored)
111
+ return { ok: true, error: "Merge succeeded but stashed work could not be restored \u2014 check git stash list" };
112
+ }
113
+ return { ok: true };
114
+ }
115
+ function preservedBranchName(teamName, memberName) {
116
+ return `ensemble/preserved/${teamName}/${memberName}`;
117
+ }
118
+ var init_merge_helper = () => {};
16
119
 
17
120
  // node_modules/zod/v4/classic/external.js
18
121
  var exports_external = {};
@@ -15491,8 +15594,10 @@ function markDelivered(db, messageId) {
15491
15594
  }
15492
15595
 
15493
15596
  // src/recovery.ts
15494
- async function recoverStaleMembers(db, client3) {
15495
- const stale = db.query(`SELECT tm.session_id FROM team_member tm
15597
+ init_merge_helper();
15598
+ async function recoverStaleMembers(db, client3, cwd) {
15599
+ const stale = db.query(`SELECT tm.session_id, tm.worktree_branch, tm.name, tm.team_id, t.name as team_name
15600
+ FROM team_member tm
15496
15601
  JOIN team t ON tm.team_id = t.id
15497
15602
  WHERE tm.status = 'busy' AND t.status = 'active'`).all();
15498
15603
  const result = db.run(`UPDATE team_member SET status = 'error', execution_status = 'idle', time_updated = ?
@@ -15500,6 +15605,14 @@ async function recoverStaleMembers(db, client3) {
15500
15605
  AND team_id IN (SELECT id FROM team WHERE status = 'active')`, [Date.now()]);
15501
15606
  if (client3) {
15502
15607
  for (const member of stale) {
15608
+ if (cwd && member.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
15609
+ const safeBranch = preservedBranchName(member.team_name, member.name);
15610
+ const ok = await preserveBranch(member.worktree_branch, safeBranch, cwd);
15611
+ if (ok) {
15612
+ db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?", [safeBranch, member.team_id, member.name]);
15613
+ log(`recovery:branch:preserved src=${member.worktree_branch} target=${safeBranch}`);
15614
+ }
15615
+ }
15503
15616
  try {
15504
15617
  await client3.session.abort({ sessionID: member.session_id });
15505
15618
  } catch {}
@@ -15558,6 +15671,41 @@ async function recoverUndeliveredMessages(db, client3, registry2) {
15558
15671
  }
15559
15672
  return { redelivered };
15560
15673
  }
15674
+ async function recoverOrphanedBranches(db, cwd) {
15675
+ let removed = 0;
15676
+ const archivedTeams = db.query(`SELECT t.name FROM team t
15677
+ WHERE t.status = 'archived'
15678
+ AND NOT EXISTS (
15679
+ SELECT 1 FROM team_member tm
15680
+ WHERE tm.team_id = t.id AND tm.status NOT IN ('shutdown', 'error')
15681
+ )`).all();
15682
+ if (archivedTeams.length === 0)
15683
+ return { removed: 0 };
15684
+ const archivedNames = new Set(archivedTeams.map((t) => t.name));
15685
+ const proc = Bun.spawn(["git", "branch", "--list", "ensemble/preserved/*"], { cwd, stdout: "pipe", stderr: "pipe" });
15686
+ const stdoutPromise = new Response(proc.stdout).text();
15687
+ await proc.exited;
15688
+ const stdout = await stdoutPromise;
15689
+ const branches = stdout.split(`
15690
+ `).map((b) => b.trim().replace(/^\* /, "")).filter(Boolean);
15691
+ for (const branch of branches) {
15692
+ const parts = branch.split("/");
15693
+ if (parts.length < 4)
15694
+ continue;
15695
+ const teamName = parts[2];
15696
+ if (!teamName || !archivedNames.has(teamName))
15697
+ continue;
15698
+ try {
15699
+ const del = Bun.spawn(["git", "branch", "-D", branch], { cwd, stdout: "pipe", stderr: "pipe" });
15700
+ const exitCode = await del.exited;
15701
+ if (exitCode === 0) {
15702
+ removed++;
15703
+ log(`recovery:branch:deleted branch=${branch}`);
15704
+ }
15705
+ } catch {}
15706
+ }
15707
+ return { removed };
15708
+ }
15561
15709
 
15562
15710
  // src/state.ts
15563
15711
  class MemberRegistry {
@@ -15831,7 +15979,7 @@ function buildLeadSystemPrompt(db, teamId) {
15831
15979
  }
15832
15980
  lines.push("--- End Messages ---");
15833
15981
  }
15834
- 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.", "", "Before calling team_cleanup, verify teammates have committed their work.", "team_shutdown will warn you if a teammate has uncommitted changes.", "team_cleanup will block if any worktree has uncommitted changes \u2014 merge or commit first.");
15982
+ 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.", "", "MERGE WORKFLOW:", "After a teammate finishes and you shut them down, use team_merge to merge their branch.", "Do NOT tell teammates to commit \u2014 they handle that themselves.", "Do NOT run git merge manually \u2014 use team_merge which handles stashing and unstaging.", "team_cleanup will safety-net merge any branches you forgot, but prefer explicit team_merge.", "", "Before calling team_cleanup, verify teammates have committed their work.", "team_shutdown will warn you if a teammate has uncommitted changes.", "team_cleanup will block if any worktree has uncommitted changes \u2014 merge or commit first.");
15835
15983
  return lines.join(`
15836
15984
  `);
15837
15985
  }
@@ -15890,18 +16038,6 @@ function buildTeamCompactionContext(db, teamId, role, memberName) {
15890
16038
  return lines.join(`
15891
16039
  `);
15892
16040
  }
15893
-
15894
- // src/log.ts
15895
- var _client = null;
15896
- function initLog(client3) {
15897
- _client = client3;
15898
- }
15899
- function log(msg) {
15900
- if (!_client)
15901
- return;
15902
- _client.app.log({ service: "ensemble", level: "info", message: msg }).catch(() => {});
15903
- }
15904
-
15905
16041
  // src/types.ts
15906
16042
  function findTeamBySession(db, registry2, sessionId) {
15907
16043
  const entry = registry2.getBySession(sessionId);
@@ -16390,7 +16526,8 @@ async function executeTeamBroadcast(deps, args, sessionId) {
16390
16526
  }
16391
16527
 
16392
16528
  // src/tools/team-shutdown.ts
16393
- async function executeTeamShutdown(deps, args, sessionId, isDirty = checkWorktreeDirty) {
16529
+ init_merge_helper();
16530
+ async function executeTeamShutdown(deps, args, sessionId, isDirty = checkWorktreeDirty, preserve = preserveBranch) {
16394
16531
  const teamInfo = requireLead(deps, sessionId);
16395
16532
  const member = deps.db.query("SELECT session_id, status, worktree_branch, worktree_dir FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
16396
16533
  if (!member)
@@ -16399,8 +16536,8 @@ async function executeTeamShutdown(deps, args, sessionId, isDirty = checkWorktre
16399
16536
  throw new Error(`Teammate "${args.member}" is already shut down`);
16400
16537
  const force = args.force ?? false;
16401
16538
  if (member.status === "shutdown_requested") {
16402
- await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id);
16403
- const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : "";
16539
+ await preserveAndAbort(deps, teamInfo.teamId, teamInfo.teamName, args.member, member.session_id, member.worktree_branch, preserve);
16540
+ const branchInfo = getBranchInfo(deps, teamInfo.teamId, args.member);
16404
16541
  const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty);
16405
16542
  return `Force shut down "${args.member}".${branchInfo}${dirtyWarning}`;
16406
16543
  }
@@ -16411,29 +16548,51 @@ async function executeTeamShutdown(deps, args, sessionId, isDirty = checkWorktre
16411
16548
  isIdle = !sessionStatus || sessionStatus.type === "idle";
16412
16549
  } catch {}
16413
16550
  if (isIdle || force) {
16414
- await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id);
16415
- const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : "";
16551
+ await preserveAndAbort(deps, teamInfo.teamId, teamInfo.teamName, args.member, member.session_id, member.worktree_branch, preserve);
16552
+ const branchInfo = getBranchInfo(deps, teamInfo.teamId, args.member);
16416
16553
  const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty);
16417
16554
  return `Teammate "${args.member}" has been shut down.${branchInfo}${dirtyWarning}`;
16418
16555
  }
16556
+ if (member.worktree_branch) {
16557
+ const safeBranch = preservedBranchName(teamInfo.teamName, args.member);
16558
+ const ok = await preserve(member.worktree_branch, safeBranch, deps.directory);
16559
+ if (ok) {
16560
+ deps.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?", [safeBranch, teamInfo.teamId, args.member]);
16561
+ log(`shutdown:branch:preserved-graceful src=${member.worktree_branch} target=${safeBranch}`);
16562
+ }
16563
+ }
16419
16564
  try {
16420
- await deps.client.session.promptAsync({
16565
+ deps.client.session.promptAsync({
16421
16566
  sessionID: member.session_id,
16422
16567
  parts: [{
16423
16568
  type: "text",
16424
16569
  text: `[Shutdown requested]: The lead has requested you shut down. Finish your current task, send your final findings to the lead via team_message, then stop.`
16425
16570
  }]
16426
- });
16571
+ }).catch(() => {});
16427
16572
  } catch {}
16428
16573
  deps.db.run("UPDATE team_member SET status = 'shutdown_requested', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamInfo.teamId, args.member]);
16429
16574
  return `Shutdown requested for ${args.member}. They will finish current work and shut down. Call team_shutdown with force: true to abort immediately.`;
16430
16575
  }
16431
- async function abortAndShutdown(deps, teamId, memberName, sessionId) {
16576
+ async function preserveAndAbort(deps, teamId, teamName, memberName, sessionId, worktreeBranch, preserve) {
16577
+ if (worktreeBranch && !worktreeBranch.startsWith("ensemble/preserved/")) {
16578
+ const safeBranch = preservedBranchName(teamName, memberName);
16579
+ const ok = await preserve(worktreeBranch, safeBranch, deps.directory);
16580
+ if (ok) {
16581
+ deps.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?", [safeBranch, teamId, memberName]);
16582
+ log(`shutdown:branch:preserved src=${worktreeBranch} target=${safeBranch}`);
16583
+ } else {
16584
+ log(`shutdown:branch:preserve-failed src=${worktreeBranch} target=${safeBranch}`);
16585
+ }
16586
+ }
16432
16587
  try {
16433
16588
  await deps.client.session.abort({ sessionID: sessionId });
16434
16589
  } catch {}
16435
16590
  deps.db.run("UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamId, memberName]);
16436
16591
  }
16592
+ function getBranchInfo(deps, teamId, memberName) {
16593
+ const row = deps.db.query("SELECT worktree_branch FROM team_member WHERE team_id = ? AND name = ?").get(teamId, memberName);
16594
+ return row?.worktree_branch ? ` Changes preserved on branch: ${row.worktree_branch}. Use team_merge to merge.` : "";
16595
+ }
16437
16596
  async function getDirtyWarning(worktreeDir, memberName, isDirty) {
16438
16597
  if (!worktreeDir)
16439
16598
  return "";
@@ -16448,40 +16607,8 @@ Warning: ${memberName} has uncommitted changes in their worktree. Commit or merg
16448
16607
  }
16449
16608
 
16450
16609
  // src/tools/team-cleanup.ts
16451
- async function mergeAllBranches(branches, cwd) {
16452
- const merged = [];
16453
- const conflicted = [];
16454
- const stash = Bun.spawn(["git", "stash", "--include-untracked"], { cwd, stdout: "pipe", stderr: "pipe" });
16455
- const stashOut = await new Response(stash.stdout).text();
16456
- const stashExit = await stash.exited;
16457
- const didStash = stashExit === 0 && !stashOut.includes("No local changes");
16458
- for (const branch of branches) {
16459
- const merge2 = Bun.spawn(["git", "merge", "--squash", branch], { cwd, stdout: "pipe", stderr: "pipe" });
16460
- const stderrPromise = new Response(merge2.stderr).text();
16461
- const exit = await merge2.exited;
16462
- if (exit !== 0) {
16463
- const stderr = await stderrPromise;
16464
- log(`cleanup:merge:conflict branch=${branch} err=${stderr.trim()}`);
16465
- const abort = Bun.spawn(["git", "merge", "--abort"], { cwd, stdout: "pipe", stderr: "pipe" });
16466
- await abort.exited;
16467
- conflicted.push(branch);
16468
- } else {
16469
- const del = Bun.spawn(["git", "branch", "-D", branch], { cwd, stdout: "pipe", stderr: "pipe" });
16470
- await del.exited;
16471
- merged.push(branch);
16472
- }
16473
- }
16474
- if (merged.length > 0) {
16475
- const reset = Bun.spawn(["git", "reset", "HEAD"], { cwd, stdout: "pipe", stderr: "pipe" });
16476
- await reset.exited;
16477
- }
16478
- if (didStash) {
16479
- const pop = Bun.spawn(["git", "stash", "pop", "--index"], { cwd, stdout: "pipe", stderr: "pipe" });
16480
- await pop.exited;
16481
- }
16482
- return { merged, conflicted };
16483
- }
16484
- async function executeTeamCleanup(deps, args, sessionId, isDirty = checkWorktreeDirty, mergeAll = mergeAllBranches, mergeOnCleanup = true) {
16610
+ init_merge_helper();
16611
+ async function executeTeamCleanup(deps, args, sessionId, isDirty = checkWorktreeDirty, merge2 = mergeBranch, delBranch = deleteBranch, mergeOnCleanup = true) {
16485
16612
  const teamInfo = requireLead(deps, sessionId);
16486
16613
  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);
16487
16614
  const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
@@ -16513,24 +16640,35 @@ Commit or merge their work first, then call team_cleanup with acknowledge_uncomm
16513
16640
  }
16514
16641
  if (args.force) {
16515
16642
  for (const member of active) {
16643
+ if (member.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
16644
+ const safeBranch = preservedBranchName(teamInfo.teamName, member.name);
16645
+ const ok = await preserveBranch(member.worktree_branch, safeBranch, deps.directory);
16646
+ if (ok) {
16647
+ deps.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?", [safeBranch, teamInfo.teamId, member.name]);
16648
+ member.worktree_branch = safeBranch;
16649
+ }
16650
+ }
16516
16651
  try {
16517
16652
  await deps.client.session.abort({ sessionID: member.session_id });
16518
16653
  } catch {}
16519
16654
  }
16520
16655
  }
16521
- const branches = members.map((m) => m.worktree_branch).filter((b) => b !== null);
16522
- let mergeResult = "";
16523
- if (branches.length > 0 && mergeOnCleanup) {
16524
- const { merged, conflicted } = await mergeAll(branches, deps.directory);
16525
- const parts = [];
16526
- if (merged.length > 0) {
16527
- parts.push(`Merged ${merged.length} branch(es) into working directory (unstaged). Review changes with: git diff`);
16528
- }
16529
- if (conflicted.length > 0) {
16530
- parts.push(`Could not auto-merge: ${conflicted.join(", ")}. Merge manually with: git merge <branch>`);
16656
+ const unmerged = members.filter((m) => m.worktree_branch !== null);
16657
+ const merged = [];
16658
+ const conflicted = [];
16659
+ if (unmerged.length > 0 && mergeOnCleanup) {
16660
+ for (const member of unmerged) {
16661
+ const branch = member.worktree_branch;
16662
+ const result = await merge2(branch, deps.directory);
16663
+ if (result.ok) {
16664
+ await delBranch(branch, deps.directory);
16665
+ deps.db.run("UPDATE team_member SET worktree_branch = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name]);
16666
+ merged.push(`${member.name} (${branch})`);
16667
+ } else {
16668
+ log(`cleanup:merge:conflict member=${member.name} branch=${branch} err=${result.error}`);
16669
+ conflicted.push(`${member.name} (${branch})`);
16670
+ }
16531
16671
  }
16532
- mergeResult = parts.join(`
16533
- `);
16534
16672
  }
16535
16673
  for (const member of members) {
16536
16674
  if (member.workspace_id) {
@@ -16549,19 +16687,43 @@ Commit or merge their work first, then call team_cleanup with acknowledge_uncomm
16549
16687
  deps.db.run("UPDATE team SET status = 'archived', time_updated = ? WHERE id = ?", [Date.now(), teamInfo.teamId]);
16550
16688
  deps.registry.unregisterTeam(teamInfo.teamId);
16551
16689
  spawnFailures.delete(teamInfo.teamId);
16552
- if (mergeResult) {
16553
- return `Team "${teamInfo.teamName}" cleaned up.
16554
- ${mergeResult}`;
16690
+ const parts = [`Team "${teamInfo.teamName}" cleaned up.`];
16691
+ if (merged.length > 0) {
16692
+ parts.push(`Safety-net merged ${merged.length} unmerged branch(es): ${merged.join(", ")}. Review with: git diff`);
16555
16693
  }
16556
- if (branches.length > 0) {
16557
- const mergeCommands = branches.map((b) => ` git merge ${b}`).join(`
16694
+ if (conflicted.length > 0) {
16695
+ parts.push(`Could not auto-merge: ${conflicted.join(", ")}. Merge manually.`);
16696
+ }
16697
+ return parts.join(`
16558
16698
  `);
16559
- return `Team "${teamInfo.teamName}" cleaned up. Worktrees removed.
16699
+ }
16700
+
16701
+ // src/tools/team-merge.ts
16702
+ init_merge_helper();
16703
+ async function executeTeamMerge(deps, args, sessionId, merge2 = mergeBranch, delBranch = deleteBranch) {
16704
+ const teamInfo = requireLead(deps, sessionId);
16705
+ const member = deps.db.query("SELECT status, worktree_branch FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
16706
+ if (!member)
16707
+ throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
16708
+ if (member.status !== "shutdown" && member.status !== "error") {
16709
+ throw new Error(`Teammate "${args.member}" is still active (status: ${member.status}). Shut them down first with team_shutdown.`);
16710
+ }
16711
+ if (!member.worktree_branch) {
16712
+ throw new Error(`No branch to merge for "${args.member}". They may not have a worktree, or their work was already merged.`);
16713
+ }
16714
+ const branch = member.worktree_branch;
16715
+ log(`merge:start member=${args.member} branch=${branch}`);
16716
+ const result = await merge2(branch, deps.directory);
16717
+ if (!result.ok) {
16718
+ return `Merge conflict with ${args.member}'s branch (${branch}). Resolve manually:
16719
+ git merge --squash ${branch}
16560
16720
 
16561
- Merge teammate branches:
16562
- ${mergeCommands}`;
16721
+ Error: ${result.error}`;
16563
16722
  }
16564
- return `Team "${teamInfo.teamName}" cleaned up.`;
16723
+ await delBranch(branch, deps.directory);
16724
+ deps.db.run("UPDATE team_member SET worktree_branch = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, args.member]);
16725
+ log(`merge:done member=${args.member} branch=${branch}`);
16726
+ return `Merged ${args.member}'s changes into working directory (unstaged). Review with: git diff`;
16565
16727
  }
16566
16728
 
16567
16729
  // src/tools/team-tasks-list.ts
@@ -16823,6 +16985,7 @@ class TokenBucket {
16823
16985
  }
16824
16986
 
16825
16987
  // src/watchdog.ts
16988
+ init_merge_helper();
16826
16989
  class Watchdog {
16827
16990
  db;
16828
16991
  client;
@@ -16833,6 +16996,7 @@ class Watchdog {
16833
16996
  stallThresholdMs;
16834
16997
  stallMinSteps;
16835
16998
  stallTokenThreshold;
16999
+ cwd;
16836
17000
  timer;
16837
17001
  constructor(opts) {
16838
17002
  this.db = opts.db;
@@ -16844,6 +17008,7 @@ class Watchdog {
16844
17008
  this.stallThresholdMs = opts.stallThresholdMs ?? 0;
16845
17009
  this.stallMinSteps = opts.stallMinSteps ?? 3;
16846
17010
  this.stallTokenThreshold = opts.stallTokenThreshold ?? 500;
17011
+ this.cwd = opts.cwd;
16847
17012
  }
16848
17013
  static STALE_THRESHOLD_MS = Number(process.env.STALE_WORKTREE_THRESHOLD_MS) || 300000;
16849
17014
  async cleanupStaleWorktrees() {
@@ -16907,13 +17072,21 @@ class Watchdog {
16907
17072
  if (this.ttlMs === 0)
16908
17073
  return;
16909
17074
  const cutoff = Date.now() - this.ttlMs;
16910
- const stale = this.db.query(`SELECT tm.team_id, tm.name, tm.session_id
17075
+ const stale = this.db.query(`SELECT tm.team_id, tm.name, tm.session_id, tm.worktree_branch, t.name as team_name
16911
17076
  FROM team_member tm
16912
17077
  JOIN team t ON tm.team_id = t.id
16913
17078
  WHERE t.status = 'active'
16914
17079
  AND tm.status = 'busy'
16915
17080
  AND tm.time_updated < ?`).all(cutoff);
16916
17081
  for (const member of stale) {
17082
+ if (this.cwd && member.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
17083
+ const safeBranch = preservedBranchName(member.team_name, member.name);
17084
+ const ok = await preserveBranch(member.worktree_branch, safeBranch, this.cwd);
17085
+ if (ok) {
17086
+ this.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?", [safeBranch, member.team_id, member.name]);
17087
+ log(`watchdog:branch:preserved src=${member.worktree_branch} target=${safeBranch}`);
17088
+ }
17089
+ }
16917
17090
  this.db.run("UPDATE team_member SET status = 'error', execution_status = 'timed_out', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), member.team_id, member.name]);
16918
17091
  try {
16919
17092
  await this.client.session.abort({ sessionID: member.session_id });
@@ -16964,7 +17137,7 @@ var plugin = async (input) => {
16964
17137
  const deps = { db, registry: registry2, tracker, client: client3, directory: input.directory };
16965
17138
  if (!isWorktreeInstance(input.directory)) {
16966
17139
  log("init:recovery:start (main instance)");
16967
- const recovery = await recoverStaleMembers(db, client3);
17140
+ const recovery = await recoverStaleMembers(db, client3, input.directory);
16968
17141
  if (recovery.interrupted > 0) {
16969
17142
  log(`init:recovery:interrupted=${recovery.interrupted}`);
16970
17143
  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();
@@ -16978,6 +17151,9 @@ var plugin = async (input) => {
16978
17151
  recoverOrphanedWorktrees(db, client3).catch((err) => {
16979
17152
  log(`init:recover-worktrees:failed err=${err instanceof Error ? err.message : String(err)}`);
16980
17153
  });
17154
+ recoverOrphanedBranches(db, input.directory).catch((err) => {
17155
+ log(`init:recover-branches:failed err=${err instanceof Error ? err.message : String(err)}`);
17156
+ });
16981
17157
  log("init:recovery:done");
16982
17158
  } else {
16983
17159
  log(`init:skip-recovery (worktree instance: ${input.directory})`);
@@ -16996,7 +17172,8 @@ var plugin = async (input) => {
16996
17172
  progressTracker,
16997
17173
  stallThresholdMs: config2.stallThresholdMs,
16998
17174
  stallMinSteps: config2.stallMinSteps,
16999
- stallTokenThreshold: config2.stallTokenThreshold
17175
+ stallTokenThreshold: config2.stallTokenThreshold,
17176
+ cwd: input.directory
17000
17177
  });
17001
17178
  watchdog.start();
17002
17179
  return {
@@ -17031,12 +17208,22 @@ var plugin = async (input) => {
17031
17208
  });
17032
17209
  } catch {}
17033
17210
  } else if (transition.to === "busy_while_shutdown") {
17034
- const entry = registry2.getBySession(sessionID);
17035
- if (entry) {
17036
- try {
17037
- await client3.session.abort({ sessionID });
17038
- } catch {}
17211
+ const member = deps.db.query("SELECT worktree_branch, name, team_id FROM team_member WHERE session_id = ?").get(sessionID);
17212
+ if (member?.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
17213
+ const teamRow = deps.db.query("SELECT name FROM team WHERE id = ?").get(member.team_id);
17214
+ if (teamRow) {
17215
+ const { preserveBranch: preserve, preservedBranchName: branchName } = await Promise.resolve().then(() => (init_merge_helper(), exports_merge_helper));
17216
+ const safeBranch = branchName(teamRow.name, member.name);
17217
+ const ok = await preserve(member.worktree_branch, safeBranch, deps.directory);
17218
+ if (ok) {
17219
+ deps.db.run("UPDATE team_member SET worktree_branch = ? WHERE team_id = ? AND name = ?", [safeBranch, member.team_id, member.name]);
17220
+ log(`busy_while_shutdown:branch:preserved src=${member.worktree_branch} target=${safeBranch}`);
17221
+ }
17222
+ }
17039
17223
  }
17224
+ try {
17225
+ await client3.session.abort({ sessionID });
17226
+ } catch {}
17040
17227
  }
17041
17228
  await notifyWorkingProgress(client3, db, transition.teamId);
17042
17229
  }
@@ -17129,7 +17316,7 @@ var plugin = async (input) => {
17129
17316
  }
17130
17317
  }),
17131
17318
  team_spawn: tool({
17132
- description: "Spawn a new teammate that works in parallel. The teammate starts immediately with the given prompt. " + "Each teammate gets their own git worktree for file isolation. " + "Teammates work asynchronously and will message you when done. Do not poll for their status.",
17319
+ description: "Spawn a new teammate that works in parallel. The teammate starts immediately with the given prompt. Each teammate gets their own git worktree for file isolation. Teammates work asynchronously and will message you when done. Do not poll for their status.",
17133
17320
  args: {
17134
17321
  name: tool.schema.string().describe("Teammate name (lowercase alphanumeric with hyphens)"),
17135
17322
  agent: tool.schema.string().default("build").describe("Agent type (e.g. 'build', 'plan', 'explore')"),
@@ -17233,7 +17420,7 @@ var plugin = async (input) => {
17233
17420
  }
17234
17421
  }),
17235
17422
  team_shutdown: tool({
17236
- description: "Request a teammate to shut down. The teammate finishes current work then stops. " + "Pass force: true to abort immediately without waiting.",
17423
+ description: "Request a teammate to shut down. The teammate finishes current work then stops. Pass force: true to abort immediately without waiting.",
17237
17424
  args: {
17238
17425
  member: tool.schema.string().describe("Teammate name to shut down"),
17239
17426
  force: tool.schema.boolean().default(false).describe("Force immediate abort without waiting for current work to finish")
@@ -17255,14 +17442,26 @@ var plugin = async (input) => {
17255
17442
  acknowledge_uncommitted: tool.schema.boolean().default(false)
17256
17443
  },
17257
17444
  async execute(args, ctx) {
17258
- const result = await executeTeamCleanup(deps, args, ctx.sessionID, undefined, undefined, config2.mergeOnCleanup);
17445
+ const result = await executeTeamCleanup(deps, args, ctx.sessionID, undefined, undefined, undefined, config2.mergeOnCleanup);
17259
17446
  const blocked = result.includes("uncommitted");
17260
17447
  ctx.metadata({ title: blocked ? "Cleanup blocked \u2014 uncommitted changes" : "Team cleaned up" });
17261
17448
  return result;
17262
17449
  }
17263
17450
  }),
17451
+ team_merge: tool({
17452
+ description: "Merge a shutdown teammate's branch into the working directory as unstaged changes. Use this after team_shutdown to review and integrate a teammate's work. The teammate must be shut down first.",
17453
+ args: {
17454
+ member: tool.schema.string().describe("Teammate name whose branch to merge")
17455
+ },
17456
+ async execute(args, ctx) {
17457
+ const result = await executeTeamMerge(deps, args, ctx.sessionID);
17458
+ const conflict = result.includes("conflict");
17459
+ ctx.metadata({ title: conflict ? `Merge conflict: ${args.member}` : `Merged ${args.member}` });
17460
+ return result;
17461
+ }
17462
+ }),
17264
17463
  team_status: tool({
17265
- description: "View team members with their current status, agent type, and session IDs. " + "Use this to check who is working, idle, or shut down. Includes a task summary.",
17464
+ description: "View team members with their current status, agent type, and session IDs. Use this to check who is working, idle, or shut down. Includes a task summary.",
17266
17465
  args: {},
17267
17466
  async execute(_args, ctx) {
17268
17467
  const result = await executeTeamStatus(deps, ctx.sessionID);
@@ -17274,7 +17473,7 @@ var plugin = async (input) => {
17274
17473
  }
17275
17474
  }),
17276
17475
  team_view: tool({
17277
- description: "Navigate the TUI to a teammate's session so you can see what they are doing. " + "Use the session picker (ctrl+p) to return to the lead session.",
17476
+ description: "Navigate the TUI to a teammate's session so you can see what they are doing. Use the session picker (ctrl+p) to return to the lead session.",
17278
17477
  args: {
17279
17478
  member: tool.schema.string().describe("Teammate name to view")
17280
17479
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
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",