@hueyexe/opencode-ensemble 0.12.0 → 0.12.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-477%20passing-brightgreen.svg)]()
5
+ [![tests](https://img.shields.io/badge/tests-482%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)
@@ -15,7 +15,7 @@ Plugin built on the public OpenCode SDK. No internal dependencies.
15
15
 
16
16
  ```json
17
17
  {
18
- "plugin": ["@hueyexe/opencode-ensemble@0.12.0"]
18
+ "plugin": ["@hueyexe/opencode-ensemble@0.12.1"]
19
19
  }
20
20
  ```
21
21
 
@@ -136,7 +136,7 @@ Add to your OpenCode config with a pinned version. Project-level or global.
136
136
 
137
137
  ```json
138
138
  {
139
- "plugin": ["@hueyexe/opencode-ensemble@0.12.0"]
139
+ "plugin": ["@hueyexe/opencode-ensemble@0.12.1"]
140
140
  }
141
141
  ```
142
142
 
@@ -144,7 +144,7 @@ Add to your OpenCode config with a pinned version. Project-level or global.
144
144
 
145
145
  ```json
146
146
  {
147
- "plugin": ["@hueyexe/opencode-ensemble@0.12.0"]
147
+ "plugin": ["@hueyexe/opencode-ensemble@0.12.1"]
148
148
  }
149
149
  ```
150
150
 
@@ -201,7 +201,7 @@ Build with `bun run build`, then restart OpenCode to pick up changes.
201
201
  | `team_create` | Create a team. Caller becomes the lead. |
202
202
  | `team_spawn` | Start a new teammate with a task. Supports `plan_approval` mode. |
203
203
  | `team_shutdown` | Ask a teammate to stop. Preserves their branch before aborting. Supports `force` flag. |
204
- | `team_merge` | Merge a shutdown teammate's branch into working directory (unstaged). |
204
+ | `team_merge` | Merge a shutdown teammate's branch into working directory (unstaged). Blocks if you have local changes to overlapping files. |
205
205
  | `team_cleanup` | Remove the team when done. Safety-net merges any forgotten branches. |
206
206
  | `team_status` | See all members, their status, and a task summary. |
207
207
  | `team_view` | Switch the TUI to a teammate's session. |
@@ -252,6 +252,7 @@ Teammate messages arrive in the lead's session as `[Team message from alice]: ..
252
252
  - **Stall detection**: detects teammates making no progress (low output tokens or no communication) and escalates to the lead
253
253
  - **Peer-to-peer communication**: teammates can message each other directly, with idle-flush delivery and chatty agent detection
254
254
  - **Auto-merge on cleanup**: worktree branches are squash-merged into your working directory as unstaged changes for review
255
+ - **Overlap detection**: `team_merge` blocks when you have local changes to files the agent also modified, preventing silent overwrites
255
256
  - **Spawn circuit breaker**: stops retrying after 3 consecutive spawn failures
256
257
  - **Graceful shutdown**: busy teammates receive a shutdown message and finish their current work. Use `force: true` to abort immediately.
257
258
  - **Rate limiting**: token bucket (configurable via config file or `OPENCODE_ENSEMBLE_RATE_LIMIT`, default 10 tokens/sec)
@@ -405,7 +406,7 @@ Same coordination model (shared tasks, peer messaging, lead coordination) with s
405
406
  ```bash
406
407
  bun install
407
408
  bun run typecheck
408
- bun test # 477 tests
409
+ bun test # 482 tests
409
410
  bun run build
410
411
  ```
411
412
 
package/dist/index.js CHANGED
@@ -36,6 +36,7 @@ __export(exports_merge_helper, {
36
36
  gitStashPop: () => gitStashPop,
37
37
  gitStash: () => gitStash,
38
38
  gitReset: () => gitReset,
39
+ getOverlappingFiles: () => getOverlappingFiles,
39
40
  deleteBranch: () => deleteBranch
40
41
  });
41
42
  async function preserveBranch(sourceBranch, targetBranch, cwd) {
@@ -112,6 +113,22 @@ async function mergeBranch(branch, cwd) {
112
113
  }
113
114
  return { ok: true };
114
115
  }
116
+ async function getOverlappingFiles(branch, cwd) {
117
+ const run = async (args) => {
118
+ const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
119
+ const out = await new Response(proc.stdout).text();
120
+ const exit = await proc.exited;
121
+ if (exit !== 0)
122
+ throw new Error(`git ${args.join(" ")} failed with exit code ${exit}`);
123
+ return out.split(`
124
+ `).filter(Boolean);
125
+ };
126
+ const agentFiles = new Set(await run(["diff", "--name-only", "HEAD", branch]));
127
+ const localChanged = await run(["diff", "--name-only", "HEAD"]);
128
+ const localUntracked = await run(["ls-files", "--others", "--exclude-standard"]);
129
+ const localFiles = [...new Set([...localChanged, ...localUntracked])];
130
+ return localFiles.filter((f) => agentFiles.has(f));
131
+ }
115
132
  function preservedBranchName(teamName, memberName) {
116
133
  return `ensemble/preserved/${teamName}/${memberName}`;
117
134
  }
@@ -17324,7 +17341,7 @@ Warning: ${memberName} has uncommitted changes in their worktree. Commit or merg
17324
17341
 
17325
17342
  // src/tools/team-cleanup.ts
17326
17343
  init_merge_helper();
17327
- async function executeTeamCleanup(deps, args, sessionId, isDirty = checkWorktreeDirty, merge2 = mergeBranch, delBranch = deleteBranch, mergeOnCleanup = true) {
17344
+ async function executeTeamCleanup(deps, args, sessionId, isDirty = checkWorktreeDirty, merge2 = mergeBranch, delBranch = deleteBranch, mergeOnCleanup = true, overlapCheck = getOverlappingFiles) {
17328
17345
  const teamInfo = requireLead(deps, sessionId);
17329
17346
  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);
17330
17347
  const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
@@ -17372,9 +17389,16 @@ Commit or merge their work first, then call team_cleanup with acknowledge_uncomm
17372
17389
  const unmerged = members.filter((m) => m.worktree_branch !== null);
17373
17390
  const merged = [];
17374
17391
  const conflicted = [];
17392
+ const overlapWarnings = [];
17375
17393
  if (unmerged.length > 0 && mergeOnCleanup) {
17376
17394
  for (const member of unmerged) {
17377
17395
  const branch = member.worktree_branch;
17396
+ try {
17397
+ const overlap = await overlapCheck(branch, deps.directory);
17398
+ if (overlap.length > 0) {
17399
+ overlapWarnings.push(`${member.name}: ${overlap.join(", ")}`);
17400
+ }
17401
+ } catch {}
17378
17402
  const result = await merge2(branch, deps.directory);
17379
17403
  if (result.ok) {
17380
17404
  await delBranch(branch, deps.directory);
@@ -17410,13 +17434,19 @@ Commit or merge their work first, then call team_cleanup with acknowledge_uncomm
17410
17434
  if (conflicted.length > 0) {
17411
17435
  parts.push(`Could not auto-merge: ${conflicted.join(", ")}. Merge manually.`);
17412
17436
  }
17437
+ if (overlapWarnings.length > 0) {
17438
+ parts.push(`Warning: safety-net merge overwrote local changes to overlapping files:
17439
+ ${overlapWarnings.map((w) => ` - ${w}`).join(`
17440
+ `)}
17441
+ Check git stash list or git diff to recover.`);
17442
+ }
17413
17443
  return parts.join(`
17414
17444
  `);
17415
17445
  }
17416
17446
 
17417
17447
  // src/tools/team-merge.ts
17418
17448
  init_merge_helper();
17419
- async function executeTeamMerge(deps, args, sessionId, merge2 = mergeBranch, delBranch = deleteBranch) {
17449
+ async function executeTeamMerge(deps, args, sessionId, merge2 = mergeBranch, delBranch = deleteBranch, overlapCheck = getOverlappingFiles) {
17420
17450
  const teamInfo = requireLead(deps, sessionId);
17421
17451
  const member = deps.db.query("SELECT status, worktree_branch FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
17422
17452
  if (!member)
@@ -17429,6 +17459,20 @@ async function executeTeamMerge(deps, args, sessionId, merge2 = mergeBranch, del
17429
17459
  }
17430
17460
  const branch = member.worktree_branch;
17431
17461
  log(`merge:start member=${args.member} branch=${branch}`);
17462
+ try {
17463
+ const overlap = await overlapCheck(branch, deps.directory);
17464
+ if (overlap.length > 0) {
17465
+ const files = overlap.map((f) => ` - ${f}`).join(`
17466
+ `);
17467
+ return `Cannot merge ${args.member}'s branch \u2014 you have local changes to files that ${args.member} also modified:
17468
+ ${files}
17469
+
17470
+ Commit or stash your changes to these files first, then retry team_merge.
17471
+ Branch preserved: ${branch}`;
17472
+ }
17473
+ } catch {
17474
+ log(`merge:overlap-check:failed member=${args.member} branch=${branch}`);
17475
+ }
17432
17476
  const result = await merge2(branch, deps.directory);
17433
17477
  if (!result.ok) {
17434
17478
  return `Merge conflict with ${args.member}'s branch (${branch}). Resolve manually:
@@ -17439,7 +17483,10 @@ Error: ${result.error}`;
17439
17483
  await delBranch(branch, deps.directory);
17440
17484
  deps.db.run("UPDATE team_member SET worktree_branch = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, args.member]);
17441
17485
  log(`merge:done member=${args.member} branch=${branch}`);
17442
- return `Merged ${args.member}'s changes into working directory (unstaged). Review with: git diff`;
17486
+ const msg = `Merged ${args.member}'s changes into working directory (unstaged). Review with: git diff`;
17487
+ return result.error ? `${msg}
17488
+
17489
+ Warning: ${result.error}` : msg;
17443
17490
  }
17444
17491
 
17445
17492
  // src/tools/team-tasks-list.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.12.0",
3
+ "version": "0.12.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",
@@ -9,6 +9,9 @@ export interface MergeResult {
9
9
  /** Injectable function for testing. */
10
10
  export type MergeBranchFn = (branch: string, cwd: string) => Promise<MergeResult>
11
11
 
12
+ /** Injectable function for overlap detection before merge. */
13
+ export type OverlapCheckFn = (branch: string, cwd: string) => Promise<string[]>
14
+
12
15
  /** Injectable function for preserving a branch before worktree deletion. */
13
16
  export type PreserveBranchFn = (sourceBranch: string, targetBranch: string, cwd: string) => Promise<boolean>
14
17
 
@@ -118,6 +121,25 @@ export async function mergeBranch(branch: string, cwd: string): Promise<MergeRes
118
121
  return { ok: true }
119
122
  }
120
123
 
124
+ /**
125
+ * Detect files that both the lead (local changes) and the agent (branch) modified.
126
+ * Returns the list of overlapping file paths, or empty if safe to merge.
127
+ */
128
+ export async function getOverlappingFiles(branch: string, cwd: string): Promise<string[]> {
129
+ const run = async (args: string[]) => {
130
+ const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" })
131
+ const out = await new Response(proc.stdout).text()
132
+ const exit = await proc.exited
133
+ if (exit !== 0) throw new Error(`git ${args.join(" ")} failed with exit code ${exit}`)
134
+ return out.split("\n").filter(Boolean)
135
+ }
136
+ const agentFiles = new Set(await run(["diff", "--name-only", "HEAD", branch]))
137
+ const localChanged = await run(["diff", "--name-only", "HEAD"])
138
+ const localUntracked = await run(["ls-files", "--others", "--exclude-standard"])
139
+ const localFiles = [...new Set([...localChanged, ...localUntracked])]
140
+ return localFiles.filter(f => agentFiles.has(f))
141
+ }
142
+
121
143
  /**
122
144
  * Build the preserved branch name for a team member.
123
145
  */
@@ -2,8 +2,8 @@ import type { ToolDeps } from "../types"
2
2
  import { requireLead, checkWorktreeDirty } from "./shared"
3
3
  import type { IsDirtyFn } from "./shared"
4
4
  import { spawnFailures } from "./team-spawn"
5
- import { mergeBranch, deleteBranch, preserveBranch, preservedBranchName } from "./merge-helper"
6
- import type { MergeBranchFn, DeleteBranchFn, PreserveBranchFn } from "./merge-helper"
5
+ import { mergeBranch, deleteBranch, preserveBranch, preservedBranchName, getOverlappingFiles } from "./merge-helper"
6
+ import type { MergeBranchFn, DeleteBranchFn, PreserveBranchFn, OverlapCheckFn } from "./merge-helper"
7
7
  import { log } from "../log"
8
8
 
9
9
  /**
@@ -19,6 +19,7 @@ export async function executeTeamCleanup(
19
19
  merge: MergeBranchFn = mergeBranch,
20
20
  delBranch: DeleteBranchFn = deleteBranch,
21
21
  mergeOnCleanup = true,
22
+ overlapCheck: OverlapCheckFn = getOverlappingFiles,
22
23
  ): Promise<string> {
23
24
  const teamInfo = requireLead(deps, sessionId)
24
25
 
@@ -75,10 +76,18 @@ export async function executeTeamCleanup(
75
76
  const unmerged = members.filter(m => m.worktree_branch !== null)
76
77
  const merged: string[] = []
77
78
  const conflicted: string[] = []
79
+ const overlapWarnings: string[] = []
78
80
 
79
81
  if (unmerged.length > 0 && mergeOnCleanup) {
80
82
  for (const member of unmerged) {
81
83
  const branch = member.worktree_branch!
84
+ // Warn (but don't block) if lead has local changes to overlapping files
85
+ try {
86
+ const overlap = await overlapCheck(branch, deps.directory)
87
+ if (overlap.length > 0) {
88
+ overlapWarnings.push(`${member.name}: ${overlap.join(", ")}`)
89
+ }
90
+ } catch { /* best effort */ }
82
91
  const result = await merge(branch, deps.directory)
83
92
  if (result.ok) {
84
93
  await delBranch(branch, deps.directory)
@@ -122,5 +131,8 @@ export async function executeTeamCleanup(
122
131
  if (conflicted.length > 0) {
123
132
  parts.push(`Could not auto-merge: ${conflicted.join(", ")}. Merge manually.`)
124
133
  }
134
+ if (overlapWarnings.length > 0) {
135
+ parts.push(`Warning: safety-net merge overwrote local changes to overlapping files:\n${overlapWarnings.map(w => ` - ${w}`).join("\n")}\nCheck git stash list or git diff to recover.`)
136
+ }
125
137
  return parts.join("\n")
126
138
  }
@@ -1,7 +1,7 @@
1
1
  import type { ToolDeps } from "../types"
2
2
  import { requireLead } from "./shared"
3
- import { mergeBranch, deleteBranch } from "./merge-helper"
4
- import type { MergeBranchFn, DeleteBranchFn } from "./merge-helper"
3
+ import { mergeBranch, deleteBranch, getOverlappingFiles } from "./merge-helper"
4
+ import type { MergeBranchFn, DeleteBranchFn, OverlapCheckFn } from "./merge-helper"
5
5
  import { log } from "../log"
6
6
 
7
7
  /**
@@ -14,6 +14,7 @@ export async function executeTeamMerge(
14
14
  sessionId: string,
15
15
  merge: MergeBranchFn = mergeBranch,
16
16
  delBranch: DeleteBranchFn = deleteBranch,
17
+ overlapCheck: OverlapCheckFn = getOverlappingFiles,
17
18
  ): Promise<string> {
18
19
  const teamInfo = requireLead(deps, sessionId)
19
20
 
@@ -32,6 +33,17 @@ export async function executeTeamMerge(
32
33
  const branch = member.worktree_branch
33
34
  log(`merge:start member=${args.member} branch=${branch}`)
34
35
 
36
+ // Block merge if lead has local changes to files the agent also modified
37
+ try {
38
+ const overlap = await overlapCheck(branch, deps.directory)
39
+ if (overlap.length > 0) {
40
+ const files = overlap.map(f => ` - ${f}`).join("\n")
41
+ return `Cannot merge ${args.member}'s branch — you have local changes to files that ${args.member} also modified:\n${files}\n\nCommit or stash your changes to these files first, then retry team_merge.\nBranch preserved: ${branch}`
42
+ }
43
+ } catch {
44
+ log(`merge:overlap-check:failed member=${args.member} branch=${branch}`)
45
+ }
46
+
35
47
  const result = await merge(branch, deps.directory)
36
48
  if (!result.ok) {
37
49
  return `Merge conflict with ${args.member}'s branch (${branch}). Resolve manually:\n git merge --squash ${branch}\n\nError: ${result.error}`
@@ -45,5 +57,6 @@ export async function executeTeamMerge(
45
57
  )
46
58
 
47
59
  log(`merge:done member=${args.member} branch=${branch}`)
48
- return `Merged ${args.member}'s changes into working directory (unstaged). Review with: git diff`
60
+ const msg = `Merged ${args.member}'s changes into working directory (unstaged). Review with: git diff`
61
+ return result.error ? `${msg}\n\nWarning: ${result.error}` : msg
49
62
  }