@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 +7 -6
- package/dist/index.js +50 -3
- package/package.json +1 -1
- package/src/tools/merge-helper.ts +22 -0
- package/src/tools/team-cleanup.ts +14 -2
- package/src/tools/team-merge.ts +16 -3
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
|
|
4
4
|
[](https://www.npmjs.com/package/@hueyexe/opencode-ensemble)
|
|
5
|
-
[]()
|
|
6
6
|
[]()
|
|
7
7
|
[]()
|
|
8
8
|
[](./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.
|
|
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.
|
|
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.
|
|
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 #
|
|
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
|
-
|
|
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.
|
|
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
|
}
|
package/src/tools/team-merge.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|