@hueyexe/opencode-ensemble 0.3.0 → 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 +4 -3
- package/dist/index.js +124 -51
- 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-create.ts +1 -1
- package/src/tools/team-shutdown.ts +4 -3
- package/src/tools/team-spawn.ts +73 -15
- package/src/tools/team-status.ts +6 -5
- package/src/tools/team-view.ts +3 -7
- package/src/types.ts +6 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# OpenCode Ensemble
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Run a team of parallel AI agents in OpenCode. Each agent gets its own session, context window, and task. They coordinate through messaging and a shared task board.
|
|
4
4
|
|
|
5
5
|
Built as a plugin on the public OpenCode SDK. Zero internal dependencies.
|
|
6
6
|
|
|
@@ -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
|
@@ -1,16 +1,12 @@
|
|
|
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
|
-
}
|
|
7
3
|
var __export = (target, all) => {
|
|
8
4
|
for (var name in all)
|
|
9
5
|
__defProp(target, name, {
|
|
10
6
|
get: all[name],
|
|
11
7
|
enumerable: true,
|
|
12
8
|
configurable: true,
|
|
13
|
-
set:
|
|
9
|
+
set: (newValue) => all[name] = () => newValue
|
|
14
10
|
});
|
|
15
11
|
};
|
|
16
12
|
|
|
@@ -12409,7 +12405,9 @@ var MIGRATIONS = [
|
|
|
12409
12405
|
WHERE delivered = 0;
|
|
12410
12406
|
`,
|
|
12411
12407
|
`ALTER TABLE team_message ADD COLUMN read INTEGER NOT NULL DEFAULT 0;
|
|
12412
|
-
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;`
|
|
12413
12411
|
];
|
|
12414
12412
|
function applyMigrations(db) {
|
|
12415
12413
|
const { user_version: current } = db.query("PRAGMA user_version").get();
|
|
@@ -12502,6 +12500,28 @@ async function recoverStaleMembers(db, client) {
|
|
|
12502
12500
|
}
|
|
12503
12501
|
return { interrupted: result.changes };
|
|
12504
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
|
+
}
|
|
12505
12525
|
async function recoverUndeliveredMessages(db, client, registry2) {
|
|
12506
12526
|
const teams = db.query("SELECT id, lead_session_id FROM team WHERE status = 'active'").all();
|
|
12507
12527
|
let redelivered = 0;
|
|
@@ -12743,7 +12763,7 @@ async function executeTeamCreate(deps, args, sessionId) {
|
|
|
12743
12763
|
const id = generateId("team");
|
|
12744
12764
|
const now = Date.now();
|
|
12745
12765
|
deps.db.run("INSERT INTO team (id, name, lead_session_id, status, delegate, time_created, time_updated) VALUES (?, ?, ?, 'active', 0, ?, ?)", [id, args.name, sessionId, now, now]);
|
|
12746
|
-
return `Team "${args.name}" created
|
|
12766
|
+
return `Team "${args.name}" created. You are the lead. Use team_spawn to add teammates.`;
|
|
12747
12767
|
}
|
|
12748
12768
|
|
|
12749
12769
|
// src/tools/team-spawn.ts
|
|
@@ -12759,40 +12779,66 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
12759
12779
|
const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.name);
|
|
12760
12780
|
if (existing)
|
|
12761
12781
|
throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
|
|
12762
|
-
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 = {
|
|
12763
12805
|
body: { parentID: sessionId, title: `${args.name} (@${args.agent} teammate)` }
|
|
12764
|
-
}
|
|
12765
|
-
|
|
12766
|
-
|
|
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
|
+
}
|
|
12767
12828
|
throw new Error("Failed to create teammate session");
|
|
12829
|
+
}
|
|
12768
12830
|
const now = Date.now();
|
|
12769
|
-
deps.db.run(`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, time_created, time_updated)
|
|
12770
|
-
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]);
|
|
12771
12833
|
deps.registry.register(teamInfo.teamId, args.name, childSessionId);
|
|
12772
12834
|
const context = [
|
|
12773
12835
|
`You are "${args.name}", a teammate in team "${teamInfo.teamName}".`,
|
|
12774
|
-
`Your agent type is "${args.agent}"
|
|
12775
|
-
"",
|
|
12776
|
-
"Tools available to you:",
|
|
12777
|
-
"- team_message: send a message to the lead or another teammate",
|
|
12778
|
-
"- team_broadcast: send a message to all team members",
|
|
12779
|
-
"- team_tasks_list: view the shared team task board",
|
|
12780
|
-
"- team_tasks_add: add tasks to the shared board",
|
|
12781
|
-
"- team_tasks_complete: mark a task complete on the shared board",
|
|
12782
|
-
"- team_claim: claim a pending task from the shared board",
|
|
12783
|
-
"",
|
|
12784
|
-
"When you finish your task:",
|
|
12785
|
-
"1. If you claimed a task, mark it complete using team_tasks_complete.",
|
|
12786
|
-
"2. Send ONE message to the lead with your findings using team_message.",
|
|
12787
|
-
"3. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.",
|
|
12788
|
-
"",
|
|
12789
|
-
"If you are blocked, send ONE message to the lead describing the specific blocker.",
|
|
12790
|
-
"",
|
|
12791
|
-
"Your plain text output is NOT visible to the team. You MUST use team_message to communicate.",
|
|
12792
|
-
"",
|
|
12793
|
-
"Your task:",
|
|
12794
|
-
args.prompt
|
|
12836
|
+
`Your agent type is "${args.agent}".`
|
|
12795
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);
|
|
12796
12842
|
if (args.claim_task) {
|
|
12797
12843
|
context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`);
|
|
12798
12844
|
}
|
|
@@ -12809,9 +12855,15 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
12809
12855
|
try {
|
|
12810
12856
|
await deps.client.session.abort({ path: { id: childSessionId } });
|
|
12811
12857
|
} catch {}
|
|
12858
|
+
if (worktreeDir) {
|
|
12859
|
+
try {
|
|
12860
|
+
await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
|
|
12861
|
+
} catch {}
|
|
12862
|
+
}
|
|
12812
12863
|
throw new Error(`Failed to send initial prompt to teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
12813
12864
|
}
|
|
12814
|
-
|
|
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 ? "..." : ""}`;
|
|
12815
12867
|
}
|
|
12816
12868
|
|
|
12817
12869
|
// src/tools/team-message.ts
|
|
@@ -12892,7 +12944,7 @@ async function executeTeamShutdown(deps, args, sessionId) {
|
|
|
12892
12944
|
throw new Error("This session is not in a team.");
|
|
12893
12945
|
if (teamInfo.role !== "lead")
|
|
12894
12946
|
throw new Error("Only the team lead can shut down teammates.");
|
|
12895
|
-
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);
|
|
12896
12948
|
if (!member)
|
|
12897
12949
|
throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
|
|
12898
12950
|
if (member.status === "shutdown")
|
|
@@ -12915,7 +12967,8 @@ async function executeTeamShutdown(deps, args, sessionId) {
|
|
|
12915
12967
|
const sessionStatus = statuses.data?.[member.session_id];
|
|
12916
12968
|
if (!sessionStatus || sessionStatus.type === "idle") {
|
|
12917
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]);
|
|
12918
|
-
|
|
12970
|
+
const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : "";
|
|
12971
|
+
return `Teammate "${args.member}" has been shut down.${branchInfo}`;
|
|
12919
12972
|
}
|
|
12920
12973
|
} catch {}
|
|
12921
12974
|
return `Shutdown requested for ${args.member}. Will complete when current work finishes.`;
|
|
@@ -12928,7 +12981,7 @@ async function executeTeamCleanup(deps, args, sessionId) {
|
|
|
12928
12981
|
throw new Error("This session is not in a team.");
|
|
12929
12982
|
if (teamInfo.role !== "lead")
|
|
12930
12983
|
throw new Error("Only the team lead can clean up the team.");
|
|
12931
|
-
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);
|
|
12932
12985
|
const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
|
|
12933
12986
|
if (active.length > 0 && !args.force) {
|
|
12934
12987
|
const names = active.map((m) => m.name).join(", ");
|
|
@@ -12941,8 +12994,27 @@ async function executeTeamCleanup(deps, args, sessionId) {
|
|
|
12941
12994
|
} catch {}
|
|
12942
12995
|
}
|
|
12943
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
|
+
}
|
|
12944
13008
|
deps.db.run("UPDATE team SET status = 'archived', time_updated = ? WHERE id = ?", [Date.now(), teamInfo.teamId]);
|
|
12945
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
|
+
}
|
|
12946
13018
|
return `Team "${teamInfo.teamName}" cleaned up.`;
|
|
12947
13019
|
}
|
|
12948
13020
|
|
|
@@ -13065,10 +13137,10 @@ async function executeTeamStatus(deps, sessionId) {
|
|
|
13065
13137
|
const now = Date.now();
|
|
13066
13138
|
const last = lastCallTime.get(teamInfo.teamId);
|
|
13067
13139
|
if (last !== undefined && now - last < RATE_LIMIT_MS) {
|
|
13068
|
-
return "
|
|
13140
|
+
return "No changes since last check.";
|
|
13069
13141
|
}
|
|
13070
13142
|
lastCallTime.set(teamInfo.teamId, now);
|
|
13071
|
-
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);
|
|
13072
13144
|
const tasks = deps.db.query("SELECT status FROM team_task WHERE team_id = ?").all(teamInfo.teamId);
|
|
13073
13145
|
const lines = [];
|
|
13074
13146
|
lines.push(`Team: ${teamInfo.teamName} (you are the ${teamInfo.role})`);
|
|
@@ -13079,7 +13151,8 @@ async function executeTeamStatus(deps, sessionId) {
|
|
|
13079
13151
|
lines.push("Members:");
|
|
13080
13152
|
for (const m of members) {
|
|
13081
13153
|
const statusIcon = m.status === "busy" ? "working" : m.status === "ready" ? "idle" : m.status;
|
|
13082
|
-
|
|
13154
|
+
const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : "";
|
|
13155
|
+
lines.push(` ${m.name} [${statusIcon}] agent: ${m.agent}${branch} session: ${m.session_id}`);
|
|
13083
13156
|
}
|
|
13084
13157
|
}
|
|
13085
13158
|
if (tasks.length > 0) {
|
|
@@ -13110,9 +13183,7 @@ async function executeTeamStatus(deps, sessionId) {
|
|
|
13110
13183
|
});
|
|
13111
13184
|
} catch {}
|
|
13112
13185
|
}
|
|
13113
|
-
return `
|
|
13114
|
-
|
|
13115
|
-
` + lines.join(`
|
|
13186
|
+
return lines.join(`
|
|
13116
13187
|
`);
|
|
13117
13188
|
}
|
|
13118
13189
|
|
|
@@ -13125,11 +13196,11 @@ async function executeTeamView(deps, args, sessionId) {
|
|
|
13125
13196
|
if (!member)
|
|
13126
13197
|
throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
|
|
13127
13198
|
try {
|
|
13128
|
-
|
|
13129
|
-
|
|
13130
|
-
|
|
13131
|
-
|
|
13132
|
-
|
|
13199
|
+
await deps.client.tui.selectSession({ sessionID: member.session_id });
|
|
13200
|
+
return `Switched to ${args.member}'s session.`;
|
|
13201
|
+
} catch {
|
|
13202
|
+
return `Could not switch to ${args.member}'s session (${member.session_id}). The session may not be accessible from the TUI.`;
|
|
13203
|
+
}
|
|
13133
13204
|
}
|
|
13134
13205
|
|
|
13135
13206
|
// src/rate-limit.ts
|
|
@@ -13275,6 +13346,7 @@ var plugin = async (input) => {
|
|
|
13275
13346
|
}
|
|
13276
13347
|
}
|
|
13277
13348
|
recoverUndeliveredMessages(db, client, registry2).catch(() => {});
|
|
13349
|
+
recoverOrphanedWorktrees(db, client).catch(() => {});
|
|
13278
13350
|
const rateLimitEnv = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT;
|
|
13279
13351
|
const rateLimitCapacity = rateLimitEnv === "0" ? 0 : parseInt(rateLimitEnv ?? "", 10) || DEFAULT_RATE_LIMIT_CAPACITY;
|
|
13280
13352
|
const rateLimiter = new TokenBucket({
|
|
@@ -13353,13 +13425,14 @@ var plugin = async (input) => {
|
|
|
13353
13425
|
}
|
|
13354
13426
|
}),
|
|
13355
13427
|
team_spawn: tool({
|
|
13356
|
-
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.",
|
|
13357
13429
|
args: {
|
|
13358
13430
|
name: tool.schema.string().describe("Teammate name (lowercase alphanumeric with hyphens)"),
|
|
13359
13431
|
agent: tool.schema.string().default("build").describe("Agent type (e.g. 'build', 'plan', 'explore')"),
|
|
13360
13432
|
prompt: tool.schema.string().describe("Task instructions for the teammate"),
|
|
13361
13433
|
model: tool.schema.string().optional().describe("Model in provider/model format (optional, uses default)"),
|
|
13362
|
-
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)")
|
|
13363
13436
|
},
|
|
13364
13437
|
async execute(args, ctx) {
|
|
13365
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
|
}
|
package/src/tools/team-create.ts
CHANGED
|
@@ -28,5 +28,5 @@ export async function executeTeamCreate(
|
|
|
28
28
|
[id, args.name, sessionId, now, now]
|
|
29
29
|
)
|
|
30
30
|
|
|
31
|
-
return `Team "${args.name}" created
|
|
31
|
+
return `Team "${args.name}" created. You are the lead. Use team_spawn to add teammates.`
|
|
32
32
|
}
|
|
@@ -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
|
@@ -19,14 +19,14 @@ export async function executeTeamStatus(
|
|
|
19
19
|
const now = Date.now()
|
|
20
20
|
const last = lastCallTime.get(teamInfo.teamId)
|
|
21
21
|
if (last !== undefined && (now - last) < RATE_LIMIT_MS) {
|
|
22
|
-
return "
|
|
22
|
+
return "No changes since last check."
|
|
23
23
|
}
|
|
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
|
|
|
@@ -80,5 +81,5 @@ export async function executeTeamStatus(
|
|
|
80
81
|
}
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
return
|
|
84
|
+
return lines.join("\n")
|
|
84
85
|
}
|
package/src/tools/team-view.ts
CHANGED
|
@@ -17,14 +17,10 @@ export async function executeTeamView(
|
|
|
17
17
|
.get(teamInfo.teamId, args.member) as { session_id: string; status: string; agent: string } | null
|
|
18
18
|
if (!member) throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`)
|
|
19
19
|
|
|
20
|
-
// TUI session navigation — try selectSession if available, otherwise guide the user
|
|
21
20
|
try {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
21
|
+
await deps.client.tui.selectSession({ sessionID: member.session_id })
|
|
22
|
+
return `Switched to ${args.member}'s session.`
|
|
25
23
|
} catch {
|
|
26
|
-
|
|
24
|
+
return `Could not switch to ${args.member}'s session (${member.session_id}). The session may not be accessible from the TUI.`
|
|
27
25
|
}
|
|
28
|
-
|
|
29
|
-
return `View ${args.member}'s session: ${member.session_id}. Use the session picker (ctrl+p) to navigate to their session.`
|
|
30
26
|
}
|
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
|
/**
|