@hueyexe/opencode-ensemble 0.3.1 → 0.5.0
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 +25 -2
- package/dist/index.js +301 -63
- package/package.json +3 -3
- package/src/index.ts +53 -3
- package/src/recovery.ts +37 -0
- package/src/schema.ts +6 -0
- package/src/system-prompt.ts +111 -0
- package/src/tools/team-cleanup.ts +22 -5
- package/src/tools/team-message.ts +36 -5
- package/src/tools/team-shutdown.ts +68 -36
- package/src/tools/team-spawn.ts +103 -15
- package/src/tools/team-status.ts +5 -3
- package/src/types.ts +8 -0
package/README.md
CHANGED
|
@@ -163,9 +163,10 @@ Teammate messages arrive in the lead's session as `[Team message from alice]: ..
|
|
|
163
163
|
|
|
164
164
|
- **SQLite** (`bun:sqlite`, WAL mode) for teams, members, tasks, and messages
|
|
165
165
|
- **promptAsync** for message delivery: injects a message and starts the prompt loop in one call
|
|
166
|
+
- **Git worktree isolation**: each teammate gets their own worktree by default, so multiple agents can edit files without conflicts. The lead merges branches after cleanup. Opt out with `worktree: false` for read-only agents.
|
|
166
167
|
- **Sub-agent isolation**: teammates' sub-agents can't use team tools (parent chain tracking, max depth 10)
|
|
167
|
-
- **Crash recovery**: stale busy members marked as errored on restart, orphaned sessions aborted, undelivered messages redelivered
|
|
168
|
-
- **Spawn rollback**: if the initial prompt fails, the member
|
|
168
|
+
- **Crash recovery**: stale busy members marked as errored on restart, orphaned sessions aborted, orphaned worktrees cleaned up, undelivered messages redelivered
|
|
169
|
+
- **Spawn rollback**: if the initial prompt fails, the member, session, and worktree are all cleaned up
|
|
169
170
|
- **Timeout watchdog**: teammates stuck busy beyond the TTL are automatically timed out and aborted
|
|
170
171
|
- **Shutdown resilience**: abort failures handled gracefully, busy-after-shutdown triggers re-abort
|
|
171
172
|
- **Rate limiting**: token bucket (configurable via `OPENCODE_ENSEMBLE_RATE_LIMIT`, default 10 tokens/sec)
|
|
@@ -186,6 +187,28 @@ OPENCODE_ENSEMBLE_TIMEOUT=3600000
|
|
|
186
187
|
OPENCODE_ENSEMBLE_TIMEOUT=0
|
|
187
188
|
```
|
|
188
189
|
|
|
190
|
+
### Best practices
|
|
191
|
+
|
|
192
|
+
- Start with 2-4 teammates for most tasks. More agents means more coordination overhead.
|
|
193
|
+
- Give each teammate 3-5 specific, self-contained tasks. Vague prompts produce vague results.
|
|
194
|
+
- Start with research before implementation — spawn an explore agent first to understand the codebase, then spawn build agents with that context.
|
|
195
|
+
- Use `worktree: false` for read-only agents (research, review, code analysis). They don't need file isolation.
|
|
196
|
+
- Use `plan_approval: true` for risky changes — the teammate sends a plan first, you review and approve before they write any code.
|
|
197
|
+
- Let the lead coordinate. Don't micromanage teammates — they message you when done or when they're blocked.
|
|
198
|
+
- After spawning, tell the user what you've set up and wait for results. Don't poll `team_status` in a loop.
|
|
199
|
+
|
|
200
|
+
### How this differs from Claude Code agent teams
|
|
201
|
+
|
|
202
|
+
Claude Code has built-in agent teams. This plugin takes the same coordination model (shared tasks, peer messaging, lead coordination) and adds:
|
|
203
|
+
|
|
204
|
+
- **Git worktree isolation by default** — each teammate gets their own branch, no merge conflicts between parallel agents
|
|
205
|
+
- **System prompt injection** — the lead's system prompt is updated with team state (member statuses, task progress) so it stays aware across turns
|
|
206
|
+
- **Compaction safety** — team context is preserved when sessions get long and OpenCode compacts the conversation
|
|
207
|
+
- **Team-aware shell environment** — teammate shells get `ENSEMBLE_TEAM`, `ENSEMBLE_MEMBER`, `ENSEMBLE_ROLE`, and `ENSEMBLE_BRANCH` variables
|
|
208
|
+
- **Graceful shutdown** — teammates finish their current work before stopping, with a force flag for emergencies
|
|
209
|
+
- **Plan approval mode** — review teammate plans before they write code, useful for risky or unfamiliar changes
|
|
210
|
+
- **Works today as a plugin** — no waiting for upstream features, install and go
|
|
211
|
+
|
|
189
212
|
## Development
|
|
190
213
|
|
|
191
214
|
```bash
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
var __defProp = Object.defineProperty;
|
|
3
|
+
var __returnValue = (v) => v;
|
|
4
|
+
function __exportSetter(name, newValue) {
|
|
5
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
6
|
+
}
|
|
3
7
|
var __export = (target, all) => {
|
|
4
8
|
for (var name in all)
|
|
5
9
|
__defProp(target, name, {
|
|
6
10
|
get: all[name],
|
|
7
11
|
enumerable: true,
|
|
8
12
|
configurable: true,
|
|
9
|
-
set: (
|
|
13
|
+
set: __exportSetter.bind(all, name)
|
|
10
14
|
});
|
|
11
15
|
};
|
|
12
16
|
|
|
@@ -12331,7 +12335,7 @@ function tool(input) {
|
|
|
12331
12335
|
}
|
|
12332
12336
|
tool.schema = exports_external;
|
|
12333
12337
|
// src/index.ts
|
|
12334
|
-
import
|
|
12338
|
+
import path3 from "path";
|
|
12335
12339
|
import { mkdirSync } from "fs";
|
|
12336
12340
|
|
|
12337
12341
|
// src/db.ts
|
|
@@ -12405,7 +12409,11 @@ var MIGRATIONS = [
|
|
|
12405
12409
|
WHERE delivered = 0;
|
|
12406
12410
|
`,
|
|
12407
12411
|
`ALTER TABLE team_message ADD COLUMN read INTEGER NOT NULL DEFAULT 0;
|
|
12408
|
-
CREATE INDEX IF NOT EXISTS team_message_unread_idx ON team_message(team_id, read) WHERE read = 0
|
|
12412
|
+
CREATE INDEX IF NOT EXISTS team_message_unread_idx ON team_message(team_id, read) WHERE read = 0;`,
|
|
12413
|
+
`ALTER TABLE team_member ADD COLUMN worktree_dir TEXT;
|
|
12414
|
+
ALTER TABLE team_member ADD COLUMN worktree_branch TEXT;`,
|
|
12415
|
+
`ALTER TABLE team_member ADD COLUMN plan_approval TEXT NOT NULL DEFAULT 'none'
|
|
12416
|
+
CHECK(plan_approval IN ('none', 'pending', 'approved', 'rejected'));`
|
|
12409
12417
|
];
|
|
12410
12418
|
function applyMigrations(db) {
|
|
12411
12419
|
const { user_version: current } = db.query("PRAGMA user_version").get();
|
|
@@ -12498,6 +12506,28 @@ async function recoverStaleMembers(db, client) {
|
|
|
12498
12506
|
}
|
|
12499
12507
|
return { interrupted: result.changes };
|
|
12500
12508
|
}
|
|
12509
|
+
async function recoverOrphanedWorktrees(db, client) {
|
|
12510
|
+
let removed = 0;
|
|
12511
|
+
try {
|
|
12512
|
+
const worktrees = await client.worktree.list();
|
|
12513
|
+
if (!worktrees.data)
|
|
12514
|
+
return { removed: 0 };
|
|
12515
|
+
const activeWorktrees = new Set(db.query(`SELECT tm.worktree_dir FROM team_member tm
|
|
12516
|
+
JOIN team t ON tm.team_id = t.id
|
|
12517
|
+
WHERE tm.worktree_dir IS NOT NULL AND t.status = 'active'`).all().map((r) => r.worktree_dir));
|
|
12518
|
+
for (const wt of worktrees.data) {
|
|
12519
|
+
if (!wt.name.startsWith("ensemble-"))
|
|
12520
|
+
continue;
|
|
12521
|
+
if (activeWorktrees.has(wt.directory))
|
|
12522
|
+
continue;
|
|
12523
|
+
try {
|
|
12524
|
+
await client.worktree.remove({ worktreeRemoveInput: { directory: wt.directory } });
|
|
12525
|
+
removed++;
|
|
12526
|
+
} catch {}
|
|
12527
|
+
}
|
|
12528
|
+
} catch {}
|
|
12529
|
+
return { removed };
|
|
12530
|
+
}
|
|
12501
12531
|
async function recoverUndeliveredMessages(db, client, registry2) {
|
|
12502
12532
|
const teams = db.query("SELECT id, lead_session_id FROM team WHERE status = 'active'").all();
|
|
12503
12533
|
let redelivered = 0;
|
|
@@ -12703,6 +12733,71 @@ async function notifyWorkingProgress(client, db, teamId) {
|
|
|
12703
12733
|
} catch {}
|
|
12704
12734
|
}
|
|
12705
12735
|
|
|
12736
|
+
// src/system-prompt.ts
|
|
12737
|
+
var STATUS_DISPLAY = {
|
|
12738
|
+
busy: "working",
|
|
12739
|
+
ready: "idle",
|
|
12740
|
+
shutdown_requested: "shutting down",
|
|
12741
|
+
shutdown: "shut down",
|
|
12742
|
+
error: "error"
|
|
12743
|
+
};
|
|
12744
|
+
function buildLeadSystemPrompt(db, teamId) {
|
|
12745
|
+
const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId);
|
|
12746
|
+
if (!team)
|
|
12747
|
+
return "";
|
|
12748
|
+
const members = db.query("SELECT name, status FROM team_member WHERE team_id = ?").all(teamId);
|
|
12749
|
+
const taskCounts = db.query("SELECT status, COUNT(*) as count FROM team_task WHERE team_id = ? GROUP BY status").all(teamId);
|
|
12750
|
+
const countMap = {};
|
|
12751
|
+
for (const row of taskCounts) {
|
|
12752
|
+
countMap[row.status] = row.count;
|
|
12753
|
+
}
|
|
12754
|
+
const completed = countMap["completed"] ?? 0;
|
|
12755
|
+
const inProgress = countMap["in_progress"] ?? 0;
|
|
12756
|
+
const pending = countMap["pending"] ?? 0;
|
|
12757
|
+
const memberList = members.map((m) => `${m.name} [${STATUS_DISPLAY[m.status] ?? m.status}]`).join(", ");
|
|
12758
|
+
const teammateLine = members.length > 0 ? `Teammates: ${memberList}` : "Teammates: none";
|
|
12759
|
+
return [
|
|
12760
|
+
`You are leading team "${team.name}" with ${members.length} active teammates.`,
|
|
12761
|
+
teammateLine,
|
|
12762
|
+
`Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`,
|
|
12763
|
+
"",
|
|
12764
|
+
"Teammates work asynchronously and message you when done.",
|
|
12765
|
+
"Do NOT poll team_status or team_tasks_list repeatedly \u2014 wait for messages.",
|
|
12766
|
+
"After spawning teammates, tell the user what you've set up and wait.",
|
|
12767
|
+
"When all teammates finish, summarize results and suggest next steps."
|
|
12768
|
+
].join(`
|
|
12769
|
+
`);
|
|
12770
|
+
}
|
|
12771
|
+
function buildTeammateSystemPrompt(db, teamId, memberName) {
|
|
12772
|
+
const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId);
|
|
12773
|
+
if (!team)
|
|
12774
|
+
return "";
|
|
12775
|
+
return `You are "${memberName}", a teammate in team "${team.name}". Use team_message to communicate.`;
|
|
12776
|
+
}
|
|
12777
|
+
function buildTeamCompactionContext(db, teamId, role, memberName) {
|
|
12778
|
+
const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId);
|
|
12779
|
+
if (!team)
|
|
12780
|
+
return "";
|
|
12781
|
+
const members = db.query("SELECT name, status FROM team_member WHERE team_id = ?").all(teamId);
|
|
12782
|
+
const taskCounts = db.query("SELECT status, COUNT(*) as count FROM team_task WHERE team_id = ? GROUP BY status").all(teamId);
|
|
12783
|
+
const countMap = {};
|
|
12784
|
+
for (const row of taskCounts) {
|
|
12785
|
+
countMap[row.status] = row.count;
|
|
12786
|
+
}
|
|
12787
|
+
const completed = countMap["completed"] ?? 0;
|
|
12788
|
+
const inProgress = countMap["in_progress"] ?? 0;
|
|
12789
|
+
const pending = countMap["pending"] ?? 0;
|
|
12790
|
+
const roleLine = role === "lead" ? `[Team Context] You are the lead of team "${team.name}".` : `[Team Context] You are a teammate named "${memberName}" in team "${team.name}".`;
|
|
12791
|
+
const memberList = members.map((m) => `${m.name} (${STATUS_DISPLAY[m.status] ?? m.status})`).join(", ");
|
|
12792
|
+
const membersLine = members.length > 0 ? `Members: ${memberList}` : "Members: none";
|
|
12793
|
+
return [
|
|
12794
|
+
roleLine,
|
|
12795
|
+
membersLine,
|
|
12796
|
+
`Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`
|
|
12797
|
+
].join(`
|
|
12798
|
+
`);
|
|
12799
|
+
}
|
|
12800
|
+
|
|
12706
12801
|
// src/types.ts
|
|
12707
12802
|
function findTeamBySession(db, registry2, sessionId) {
|
|
12708
12803
|
const entry = registry2.getBySession(sessionId);
|
|
@@ -12743,6 +12838,7 @@ async function executeTeamCreate(deps, args, sessionId) {
|
|
|
12743
12838
|
}
|
|
12744
12839
|
|
|
12745
12840
|
// src/tools/team-spawn.ts
|
|
12841
|
+
import path2 from "path";
|
|
12746
12842
|
async function executeTeamSpawn(deps, args, sessionId) {
|
|
12747
12843
|
const nameError = validateMemberName(args.name);
|
|
12748
12844
|
if (nameError)
|
|
@@ -12755,40 +12851,82 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
12755
12851
|
const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.name);
|
|
12756
12852
|
if (existing)
|
|
12757
12853
|
throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
|
|
12758
|
-
const
|
|
12854
|
+
const useWorktree = args.worktree !== false;
|
|
12855
|
+
const usePlanApproval = args.plan_approval === true;
|
|
12856
|
+
let worktreeDir = null;
|
|
12857
|
+
let worktreeBranch = null;
|
|
12858
|
+
if (useWorktree) {
|
|
12859
|
+
const worktreeName = `ensemble-${teamInfo.teamName}-${args.name}`;
|
|
12860
|
+
try {
|
|
12861
|
+
const result = await deps.client.worktree.create({ name: worktreeName });
|
|
12862
|
+
if (result.data) {
|
|
12863
|
+
worktreeDir = result.data.directory;
|
|
12864
|
+
worktreeBranch = result.data.branch;
|
|
12865
|
+
}
|
|
12866
|
+
} catch {
|
|
12867
|
+
try {
|
|
12868
|
+
await deps.client.tui.showToast({
|
|
12869
|
+
title: "Team",
|
|
12870
|
+
message: `Worktree creation failed for ${args.name}, using shared directory`,
|
|
12871
|
+
variant: "warning",
|
|
12872
|
+
duration: 4000
|
|
12873
|
+
});
|
|
12874
|
+
} catch {}
|
|
12875
|
+
}
|
|
12876
|
+
}
|
|
12877
|
+
const createOpts = {
|
|
12759
12878
|
body: { parentID: sessionId, title: `${args.name} (@${args.agent} teammate)` }
|
|
12760
|
-
}
|
|
12761
|
-
|
|
12762
|
-
|
|
12879
|
+
};
|
|
12880
|
+
if (worktreeDir) {
|
|
12881
|
+
createOpts.directory = worktreeDir;
|
|
12882
|
+
}
|
|
12883
|
+
let childSessionId;
|
|
12884
|
+
try {
|
|
12885
|
+
const createResult = await deps.client.session.create(createOpts);
|
|
12886
|
+
childSessionId = createResult.data?.id;
|
|
12887
|
+
} catch (err) {
|
|
12888
|
+
if (worktreeDir) {
|
|
12889
|
+
try {
|
|
12890
|
+
await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
|
|
12891
|
+
} catch {}
|
|
12892
|
+
}
|
|
12893
|
+
throw new Error(`Failed to create session for teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
12894
|
+
}
|
|
12895
|
+
if (!childSessionId) {
|
|
12896
|
+
if (worktreeDir) {
|
|
12897
|
+
try {
|
|
12898
|
+
await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
|
|
12899
|
+
} catch {}
|
|
12900
|
+
}
|
|
12763
12901
|
throw new Error("Failed to create teammate session");
|
|
12902
|
+
}
|
|
12903
|
+
const planApproval = usePlanApproval ? "pending" : "none";
|
|
12764
12904
|
const now = Date.now();
|
|
12765
|
-
deps.db.run(`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, time_created, time_updated)
|
|
12766
|
-
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, now, now]);
|
|
12905
|
+
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)
|
|
12906
|
+
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, planApproval, now, now]);
|
|
12767
12907
|
deps.registry.register(teamInfo.teamId, args.name, childSessionId);
|
|
12768
12908
|
const context = [
|
|
12769
12909
|
`You are "${args.name}", a teammate in team "${teamInfo.teamName}".`,
|
|
12770
|
-
`Your agent type is "${args.agent}"
|
|
12771
|
-
"",
|
|
12772
|
-
"Tools available to you:",
|
|
12773
|
-
"- team_message: send a message to the lead or another teammate",
|
|
12774
|
-
"- team_broadcast: send a message to all team members",
|
|
12775
|
-
"- team_tasks_list: view the shared team task board",
|
|
12776
|
-
"- team_tasks_add: add tasks to the shared board",
|
|
12777
|
-
"- team_tasks_complete: mark a task complete on the shared board",
|
|
12778
|
-
"- team_claim: claim a pending task from the shared board",
|
|
12779
|
-
"",
|
|
12780
|
-
"When you finish your task:",
|
|
12781
|
-
"1. If you claimed a task, mark it complete using team_tasks_complete.",
|
|
12782
|
-
"2. Send ONE message to the lead with your findings using team_message.",
|
|
12783
|
-
"3. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.",
|
|
12784
|
-
"",
|
|
12785
|
-
"If you are blocked, send ONE message to the lead describing the specific blocker.",
|
|
12786
|
-
"",
|
|
12787
|
-
"Your plain text output is NOT visible to the team. You MUST use team_message to communicate.",
|
|
12788
|
-
"",
|
|
12789
|
-
"Your task:",
|
|
12790
|
-
args.prompt
|
|
12910
|
+
`Your agent type is "${args.agent}".`
|
|
12791
12911
|
];
|
|
12912
|
+
if (worktreeBranch) {
|
|
12913
|
+
context.push(`You are working on branch "${worktreeBranch}" in your own worktree. Your changes are isolated from other teammates.`);
|
|
12914
|
+
}
|
|
12915
|
+
if (usePlanApproval) {
|
|
12916
|
+
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.");
|
|
12917
|
+
}
|
|
12918
|
+
context.push("", "Tools available to you:", "- team_message: send a message to the lead or another teammate", "- team_broadcast: send a message to all team members", "- team_tasks_list: view the shared team task board", "- team_tasks_add: add tasks to the shared board", "- team_tasks_complete: mark a task complete on the shared board", "- team_claim: claim a pending task from the shared board", "", "When you finish your task:", "1. If you claimed a task, mark it complete using team_tasks_complete.", "2. Send ONE message to the lead with your findings using team_message.", "3. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.", "", "If you are blocked, send ONE message to the lead describing the specific blocker.", "", "Your plain text output is NOT visible to the team. You MUST use team_message to communicate.");
|
|
12919
|
+
try {
|
|
12920
|
+
const agentsPath = path2.join(deps.directory, "AGENTS.md");
|
|
12921
|
+
const file2 = Bun.file(agentsPath);
|
|
12922
|
+
if (await file2.exists()) {
|
|
12923
|
+
const content = await file2.text();
|
|
12924
|
+
const truncated = content.length > 2000 ? content.slice(0, 2000) + `
|
|
12925
|
+
...(truncated)` : content;
|
|
12926
|
+
context.push("", "Project guidelines (from AGENTS.md):", truncated);
|
|
12927
|
+
}
|
|
12928
|
+
} catch {}
|
|
12929
|
+
context.push("", "Your task:", args.prompt);
|
|
12792
12930
|
if (args.claim_task) {
|
|
12793
12931
|
context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`);
|
|
12794
12932
|
}
|
|
@@ -12805,9 +12943,16 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
12805
12943
|
try {
|
|
12806
12944
|
await deps.client.session.abort({ path: { id: childSessionId } });
|
|
12807
12945
|
} catch {}
|
|
12946
|
+
if (worktreeDir) {
|
|
12947
|
+
try {
|
|
12948
|
+
await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
|
|
12949
|
+
} catch {}
|
|
12950
|
+
}
|
|
12808
12951
|
throw new Error(`Failed to send initial prompt to teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
12809
12952
|
}
|
|
12810
|
-
|
|
12953
|
+
const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : "";
|
|
12954
|
+
const planInfo = usePlanApproval ? " [plan mode \u2014 will send plan for approval]" : "";
|
|
12955
|
+
return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`;
|
|
12811
12956
|
}
|
|
12812
12957
|
|
|
12813
12958
|
// src/tools/team-message.ts
|
|
@@ -12819,20 +12964,40 @@ async function executeTeamMessage(deps, args, sessionId) {
|
|
|
12819
12964
|
const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to);
|
|
12820
12965
|
if (!recipientSessionId)
|
|
12821
12966
|
throw new Error(`Recipient "${args.to}" not found in team "${teamInfo.teamName}"`);
|
|
12967
|
+
let messageText = args.text;
|
|
12968
|
+
if (args.approve || args.reject) {
|
|
12969
|
+
if (args.approve && args.reject) {
|
|
12970
|
+
throw new Error("Cannot both approve and reject a plan.");
|
|
12971
|
+
}
|
|
12972
|
+
if (teamInfo.role !== "lead") {
|
|
12973
|
+
throw new Error("Only the lead can approve or reject plans.");
|
|
12974
|
+
}
|
|
12975
|
+
const recipient = deps.db.query("SELECT plan_approval FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.to);
|
|
12976
|
+
if (!recipient || recipient.plan_approval !== "pending") {
|
|
12977
|
+
throw new Error(`Recipient "${args.to}" is not in plan approval mode (plan_approval is not pending).`);
|
|
12978
|
+
}
|
|
12979
|
+
if (args.approve) {
|
|
12980
|
+
deps.db.run("UPDATE team_member SET plan_approval = 'approved', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamInfo.teamId, args.to]);
|
|
12981
|
+
messageText = `[Plan Approved] ${args.text}`;
|
|
12982
|
+
} else {
|
|
12983
|
+
deps.db.run("UPDATE team_member SET plan_approval = 'rejected', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamInfo.teamId, args.to]);
|
|
12984
|
+
messageText = `[Plan Rejected: ${args.reject}] ${args.text}`;
|
|
12985
|
+
}
|
|
12986
|
+
}
|
|
12822
12987
|
const msgId = sendMessage(deps.db, {
|
|
12823
12988
|
teamId: teamInfo.teamId,
|
|
12824
12989
|
from: senderName,
|
|
12825
12990
|
to: args.to,
|
|
12826
|
-
content:
|
|
12991
|
+
content: messageText
|
|
12827
12992
|
});
|
|
12828
12993
|
const isToLead = args.to === "lead";
|
|
12829
12994
|
const MAX_LEAD_MSG = 500;
|
|
12830
12995
|
let deliveryText;
|
|
12831
|
-
if (isToLead &&
|
|
12832
|
-
const truncated =
|
|
12996
|
+
if (isToLead && messageText.length > MAX_LEAD_MSG) {
|
|
12997
|
+
const truncated = messageText.slice(0, MAX_LEAD_MSG);
|
|
12833
12998
|
deliveryText = `[Team message from ${senderName}]: ${truncated}... (use team_results to read full message)`;
|
|
12834
12999
|
} else {
|
|
12835
|
-
deliveryText = `[Team message from ${senderName}]: ${
|
|
13000
|
+
deliveryText = `[Team message from ${senderName}]: ${messageText}`;
|
|
12836
13001
|
}
|
|
12837
13002
|
await deps.client.session.promptAsync({
|
|
12838
13003
|
path: { id: recipientSessionId },
|
|
@@ -12888,33 +13053,47 @@ async function executeTeamShutdown(deps, args, sessionId) {
|
|
|
12888
13053
|
throw new Error("This session is not in a team.");
|
|
12889
13054
|
if (teamInfo.role !== "lead")
|
|
12890
13055
|
throw new Error("Only the team lead can shut down teammates.");
|
|
12891
|
-
const member = deps.db.query("SELECT session_id, status FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
|
|
13056
|
+
const member = deps.db.query("SELECT session_id, status, worktree_branch FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
|
|
12892
13057
|
if (!member)
|
|
12893
13058
|
throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
|
|
12894
13059
|
if (member.status === "shutdown")
|
|
12895
13060
|
throw new Error(`Teammate "${args.member}" is already shut down`);
|
|
12896
|
-
|
|
12897
|
-
|
|
12898
|
-
await deps.
|
|
12899
|
-
|
|
12900
|
-
|
|
12901
|
-
await deps.client.tui.showToast({
|
|
12902
|
-
title: "Team",
|
|
12903
|
-
message: `Failed to abort ${args.member} session \u2014 will rely on event hook`,
|
|
12904
|
-
variant: "warning",
|
|
12905
|
-
duration: 4000
|
|
12906
|
-
});
|
|
12907
|
-
} catch {}
|
|
13061
|
+
const force = args.force ?? false;
|
|
13062
|
+
if (member.status === "shutdown_requested") {
|
|
13063
|
+
await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id);
|
|
13064
|
+
const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : "";
|
|
13065
|
+
return `Force shut down "${args.member}".${branchInfo}`;
|
|
12908
13066
|
}
|
|
13067
|
+
let isIdle = false;
|
|
12909
13068
|
try {
|
|
12910
13069
|
const statuses = await deps.client.session.status();
|
|
12911
13070
|
const sessionStatus = statuses.data?.[member.session_id];
|
|
12912
|
-
|
|
12913
|
-
|
|
12914
|
-
|
|
12915
|
-
|
|
13071
|
+
isIdle = !sessionStatus || sessionStatus.type === "idle";
|
|
13072
|
+
} catch {}
|
|
13073
|
+
if (isIdle || force) {
|
|
13074
|
+
await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id);
|
|
13075
|
+
const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : "";
|
|
13076
|
+
return `Teammate "${args.member}" has been shut down.${branchInfo}`;
|
|
13077
|
+
}
|
|
13078
|
+
try {
|
|
13079
|
+
await deps.client.session.promptAsync({
|
|
13080
|
+
path: { id: member.session_id },
|
|
13081
|
+
body: {
|
|
13082
|
+
parts: [{
|
|
13083
|
+
type: "text",
|
|
13084
|
+
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.`
|
|
13085
|
+
}]
|
|
13086
|
+
}
|
|
13087
|
+
});
|
|
12916
13088
|
} catch {}
|
|
12917
|
-
|
|
13089
|
+
deps.db.run("UPDATE team_member SET status = 'shutdown_requested', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamInfo.teamId, args.member]);
|
|
13090
|
+
return `Shutdown requested for ${args.member}. They will finish current work and shut down. Call team_shutdown with force: true to abort immediately.`;
|
|
13091
|
+
}
|
|
13092
|
+
async function abortAndShutdown(deps, teamId, memberName, sessionId) {
|
|
13093
|
+
try {
|
|
13094
|
+
await deps.client.session.abort({ path: { id: sessionId } });
|
|
13095
|
+
} catch {}
|
|
13096
|
+
deps.db.run("UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamId, memberName]);
|
|
12918
13097
|
}
|
|
12919
13098
|
|
|
12920
13099
|
// src/tools/team-cleanup.ts
|
|
@@ -12924,7 +13103,7 @@ async function executeTeamCleanup(deps, args, sessionId) {
|
|
|
12924
13103
|
throw new Error("This session is not in a team.");
|
|
12925
13104
|
if (teamInfo.role !== "lead")
|
|
12926
13105
|
throw new Error("Only the team lead can clean up the team.");
|
|
12927
|
-
const members = deps.db.query("SELECT name, session_id, status FROM team_member WHERE team_id = ?").all(teamInfo.teamId);
|
|
13106
|
+
const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch FROM team_member WHERE team_id = ?").all(teamInfo.teamId);
|
|
12928
13107
|
const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
|
|
12929
13108
|
if (active.length > 0 && !args.force) {
|
|
12930
13109
|
const names = active.map((m) => m.name).join(", ");
|
|
@@ -12937,8 +13116,27 @@ async function executeTeamCleanup(deps, args, sessionId) {
|
|
|
12937
13116
|
} catch {}
|
|
12938
13117
|
}
|
|
12939
13118
|
}
|
|
13119
|
+
const branches = [];
|
|
13120
|
+
for (const member of members) {
|
|
13121
|
+
if (member.worktree_dir) {
|
|
13122
|
+
try {
|
|
13123
|
+
await deps.client.worktree.remove({ worktreeRemoveInput: { directory: member.worktree_dir } });
|
|
13124
|
+
} catch {}
|
|
13125
|
+
}
|
|
13126
|
+
if (member.worktree_branch) {
|
|
13127
|
+
branches.push(member.worktree_branch);
|
|
13128
|
+
}
|
|
13129
|
+
}
|
|
12940
13130
|
deps.db.run("UPDATE team SET status = 'archived', time_updated = ? WHERE id = ?", [Date.now(), teamInfo.teamId]);
|
|
12941
13131
|
deps.registry.unregisterTeam(teamInfo.teamId);
|
|
13132
|
+
if (branches.length > 0) {
|
|
13133
|
+
const mergeCommands = branches.map((b) => ` git merge ${b}`).join(`
|
|
13134
|
+
`);
|
|
13135
|
+
return `Team "${teamInfo.teamName}" cleaned up. Worktrees removed.
|
|
13136
|
+
|
|
13137
|
+
Merge teammate branches:
|
|
13138
|
+
${mergeCommands}`;
|
|
13139
|
+
}
|
|
12942
13140
|
return `Team "${teamInfo.teamName}" cleaned up.`;
|
|
12943
13141
|
}
|
|
12944
13142
|
|
|
@@ -13064,7 +13262,7 @@ async function executeTeamStatus(deps, sessionId) {
|
|
|
13064
13262
|
return "No changes since last check.";
|
|
13065
13263
|
}
|
|
13066
13264
|
lastCallTime.set(teamInfo.teamId, now);
|
|
13067
|
-
const members = deps.db.query("SELECT name, session_id, agent, status, execution_status FROM team_member WHERE team_id = ? ORDER BY time_created ASC").all(teamInfo.teamId);
|
|
13265
|
+
const members = deps.db.query("SELECT name, session_id, agent, status, execution_status, worktree_branch, plan_approval FROM team_member WHERE team_id = ? ORDER BY time_created ASC").all(teamInfo.teamId);
|
|
13068
13266
|
const tasks = deps.db.query("SELECT status FROM team_task WHERE team_id = ?").all(teamInfo.teamId);
|
|
13069
13267
|
const lines = [];
|
|
13070
13268
|
lines.push(`Team: ${teamInfo.teamName} (you are the ${teamInfo.role})`);
|
|
@@ -13075,7 +13273,9 @@ async function executeTeamStatus(deps, sessionId) {
|
|
|
13075
13273
|
lines.push("Members:");
|
|
13076
13274
|
for (const m of members) {
|
|
13077
13275
|
const statusIcon = m.status === "busy" ? "working" : m.status === "ready" ? "idle" : m.status;
|
|
13078
|
-
|
|
13276
|
+
const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : "";
|
|
13277
|
+
const plan = m.plan_approval !== "none" ? `, plan: ${m.plan_approval}` : "";
|
|
13278
|
+
lines.push(` ${m.name} [${statusIcon}${plan}] agent: ${m.agent}${branch} session: ${m.session_id}`);
|
|
13079
13279
|
}
|
|
13080
13280
|
}
|
|
13081
13281
|
if (tasks.length > 0) {
|
|
@@ -13255,12 +13455,12 @@ var DEFAULT_WATCHDOG_TTL_MS = 30 * 60 * 1000;
|
|
|
13255
13455
|
var DEFAULT_WATCHDOG_CHECK_MS = 60 * 1000;
|
|
13256
13456
|
var plugin = async (input) => {
|
|
13257
13457
|
const dbPath = getDbPath();
|
|
13258
|
-
mkdirSync(
|
|
13458
|
+
mkdirSync(path3.dirname(dbPath), { recursive: true });
|
|
13259
13459
|
const db = createDb(dbPath);
|
|
13260
13460
|
const registry2 = new MemberRegistry;
|
|
13261
13461
|
const tracker = new DescendantTracker;
|
|
13262
13462
|
const client = input.client;
|
|
13263
|
-
const deps = { db, registry: registry2, tracker, client };
|
|
13463
|
+
const deps = { db, registry: registry2, tracker, client, directory: input.directory };
|
|
13264
13464
|
const recovery = await recoverStaleMembers(db, client);
|
|
13265
13465
|
if (recovery.interrupted > 0) {
|
|
13266
13466
|
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();
|
|
@@ -13269,6 +13469,7 @@ var plugin = async (input) => {
|
|
|
13269
13469
|
}
|
|
13270
13470
|
}
|
|
13271
13471
|
recoverUndeliveredMessages(db, client, registry2).catch(() => {});
|
|
13472
|
+
recoverOrphanedWorktrees(db, client).catch(() => {});
|
|
13272
13473
|
const rateLimitEnv = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT;
|
|
13273
13474
|
const rateLimitCapacity = rateLimitEnv === "0" ? 0 : parseInt(rateLimitEnv ?? "", 10) || DEFAULT_RATE_LIMIT_CAPACITY;
|
|
13274
13475
|
const rateLimiter = new TokenBucket({
|
|
@@ -13334,6 +13535,38 @@ var plugin = async (input) => {
|
|
|
13334
13535
|
}
|
|
13335
13536
|
}
|
|
13336
13537
|
},
|
|
13538
|
+
"experimental.chat.system.transform": async (input2, output) => {
|
|
13539
|
+
if (!input2.sessionID)
|
|
13540
|
+
return;
|
|
13541
|
+
const teamInfo = findTeamBySession(db, registry2, input2.sessionID);
|
|
13542
|
+
if (!teamInfo)
|
|
13543
|
+
return;
|
|
13544
|
+
const prompt = teamInfo.role === "lead" ? buildLeadSystemPrompt(db, teamInfo.teamId) : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName);
|
|
13545
|
+
output.system.push(prompt);
|
|
13546
|
+
},
|
|
13547
|
+
"experimental.session.compacting": async (input2, output) => {
|
|
13548
|
+
const teamInfo = findTeamBySession(db, registry2, input2.sessionID);
|
|
13549
|
+
if (!teamInfo)
|
|
13550
|
+
return;
|
|
13551
|
+
const context = buildTeamCompactionContext(db, teamInfo.teamId, teamInfo.role, teamInfo.memberName);
|
|
13552
|
+
output.context.push(context);
|
|
13553
|
+
},
|
|
13554
|
+
"shell.env": async (input2, output) => {
|
|
13555
|
+
if (!input2.sessionID)
|
|
13556
|
+
return;
|
|
13557
|
+
const teamInfo = findTeamBySession(db, registry2, input2.sessionID);
|
|
13558
|
+
if (!teamInfo)
|
|
13559
|
+
return;
|
|
13560
|
+
output.env.ENSEMBLE_TEAM = teamInfo.teamName;
|
|
13561
|
+
output.env.ENSEMBLE_ROLE = teamInfo.role;
|
|
13562
|
+
if (teamInfo.memberName) {
|
|
13563
|
+
output.env.ENSEMBLE_MEMBER = teamInfo.memberName;
|
|
13564
|
+
const member = db.query("SELECT worktree_branch FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, teamInfo.memberName);
|
|
13565
|
+
if (member?.worktree_branch) {
|
|
13566
|
+
output.env.ENSEMBLE_BRANCH = member.worktree_branch;
|
|
13567
|
+
}
|
|
13568
|
+
}
|
|
13569
|
+
},
|
|
13337
13570
|
tool: {
|
|
13338
13571
|
team_create: tool({
|
|
13339
13572
|
description: "Create a new agent team. You become the team lead. Use this before spawning teammates.",
|
|
@@ -13347,13 +13580,15 @@ var plugin = async (input) => {
|
|
|
13347
13580
|
}
|
|
13348
13581
|
}),
|
|
13349
13582
|
team_spawn: tool({
|
|
13350
|
-
description: "Spawn a new teammate that works in parallel. The teammate starts immediately with the given prompt. " + "Teammates work asynchronously and will message you when done. Do not poll for their status.",
|
|
13583
|
+
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.",
|
|
13351
13584
|
args: {
|
|
13352
13585
|
name: tool.schema.string().describe("Teammate name (lowercase alphanumeric with hyphens)"),
|
|
13353
13586
|
agent: tool.schema.string().default("build").describe("Agent type (e.g. 'build', 'plan', 'explore')"),
|
|
13354
13587
|
prompt: tool.schema.string().describe("Task instructions for the teammate"),
|
|
13355
13588
|
model: tool.schema.string().optional().describe("Model in provider/model format (optional, uses default)"),
|
|
13356
|
-
claim_task: tool.schema.string().optional().describe("Task ID to auto-claim for this teammate (optional)")
|
|
13589
|
+
claim_task: tool.schema.string().optional().describe("Task ID to auto-claim for this teammate (optional)"),
|
|
13590
|
+
worktree: tool.schema.boolean().default(true).describe("Create a git worktree for file isolation (default: true, set false for read-only agents)"),
|
|
13591
|
+
plan_approval: tool.schema.boolean().default(false).describe("Require teammate to send a plan for approval before writing files (default: false)")
|
|
13357
13592
|
},
|
|
13358
13593
|
async execute(args, ctx) {
|
|
13359
13594
|
const result = await executeTeamSpawn(deps, args, ctx.sessionID);
|
|
@@ -13365,7 +13600,9 @@ var plugin = async (input) => {
|
|
|
13365
13600
|
description: "Send a message to a specific teammate or to the lead. Use 'lead' to message the team lead.",
|
|
13366
13601
|
args: {
|
|
13367
13602
|
to: tool.schema.string().describe("Recipient name ('lead' or teammate name)"),
|
|
13368
|
-
text: tool.schema.string().describe("Message content (max 10KB)")
|
|
13603
|
+
text: tool.schema.string().describe("Message content (max 10KB)"),
|
|
13604
|
+
approve: tool.schema.boolean().optional().describe("Approve a teammate's plan (only when recipient has plan_approval='pending')"),
|
|
13605
|
+
reject: tool.schema.string().optional().describe("Reject a teammate's plan with reason (only when recipient has plan_approval='pending')")
|
|
13369
13606
|
},
|
|
13370
13607
|
async execute(args, ctx) {
|
|
13371
13608
|
const result = await executeTeamMessage(deps, args, ctx.sessionID);
|
|
@@ -13444,9 +13681,10 @@ var plugin = async (input) => {
|
|
|
13444
13681
|
}
|
|
13445
13682
|
}),
|
|
13446
13683
|
team_shutdown: tool({
|
|
13447
|
-
description: "Request a teammate to shut down. The teammate finishes current work then stops.",
|
|
13684
|
+
description: "Request a teammate to shut down. The teammate finishes current work then stops. " + "Pass force: true to abort immediately without waiting.",
|
|
13448
13685
|
args: {
|
|
13449
|
-
member: tool.schema.string().describe("Teammate name to shut down")
|
|
13686
|
+
member: tool.schema.string().describe("Teammate name to shut down"),
|
|
13687
|
+
force: tool.schema.boolean().default(false).describe("Force immediate abort without waiting for current work to finish")
|
|
13450
13688
|
},
|
|
13451
13689
|
async execute(args, ctx) {
|
|
13452
13690
|
const result = await executeTeamShutdown(deps, args, ctx.sessionID);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hueyexe/opencode-ensemble",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"typescript": "^5"
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@opencode-ai/plugin": "^1.2.
|
|
42
|
-
"@opencode-ai/sdk": "^1.2.
|
|
41
|
+
"@opencode-ai/plugin": "^1.2.27",
|
|
42
|
+
"@opencode-ai/sdk": "^1.2.27"
|
|
43
43
|
}
|
|
44
44
|
}
|