@hueyexe/opencode-ensemble 0.3.1 → 0.4.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 +3 -2
- package/dist/index.js +115 -36
- package/package.json +1 -1
- package/src/index.ts +8 -1
- package/src/recovery.ts +37 -0
- package/src/schema.ts +3 -0
- package/src/tools/team-cleanup.ts +22 -5
- package/src/tools/team-shutdown.ts +4 -3
- package/src/tools/team-spawn.ts +73 -15
- package/src/tools/team-status.ts +4 -3
- package/src/types.ts +6 -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)
|
package/dist/index.js
CHANGED
|
@@ -12405,7 +12405,9 @@ var MIGRATIONS = [
|
|
|
12405
12405
|
WHERE delivered = 0;
|
|
12406
12406
|
`,
|
|
12407
12407
|
`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
|
|
12408
|
+
CREATE INDEX IF NOT EXISTS team_message_unread_idx ON team_message(team_id, read) WHERE read = 0;`,
|
|
12409
|
+
`ALTER TABLE team_member ADD COLUMN worktree_dir TEXT;
|
|
12410
|
+
ALTER TABLE team_member ADD COLUMN worktree_branch TEXT;`
|
|
12409
12411
|
];
|
|
12410
12412
|
function applyMigrations(db) {
|
|
12411
12413
|
const { user_version: current } = db.query("PRAGMA user_version").get();
|
|
@@ -12498,6 +12500,28 @@ async function recoverStaleMembers(db, client) {
|
|
|
12498
12500
|
}
|
|
12499
12501
|
return { interrupted: result.changes };
|
|
12500
12502
|
}
|
|
12503
|
+
async function recoverOrphanedWorktrees(db, client) {
|
|
12504
|
+
let removed = 0;
|
|
12505
|
+
try {
|
|
12506
|
+
const worktrees = await client.worktree.list();
|
|
12507
|
+
if (!worktrees.data)
|
|
12508
|
+
return { removed: 0 };
|
|
12509
|
+
const activeWorktrees = new Set(db.query(`SELECT tm.worktree_dir FROM team_member tm
|
|
12510
|
+
JOIN team t ON tm.team_id = t.id
|
|
12511
|
+
WHERE tm.worktree_dir IS NOT NULL AND t.status = 'active'`).all().map((r) => r.worktree_dir));
|
|
12512
|
+
for (const wt of worktrees.data) {
|
|
12513
|
+
if (!wt.name.startsWith("ensemble-"))
|
|
12514
|
+
continue;
|
|
12515
|
+
if (activeWorktrees.has(wt.directory))
|
|
12516
|
+
continue;
|
|
12517
|
+
try {
|
|
12518
|
+
await client.worktree.remove({ worktreeRemoveInput: { directory: wt.directory } });
|
|
12519
|
+
removed++;
|
|
12520
|
+
} catch {}
|
|
12521
|
+
}
|
|
12522
|
+
} catch {}
|
|
12523
|
+
return { removed };
|
|
12524
|
+
}
|
|
12501
12525
|
async function recoverUndeliveredMessages(db, client, registry2) {
|
|
12502
12526
|
const teams = db.query("SELECT id, lead_session_id FROM team WHERE status = 'active'").all();
|
|
12503
12527
|
let redelivered = 0;
|
|
@@ -12755,40 +12779,66 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
12755
12779
|
const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.name);
|
|
12756
12780
|
if (existing)
|
|
12757
12781
|
throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
|
|
12758
|
-
const
|
|
12782
|
+
const useWorktree = args.worktree !== false;
|
|
12783
|
+
let worktreeDir = null;
|
|
12784
|
+
let worktreeBranch = null;
|
|
12785
|
+
if (useWorktree) {
|
|
12786
|
+
const worktreeName = `ensemble-${teamInfo.teamName}-${args.name}`;
|
|
12787
|
+
try {
|
|
12788
|
+
const result = await deps.client.worktree.create({ name: worktreeName });
|
|
12789
|
+
if (result.data) {
|
|
12790
|
+
worktreeDir = result.data.directory;
|
|
12791
|
+
worktreeBranch = result.data.branch;
|
|
12792
|
+
}
|
|
12793
|
+
} catch {
|
|
12794
|
+
try {
|
|
12795
|
+
await deps.client.tui.showToast({
|
|
12796
|
+
title: "Team",
|
|
12797
|
+
message: `Worktree creation failed for ${args.name}, using shared directory`,
|
|
12798
|
+
variant: "warning",
|
|
12799
|
+
duration: 4000
|
|
12800
|
+
});
|
|
12801
|
+
} catch {}
|
|
12802
|
+
}
|
|
12803
|
+
}
|
|
12804
|
+
const createOpts = {
|
|
12759
12805
|
body: { parentID: sessionId, title: `${args.name} (@${args.agent} teammate)` }
|
|
12760
|
-
}
|
|
12761
|
-
|
|
12762
|
-
|
|
12806
|
+
};
|
|
12807
|
+
if (worktreeDir) {
|
|
12808
|
+
createOpts.directory = worktreeDir;
|
|
12809
|
+
}
|
|
12810
|
+
let childSessionId;
|
|
12811
|
+
try {
|
|
12812
|
+
const createResult = await deps.client.session.create(createOpts);
|
|
12813
|
+
childSessionId = createResult.data?.id;
|
|
12814
|
+
} catch (err) {
|
|
12815
|
+
if (worktreeDir) {
|
|
12816
|
+
try {
|
|
12817
|
+
await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
|
|
12818
|
+
} catch {}
|
|
12819
|
+
}
|
|
12820
|
+
throw new Error(`Failed to create session for teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
12821
|
+
}
|
|
12822
|
+
if (!childSessionId) {
|
|
12823
|
+
if (worktreeDir) {
|
|
12824
|
+
try {
|
|
12825
|
+
await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
|
|
12826
|
+
} catch {}
|
|
12827
|
+
}
|
|
12763
12828
|
throw new Error("Failed to create teammate session");
|
|
12829
|
+
}
|
|
12764
12830
|
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]);
|
|
12831
|
+
deps.db.run(`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, time_created, time_updated)
|
|
12832
|
+
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, now, now]);
|
|
12767
12833
|
deps.registry.register(teamInfo.teamId, args.name, childSessionId);
|
|
12768
12834
|
const context = [
|
|
12769
12835
|
`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
|
|
12836
|
+
`Your agent type is "${args.agent}".`
|
|
12791
12837
|
];
|
|
12838
|
+
if (worktreeBranch) {
|
|
12839
|
+
context.push(`You are working on branch "${worktreeBranch}" in your own worktree. Your changes are isolated from other teammates.`);
|
|
12840
|
+
}
|
|
12841
|
+
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.", "", "Your task:", args.prompt);
|
|
12792
12842
|
if (args.claim_task) {
|
|
12793
12843
|
context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`);
|
|
12794
12844
|
}
|
|
@@ -12805,9 +12855,15 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
12805
12855
|
try {
|
|
12806
12856
|
await deps.client.session.abort({ path: { id: childSessionId } });
|
|
12807
12857
|
} catch {}
|
|
12858
|
+
if (worktreeDir) {
|
|
12859
|
+
try {
|
|
12860
|
+
await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
|
|
12861
|
+
} catch {}
|
|
12862
|
+
}
|
|
12808
12863
|
throw new Error(`Failed to send initial prompt to teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
12809
12864
|
}
|
|
12810
|
-
|
|
12865
|
+
const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : "";
|
|
12866
|
+
return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`;
|
|
12811
12867
|
}
|
|
12812
12868
|
|
|
12813
12869
|
// src/tools/team-message.ts
|
|
@@ -12888,7 +12944,7 @@ async function executeTeamShutdown(deps, args, sessionId) {
|
|
|
12888
12944
|
throw new Error("This session is not in a team.");
|
|
12889
12945
|
if (teamInfo.role !== "lead")
|
|
12890
12946
|
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);
|
|
12947
|
+
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
12948
|
if (!member)
|
|
12893
12949
|
throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
|
|
12894
12950
|
if (member.status === "shutdown")
|
|
@@ -12911,7 +12967,8 @@ async function executeTeamShutdown(deps, args, sessionId) {
|
|
|
12911
12967
|
const sessionStatus = statuses.data?.[member.session_id];
|
|
12912
12968
|
if (!sessionStatus || sessionStatus.type === "idle") {
|
|
12913
12969
|
deps.db.run("UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamInfo.teamId, args.member]);
|
|
12914
|
-
|
|
12970
|
+
const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : "";
|
|
12971
|
+
return `Teammate "${args.member}" has been shut down.${branchInfo}`;
|
|
12915
12972
|
}
|
|
12916
12973
|
} catch {}
|
|
12917
12974
|
return `Shutdown requested for ${args.member}. Will complete when current work finishes.`;
|
|
@@ -12924,7 +12981,7 @@ async function executeTeamCleanup(deps, args, sessionId) {
|
|
|
12924
12981
|
throw new Error("This session is not in a team.");
|
|
12925
12982
|
if (teamInfo.role !== "lead")
|
|
12926
12983
|
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);
|
|
12984
|
+
const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch FROM team_member WHERE team_id = ?").all(teamInfo.teamId);
|
|
12928
12985
|
const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
|
|
12929
12986
|
if (active.length > 0 && !args.force) {
|
|
12930
12987
|
const names = active.map((m) => m.name).join(", ");
|
|
@@ -12937,8 +12994,27 @@ async function executeTeamCleanup(deps, args, sessionId) {
|
|
|
12937
12994
|
} catch {}
|
|
12938
12995
|
}
|
|
12939
12996
|
}
|
|
12997
|
+
const branches = [];
|
|
12998
|
+
for (const member of members) {
|
|
12999
|
+
if (member.worktree_dir) {
|
|
13000
|
+
try {
|
|
13001
|
+
await deps.client.worktree.remove({ worktreeRemoveInput: { directory: member.worktree_dir } });
|
|
13002
|
+
} catch {}
|
|
13003
|
+
}
|
|
13004
|
+
if (member.worktree_branch) {
|
|
13005
|
+
branches.push(member.worktree_branch);
|
|
13006
|
+
}
|
|
13007
|
+
}
|
|
12940
13008
|
deps.db.run("UPDATE team SET status = 'archived', time_updated = ? WHERE id = ?", [Date.now(), teamInfo.teamId]);
|
|
12941
13009
|
deps.registry.unregisterTeam(teamInfo.teamId);
|
|
13010
|
+
if (branches.length > 0) {
|
|
13011
|
+
const mergeCommands = branches.map((b) => ` git merge ${b}`).join(`
|
|
13012
|
+
`);
|
|
13013
|
+
return `Team "${teamInfo.teamName}" cleaned up. Worktrees removed.
|
|
13014
|
+
|
|
13015
|
+
Merge teammate branches:
|
|
13016
|
+
${mergeCommands}`;
|
|
13017
|
+
}
|
|
12942
13018
|
return `Team "${teamInfo.teamName}" cleaned up.`;
|
|
12943
13019
|
}
|
|
12944
13020
|
|
|
@@ -13064,7 +13140,7 @@ async function executeTeamStatus(deps, sessionId) {
|
|
|
13064
13140
|
return "No changes since last check.";
|
|
13065
13141
|
}
|
|
13066
13142
|
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);
|
|
13143
|
+
const members = deps.db.query("SELECT name, session_id, agent, status, execution_status, worktree_branch FROM team_member WHERE team_id = ? ORDER BY time_created ASC").all(teamInfo.teamId);
|
|
13068
13144
|
const tasks = deps.db.query("SELECT status FROM team_task WHERE team_id = ?").all(teamInfo.teamId);
|
|
13069
13145
|
const lines = [];
|
|
13070
13146
|
lines.push(`Team: ${teamInfo.teamName} (you are the ${teamInfo.role})`);
|
|
@@ -13075,7 +13151,8 @@ async function executeTeamStatus(deps, sessionId) {
|
|
|
13075
13151
|
lines.push("Members:");
|
|
13076
13152
|
for (const m of members) {
|
|
13077
13153
|
const statusIcon = m.status === "busy" ? "working" : m.status === "ready" ? "idle" : m.status;
|
|
13078
|
-
|
|
13154
|
+
const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : "";
|
|
13155
|
+
lines.push(` ${m.name} [${statusIcon}] agent: ${m.agent}${branch} session: ${m.session_id}`);
|
|
13079
13156
|
}
|
|
13080
13157
|
}
|
|
13081
13158
|
if (tasks.length > 0) {
|
|
@@ -13269,6 +13346,7 @@ var plugin = async (input) => {
|
|
|
13269
13346
|
}
|
|
13270
13347
|
}
|
|
13271
13348
|
recoverUndeliveredMessages(db, client, registry2).catch(() => {});
|
|
13349
|
+
recoverOrphanedWorktrees(db, client).catch(() => {});
|
|
13272
13350
|
const rateLimitEnv = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT;
|
|
13273
13351
|
const rateLimitCapacity = rateLimitEnv === "0" ? 0 : parseInt(rateLimitEnv ?? "", 10) || DEFAULT_RATE_LIMIT_CAPACITY;
|
|
13274
13352
|
const rateLimiter = new TokenBucket({
|
|
@@ -13347,13 +13425,14 @@ var plugin = async (input) => {
|
|
|
13347
13425
|
}
|
|
13348
13426
|
}),
|
|
13349
13427
|
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.",
|
|
13428
|
+
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
13429
|
args: {
|
|
13352
13430
|
name: tool.schema.string().describe("Teammate name (lowercase alphanumeric with hyphens)"),
|
|
13353
13431
|
agent: tool.schema.string().default("build").describe("Agent type (e.g. 'build', 'plan', 'explore')"),
|
|
13354
13432
|
prompt: tool.schema.string().describe("Task instructions for the teammate"),
|
|
13355
13433
|
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)")
|
|
13434
|
+
claim_task: tool.schema.string().optional().describe("Task ID to auto-claim for this teammate (optional)"),
|
|
13435
|
+
worktree: tool.schema.boolean().default(true).describe("Create a git worktree for file isolation (default: true, set false for read-only agents)")
|
|
13357
13436
|
},
|
|
13358
13437
|
async execute(args, ctx) {
|
|
13359
13438
|
const result = await executeTeamSpawn(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.4.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",
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { tool } from "@opencode-ai/plugin"
|
|
|
3
3
|
import path from "path"
|
|
4
4
|
import { mkdirSync } from "node:fs"
|
|
5
5
|
import { createDb, getDbPath } from "./db"
|
|
6
|
-
import { recoverStaleMembers, recoverUndeliveredMessages } from "./recovery"
|
|
6
|
+
import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktrees } from "./recovery"
|
|
7
7
|
import { MemberRegistry, DescendantTracker } from "./state"
|
|
8
8
|
import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation } from "./hooks"
|
|
9
9
|
import { notifyTeamEvent, notifyWorkingProgress } from "./notify"
|
|
@@ -66,6 +66,11 @@ const plugin: Plugin = async (input) => {
|
|
|
66
66
|
// Best effort — don't block plugin init
|
|
67
67
|
})
|
|
68
68
|
|
|
69
|
+
// Clean up orphaned worktrees from crashed teams
|
|
70
|
+
recoverOrphanedWorktrees(db, client).catch(() => {
|
|
71
|
+
// Best effort — don't block plugin init
|
|
72
|
+
})
|
|
73
|
+
|
|
69
74
|
// Initialize rate limiter
|
|
70
75
|
// OPENCODE_ENSEMBLE_RATE_LIMIT=0 disables it entirely
|
|
71
76
|
const rateLimitEnv = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT
|
|
@@ -163,6 +168,7 @@ const plugin: Plugin = async (input) => {
|
|
|
163
168
|
|
|
164
169
|
team_spawn: tool({
|
|
165
170
|
description: "Spawn a new teammate that works in parallel. The teammate starts immediately with the given prompt. " +
|
|
171
|
+
"Each teammate gets their own git worktree for file isolation. " +
|
|
166
172
|
"Teammates work asynchronously and will message you when done. Do not poll for their status.",
|
|
167
173
|
args: {
|
|
168
174
|
name: tool.schema.string().describe("Teammate name (lowercase alphanumeric with hyphens)"),
|
|
@@ -170,6 +176,7 @@ const plugin: Plugin = async (input) => {
|
|
|
170
176
|
prompt: tool.schema.string().describe("Task instructions for the teammate"),
|
|
171
177
|
model: tool.schema.string().optional().describe("Model in provider/model format (optional, uses default)"),
|
|
172
178
|
claim_task: tool.schema.string().optional().describe("Task ID to auto-claim for this teammate (optional)"),
|
|
179
|
+
worktree: tool.schema.boolean().default(true).describe("Create a git worktree for file isolation (default: true, set false for read-only agents)"),
|
|
173
180
|
},
|
|
174
181
|
async execute(args, ctx) {
|
|
175
182
|
const result = await executeTeamSpawn(deps, args, ctx.sessionID)
|
package/src/recovery.ts
CHANGED
|
@@ -37,6 +37,43 @@ export async function recoverStaleMembers(db: Database, client?: PluginClient):
|
|
|
37
37
|
return { interrupted: result.changes }
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Clean up orphaned worktrees from archived teams or members that no longer exist.
|
|
42
|
+
* Compares worktrees on disk (via client.worktree.list) against active team members.
|
|
43
|
+
*/
|
|
44
|
+
export async function recoverOrphanedWorktrees(db: Database, client: PluginClient): Promise<{ removed: number }> {
|
|
45
|
+
let removed = 0
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const worktrees = await client.worktree.list()
|
|
49
|
+
if (!worktrees.data) return { removed: 0 }
|
|
50
|
+
|
|
51
|
+
// Get all active worktree directories from the DB
|
|
52
|
+
const activeWorktrees = new Set(
|
|
53
|
+
(db.query(
|
|
54
|
+
`SELECT tm.worktree_dir FROM team_member tm
|
|
55
|
+
JOIN team t ON tm.team_id = t.id
|
|
56
|
+
WHERE tm.worktree_dir IS NOT NULL AND t.status = 'active'`
|
|
57
|
+
).all() as Array<{ worktree_dir: string }>).map(r => r.worktree_dir)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
for (const wt of worktrees.data) {
|
|
61
|
+
// Only clean up worktrees created by ensemble (name starts with "ensemble-")
|
|
62
|
+
if (!wt.name.startsWith("ensemble-")) continue
|
|
63
|
+
if (activeWorktrees.has(wt.directory)) continue
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
await client.worktree.remove({ worktreeRemoveInput: { directory: wt.directory } })
|
|
67
|
+
removed++
|
|
68
|
+
} catch { /* best effort */ }
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// worktree.list may not be available — silently ignore
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { removed }
|
|
75
|
+
}
|
|
76
|
+
|
|
40
77
|
/**
|
|
41
78
|
* Redeliver undelivered messages (delivered=0) via promptAsync.
|
|
42
79
|
* Resolves recipient session IDs from the member registry or team lead.
|
package/src/schema.ts
CHANGED
|
@@ -70,6 +70,9 @@ export const MIGRATIONS: string[] = [
|
|
|
70
70
|
// Migration 2: Add read column to team_message for team_results tracking
|
|
71
71
|
`ALTER TABLE team_message ADD COLUMN read INTEGER NOT NULL DEFAULT 0;
|
|
72
72
|
CREATE INDEX IF NOT EXISTS team_message_unread_idx ON team_message(team_id, read) WHERE read = 0;`,
|
|
73
|
+
// Migration 3: Add worktree columns to team_member for git worktree isolation
|
|
74
|
+
`ALTER TABLE team_member ADD COLUMN worktree_dir TEXT;
|
|
75
|
+
ALTER TABLE team_member ADD COLUMN worktree_branch TEXT;`,
|
|
73
76
|
]
|
|
74
77
|
|
|
75
78
|
/**
|
|
@@ -3,6 +3,7 @@ import { findTeamBySession } from "../types"
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Execute the team_cleanup tool. Archives the team and cleans up resources.
|
|
6
|
+
* Removes worktrees and lists branches for the lead to merge.
|
|
6
7
|
* If force=true, aborts all active member sessions first.
|
|
7
8
|
*/
|
|
8
9
|
export async function executeTeamCleanup(
|
|
@@ -14,8 +15,8 @@ export async function executeTeamCleanup(
|
|
|
14
15
|
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
15
16
|
if (teamInfo.role !== "lead") throw new Error("Only the team lead can clean up the team.")
|
|
16
17
|
|
|
17
|
-
const members = deps.db.query("SELECT name, session_id, status FROM team_member WHERE team_id = ?")
|
|
18
|
-
.all(teamInfo.teamId) as Array<{ name: string; session_id: string; status: string }>
|
|
18
|
+
const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch FROM team_member WHERE team_id = ?")
|
|
19
|
+
.all(teamInfo.teamId) as Array<{ name: string; session_id: string; status: string; worktree_dir: string | null; worktree_branch: string | null }>
|
|
19
20
|
|
|
20
21
|
const active = members.filter(m => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error")
|
|
21
22
|
|
|
@@ -29,9 +30,20 @@ export async function executeTeamCleanup(
|
|
|
29
30
|
for (const member of active) {
|
|
30
31
|
try {
|
|
31
32
|
await deps.client.session.abort({ path: { id: member.session_id } })
|
|
32
|
-
} catch {
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
} catch { /* best effort */ }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Remove worktrees and collect branches for merging
|
|
38
|
+
const branches: string[] = []
|
|
39
|
+
for (const member of members) {
|
|
40
|
+
if (member.worktree_dir) {
|
|
41
|
+
try {
|
|
42
|
+
await deps.client.worktree.remove({ worktreeRemoveInput: { directory: member.worktree_dir } })
|
|
43
|
+
} catch { /* best effort — worktree may already be gone */ }
|
|
44
|
+
}
|
|
45
|
+
if (member.worktree_branch) {
|
|
46
|
+
branches.push(member.worktree_branch)
|
|
35
47
|
}
|
|
36
48
|
}
|
|
37
49
|
|
|
@@ -41,5 +53,10 @@ export async function executeTeamCleanup(
|
|
|
41
53
|
// Clean up registry
|
|
42
54
|
deps.registry.unregisterTeam(teamInfo.teamId)
|
|
43
55
|
|
|
56
|
+
if (branches.length > 0) {
|
|
57
|
+
const mergeCommands = branches.map(b => ` git merge ${b}`).join("\n")
|
|
58
|
+
return `Team "${teamInfo.teamName}" cleaned up. Worktrees removed.\n\nMerge teammate branches:\n${mergeCommands}`
|
|
59
|
+
}
|
|
60
|
+
|
|
44
61
|
return `Team "${teamInfo.teamName}" cleaned up.`
|
|
45
62
|
}
|
|
@@ -16,8 +16,8 @@ export async function executeTeamShutdown(
|
|
|
16
16
|
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
17
17
|
if (teamInfo.role !== "lead") throw new Error("Only the team lead can shut down teammates.")
|
|
18
18
|
|
|
19
|
-
const member = deps.db.query("SELECT session_id, status FROM team_member WHERE team_id = ? AND name = ?")
|
|
20
|
-
.get(teamInfo.teamId, args.member) as { session_id: string; status: string } | null
|
|
19
|
+
const member = deps.db.query("SELECT session_id, status, worktree_branch FROM team_member WHERE team_id = ? AND name = ?")
|
|
20
|
+
.get(teamInfo.teamId, args.member) as { session_id: string; status: string; worktree_branch: string | null } | null
|
|
21
21
|
if (!member) throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`)
|
|
22
22
|
if (member.status === "shutdown") throw new Error(`Teammate "${args.member}" is already shut down`)
|
|
23
23
|
|
|
@@ -53,7 +53,8 @@ export async function executeTeamShutdown(
|
|
|
53
53
|
"UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?",
|
|
54
54
|
[Date.now(), teamInfo.teamId, args.member]
|
|
55
55
|
)
|
|
56
|
-
|
|
56
|
+
const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : ""
|
|
57
|
+
return `Teammate "${args.member}" has been shut down.${branchInfo}`
|
|
57
58
|
}
|
|
58
59
|
} catch {
|
|
59
60
|
// Status poll failed — fall through to eventual consistency
|
package/src/tools/team-spawn.ts
CHANGED
|
@@ -4,10 +4,12 @@ import { findTeamBySession } from "../types"
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Execute the team_spawn tool. Creates a child session and starts a teammate.
|
|
7
|
+
* By default, each teammate gets their own git worktree for file isolation.
|
|
8
|
+
* Pass worktree: false for read-only agents that don't need isolation.
|
|
7
9
|
*/
|
|
8
10
|
export async function executeTeamSpawn(
|
|
9
11
|
deps: ToolDeps,
|
|
10
|
-
args: { name: string; agent: string; prompt: string; model?: string; claim_task?: string },
|
|
12
|
+
args: { name: string; agent: string; prompt: string; model?: string; claim_task?: string; worktree?: boolean },
|
|
11
13
|
sessionId: string,
|
|
12
14
|
): Promise<string> {
|
|
13
15
|
const nameError = validateMemberName(args.name)
|
|
@@ -22,28 +24,82 @@ export async function executeTeamSpawn(
|
|
|
22
24
|
.get(teamInfo.teamId, args.name)
|
|
23
25
|
if (existing) throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`)
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
+
const useWorktree = args.worktree !== false
|
|
28
|
+
|
|
29
|
+
// Create worktree if enabled
|
|
30
|
+
let worktreeDir: string | null = null
|
|
31
|
+
let worktreeBranch: string | null = null
|
|
32
|
+
|
|
33
|
+
if (useWorktree) {
|
|
34
|
+
const worktreeName = `ensemble-${teamInfo.teamName}-${args.name}`
|
|
35
|
+
try {
|
|
36
|
+
const result = await deps.client.worktree.create({ name: worktreeName })
|
|
37
|
+
if (result.data) {
|
|
38
|
+
worktreeDir = result.data.directory
|
|
39
|
+
worktreeBranch = result.data.branch
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// Worktree creation failed — fall back to shared directory with a warning
|
|
43
|
+
try {
|
|
44
|
+
await deps.client.tui.showToast({
|
|
45
|
+
title: "Team",
|
|
46
|
+
message: `Worktree creation failed for ${args.name}, using shared directory`,
|
|
47
|
+
variant: "warning",
|
|
48
|
+
duration: 4000,
|
|
49
|
+
})
|
|
50
|
+
} catch { /* TUI may not be available */ }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Create child session — use worktree directory if available
|
|
55
|
+
const createOpts: Record<string, unknown> = {
|
|
27
56
|
body: { parentID: sessionId, title: `${args.name} (@${args.agent} teammate)` },
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
57
|
+
}
|
|
58
|
+
if (worktreeDir) {
|
|
59
|
+
createOpts.directory = worktreeDir
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let childSessionId: string | undefined
|
|
63
|
+
try {
|
|
64
|
+
const createResult = await deps.client.session.create(createOpts as Parameters<typeof deps.client.session.create>[0])
|
|
65
|
+
childSessionId = createResult.data?.id
|
|
66
|
+
} catch (err) {
|
|
67
|
+
// Rollback worktree if session creation failed
|
|
68
|
+
if (worktreeDir) {
|
|
69
|
+
try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
|
|
70
|
+
}
|
|
71
|
+
throw new Error(`Failed to create session for teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!childSessionId) {
|
|
75
|
+
if (worktreeDir) {
|
|
76
|
+
try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
|
|
77
|
+
}
|
|
78
|
+
throw new Error("Failed to create teammate session")
|
|
79
|
+
}
|
|
31
80
|
|
|
32
81
|
// Register in DB
|
|
33
82
|
const now = Date.now()
|
|
34
83
|
deps.db.run(
|
|
35
|
-
`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, time_created, time_updated)
|
|
36
|
-
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?)`,
|
|
37
|
-
[teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, now, now]
|
|
84
|
+
`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, time_created, time_updated)
|
|
85
|
+
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?)`,
|
|
86
|
+
[teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, now, now]
|
|
38
87
|
)
|
|
39
88
|
|
|
40
89
|
// Register in memory
|
|
41
90
|
deps.registry.register(teamInfo.teamId, args.name, childSessionId)
|
|
42
91
|
|
|
43
|
-
// Build teammate context message
|
|
92
|
+
// Build teammate context message
|
|
44
93
|
const context = [
|
|
45
94
|
`You are "${args.name}", a teammate in team "${teamInfo.teamName}".`,
|
|
46
95
|
`Your agent type is "${args.agent}".`,
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
if (worktreeBranch) {
|
|
99
|
+
context.push(`You are working on branch "${worktreeBranch}" in your own worktree. Your changes are isolated from other teammates.`)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
context.push(
|
|
47
103
|
"",
|
|
48
104
|
"Tools available to you:",
|
|
49
105
|
"- team_message: send a message to the lead or another teammate",
|
|
@@ -64,7 +120,7 @@ export async function executeTeamSpawn(
|
|
|
64
120
|
"",
|
|
65
121
|
"Your task:",
|
|
66
122
|
args.prompt,
|
|
67
|
-
|
|
123
|
+
)
|
|
68
124
|
|
|
69
125
|
if (args.claim_task) {
|
|
70
126
|
context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`)
|
|
@@ -73,20 +129,22 @@ export async function executeTeamSpawn(
|
|
|
73
129
|
const contextStr = context.join("\n")
|
|
74
130
|
|
|
75
131
|
// Fire-and-forget: send prompt to teammate session
|
|
76
|
-
// OQ-1: confirmed — promptAsync queues when session is busy (verified in live testing)
|
|
77
|
-
// OQ-10: confirmed — fresh session accepts promptAsync without session.init() (verified in live testing)
|
|
78
132
|
try {
|
|
79
133
|
await deps.client.session.promptAsync({
|
|
80
134
|
path: { id: childSessionId },
|
|
81
135
|
body: { parts: [{ type: "text", text: contextStr }] },
|
|
82
136
|
})
|
|
83
137
|
} catch (err) {
|
|
84
|
-
// Rollback: clean up DB, registry, and
|
|
138
|
+
// Rollback: clean up DB, registry, session, and worktree
|
|
85
139
|
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND name = ?", [teamInfo.teamId, args.name])
|
|
86
140
|
deps.registry.unregister(childSessionId)
|
|
87
141
|
try { await deps.client.session.abort({ path: { id: childSessionId } }) } catch { /* best effort */ }
|
|
142
|
+
if (worktreeDir) {
|
|
143
|
+
try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
|
|
144
|
+
}
|
|
88
145
|
throw new Error(`Failed to send initial prompt to teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`)
|
|
89
146
|
}
|
|
90
147
|
|
|
91
|
-
|
|
148
|
+
const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : ""
|
|
149
|
+
return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
|
|
92
150
|
}
|
package/src/tools/team-status.ts
CHANGED
|
@@ -24,9 +24,9 @@ export async function executeTeamStatus(
|
|
|
24
24
|
lastCallTime.set(teamInfo.teamId, now)
|
|
25
25
|
|
|
26
26
|
const members = deps.db.query(
|
|
27
|
-
"SELECT name, session_id, agent, status, execution_status FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
|
|
27
|
+
"SELECT name, session_id, agent, status, execution_status, worktree_branch FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
|
|
28
28
|
).all(teamInfo.teamId) as Array<{
|
|
29
|
-
name: string; session_id: string; agent: string; status: string; execution_status: string
|
|
29
|
+
name: string; session_id: string; agent: string; status: string; execution_status: string; worktree_branch: string | null
|
|
30
30
|
}>
|
|
31
31
|
|
|
32
32
|
const tasks = deps.db.query(
|
|
@@ -43,7 +43,8 @@ export async function executeTeamStatus(
|
|
|
43
43
|
lines.push("Members:")
|
|
44
44
|
for (const m of members) {
|
|
45
45
|
const statusIcon = m.status === "busy" ? "working" : m.status === "ready" ? "idle" : m.status
|
|
46
|
-
|
|
46
|
+
const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : ""
|
|
47
|
+
lines.push(` ${m.name} [${statusIcon}] agent: ${m.agent}${branch} session: ${m.session_id}`)
|
|
47
48
|
}
|
|
48
49
|
}
|
|
49
50
|
|
package/src/types.ts
CHANGED
|
@@ -37,6 +37,12 @@ export interface PluginClient {
|
|
|
37
37
|
}): Promise<unknown>
|
|
38
38
|
selectSession(options: { sessionID?: string }): Promise<unknown>
|
|
39
39
|
}
|
|
40
|
+
worktree: {
|
|
41
|
+
create(options: { name?: string; startCommand?: string }): Promise<{ data?: { name: string; branch: string; directory: string } }>
|
|
42
|
+
remove(options: { worktreeRemoveInput?: { directory: string } }): Promise<unknown>
|
|
43
|
+
list(): Promise<{ data?: Array<{ name: string; branch: string; directory: string }> }>
|
|
44
|
+
reset(options: { worktreeResetInput?: { directory: string } }): Promise<unknown>
|
|
45
|
+
}
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
/**
|