@hueyexe/opencode-ensemble 0.6.3 → 0.6.4
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/dist/index.js +111 -76
- package/package.json +1 -1
- package/src/hooks.ts +0 -16
- package/src/index.ts +45 -22
- package/src/log.ts +13 -0
- package/src/messaging.ts +0 -38
- package/src/recovery.ts +2 -1
- package/src/system-prompt.ts +31 -3
- package/src/tools/team-broadcast.ts +4 -1
- package/src/tools/team-message.ts +19 -28
- package/src/tools/team-spawn.ts +43 -19
- package/src/util.ts +8 -0
package/dist/index.js
CHANGED
|
@@ -15446,6 +15446,9 @@ function validateTeamName(name) {
|
|
|
15446
15446
|
return "Team name must be lowercase alphanumeric with hyphens only";
|
|
15447
15447
|
return;
|
|
15448
15448
|
}
|
|
15449
|
+
function isWorktreeInstance(directory) {
|
|
15450
|
+
return directory.includes("/worktree/") && directory.includes("/ensemble-");
|
|
15451
|
+
}
|
|
15449
15452
|
function validateMemberName(name) {
|
|
15450
15453
|
if (name.toLowerCase() === "lead")
|
|
15451
15454
|
return `Name "lead" is reserved`;
|
|
@@ -15480,30 +15483,6 @@ function getUndeliveredMessages(db, teamId) {
|
|
|
15480
15483
|
function markDelivered(db, messageId) {
|
|
15481
15484
|
db.run("UPDATE team_message SET delivered = 1 WHERE id = ?", [messageId]);
|
|
15482
15485
|
}
|
|
15483
|
-
async function flushPendingMessages(db, client3, teamId, leadSessionId) {
|
|
15484
|
-
const pending = db.query("SELECT * FROM team_message WHERE team_id = ? AND delivered = 0 AND to_name = 'lead' ORDER BY time_created ASC").all(teamId);
|
|
15485
|
-
if (pending.length === 0)
|
|
15486
|
-
return 0;
|
|
15487
|
-
const MAX_LEAD_MSG = 500;
|
|
15488
|
-
const parts = pending.map((msg) => {
|
|
15489
|
-
if (msg.content.length > MAX_LEAD_MSG) {
|
|
15490
|
-
return `[Team message from ${msg.from_name}]: ${msg.content.slice(0, MAX_LEAD_MSG)}... (use team_results to read full message)`;
|
|
15491
|
-
}
|
|
15492
|
-
return `[Team message from ${msg.from_name}]: ${msg.content}`;
|
|
15493
|
-
});
|
|
15494
|
-
try {
|
|
15495
|
-
await client3.session.promptAsync({
|
|
15496
|
-
sessionID: leadSessionId,
|
|
15497
|
-
parts: [{ type: "text", text: parts.join(`
|
|
15498
|
-
`) }]
|
|
15499
|
-
});
|
|
15500
|
-
for (const msg of pending)
|
|
15501
|
-
markDelivered(db, msg.id);
|
|
15502
|
-
return pending.length;
|
|
15503
|
-
} catch {
|
|
15504
|
-
return 0;
|
|
15505
|
-
}
|
|
15506
|
-
}
|
|
15507
15486
|
|
|
15508
15487
|
// src/recovery.ts
|
|
15509
15488
|
async function recoverStaleMembers(db, client3) {
|
|
@@ -15552,7 +15531,7 @@ async function recoverUndeliveredMessages(db, client3, registry2) {
|
|
|
15552
15531
|
for (const msg of messages) {
|
|
15553
15532
|
let recipientSessionId;
|
|
15554
15533
|
if (msg.to_name === "lead") {
|
|
15555
|
-
|
|
15534
|
+
continue;
|
|
15556
15535
|
} else if (msg.to_name) {
|
|
15557
15536
|
const entry = registry2.getByName(team.id, msg.to_name);
|
|
15558
15537
|
recipientSessionId = entry?.sessionId;
|
|
@@ -15690,12 +15669,6 @@ function checkToolIsolation(registry2, tracker, toolName, sessionId) {
|
|
|
15690
15669
|
throw new Error("Team tools are not available to sub-agents. Report findings to your parent teammate via your normal output.");
|
|
15691
15670
|
}
|
|
15692
15671
|
}
|
|
15693
|
-
async function handleLeadIdleFlush(db, client3, sessionId) {
|
|
15694
|
-
const team = db.query("SELECT id FROM team WHERE lead_session_id = ? AND status = 'active'").get(sessionId);
|
|
15695
|
-
if (!team)
|
|
15696
|
-
return 0;
|
|
15697
|
-
return flushPendingMessages(db, client3, team.id, sessionId);
|
|
15698
|
-
}
|
|
15699
15672
|
|
|
15700
15673
|
// src/notify.ts
|
|
15701
15674
|
var TOAST_CONFIG = {
|
|
@@ -15778,16 +15751,27 @@ function buildLeadSystemPrompt(db, teamId) {
|
|
|
15778
15751
|
const pending = countMap.pending ?? 0;
|
|
15779
15752
|
const memberList = members.map((m) => `${m.name} [${STATUS_DISPLAY[m.status] ?? m.status}]`).join(", ");
|
|
15780
15753
|
const teammateLine = members.length > 0 ? `Teammates: ${memberList}` : "Teammates: none";
|
|
15781
|
-
|
|
15754
|
+
const pendingMessages = db.query("SELECT id, from_name, content FROM team_message WHERE team_id = ? AND to_name = 'lead' AND delivered = 0 ORDER BY time_created ASC").all(teamId);
|
|
15755
|
+
const lines = [
|
|
15782
15756
|
`You are leading team "${team.name}" with ${members.length} active teammates.`,
|
|
15783
15757
|
teammateLine,
|
|
15784
|
-
`Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending
|
|
15785
|
-
|
|
15786
|
-
|
|
15787
|
-
"
|
|
15788
|
-
|
|
15789
|
-
|
|
15790
|
-
|
|
15758
|
+
`Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`
|
|
15759
|
+
];
|
|
15760
|
+
if (pendingMessages.length > 0) {
|
|
15761
|
+
lines.push("", "--- Team Messages ---");
|
|
15762
|
+
const MAX_MSG = 500;
|
|
15763
|
+
for (const msg of pendingMessages) {
|
|
15764
|
+
if (msg.content.length > MAX_MSG) {
|
|
15765
|
+
lines.push(`[From ${msg.from_name}]: ${msg.content.slice(0, MAX_MSG)}... (use team_results to read full message)`);
|
|
15766
|
+
} else {
|
|
15767
|
+
lines.push(`[From ${msg.from_name}]: ${msg.content}`);
|
|
15768
|
+
}
|
|
15769
|
+
markDelivered(db, msg.id);
|
|
15770
|
+
}
|
|
15771
|
+
lines.push("--- End Messages ---");
|
|
15772
|
+
}
|
|
15773
|
+
lines.push("", "CRITICAL: Spawn teammates ONE AT A TIME. Send only ONE team_spawn call per response.", "Wait for the tool result before spawning the next teammate.", "Multiple team_spawn calls in a single response will cause timeouts.", "", "Teammates work asynchronously and message you when done.", "Do NOT poll team_status or team_tasks_list repeatedly \u2014 wait for messages.", "After spawning all teammates, tell the user what you've set up and wait.", "When all teammates finish, summarize results and suggest next steps.");
|
|
15774
|
+
return lines.join(`
|
|
15791
15775
|
`);
|
|
15792
15776
|
}
|
|
15793
15777
|
function buildTeammateSystemPrompt(db, teamId, memberName) {
|
|
@@ -15820,6 +15804,17 @@ function buildTeamCompactionContext(db, teamId, role, memberName) {
|
|
|
15820
15804
|
`);
|
|
15821
15805
|
}
|
|
15822
15806
|
|
|
15807
|
+
// src/log.ts
|
|
15808
|
+
var _client = null;
|
|
15809
|
+
function initLog(client3) {
|
|
15810
|
+
_client = client3;
|
|
15811
|
+
}
|
|
15812
|
+
function log(msg) {
|
|
15813
|
+
if (!_client)
|
|
15814
|
+
return;
|
|
15815
|
+
_client.app.log({ service: "ensemble", level: "info", message: msg }).catch(() => {});
|
|
15816
|
+
}
|
|
15817
|
+
|
|
15823
15818
|
// src/types.ts
|
|
15824
15819
|
function findTeamBySession(db, registry2, sessionId) {
|
|
15825
15820
|
const entry = registry2.getBySession(sessionId);
|
|
@@ -15860,6 +15855,18 @@ async function executeTeamCreate(deps, args, sessionId) {
|
|
|
15860
15855
|
}
|
|
15861
15856
|
|
|
15862
15857
|
// src/tools/team-spawn.ts
|
|
15858
|
+
function getSpawnTimeout() {
|
|
15859
|
+
return Number(process.env.SPAWN_TIMEOUT_MS) || 120000;
|
|
15860
|
+
}
|
|
15861
|
+
function withTimeout(promise2, ms, label) {
|
|
15862
|
+
let timer;
|
|
15863
|
+
return Promise.race([
|
|
15864
|
+
promise2.finally(() => clearTimeout(timer)),
|
|
15865
|
+
new Promise((_, reject) => {
|
|
15866
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
15867
|
+
})
|
|
15868
|
+
]);
|
|
15869
|
+
}
|
|
15863
15870
|
async function executeTeamSpawn(deps, args, sessionId) {
|
|
15864
15871
|
const nameError = validateMemberName(args.name);
|
|
15865
15872
|
if (nameError)
|
|
@@ -15874,17 +15881,21 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15874
15881
|
throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
|
|
15875
15882
|
const useWorktree = args.worktree !== false;
|
|
15876
15883
|
const usePlanApproval = args.plan_approval === true;
|
|
15884
|
+
log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`);
|
|
15877
15885
|
let worktreeDir = null;
|
|
15878
15886
|
let worktreeBranch = null;
|
|
15879
15887
|
if (useWorktree) {
|
|
15880
15888
|
const worktreeName = `ensemble-${teamInfo.teamName}-${args.name}`;
|
|
15881
15889
|
try {
|
|
15882
|
-
|
|
15890
|
+
log(`spawn:worktree:start name=${args.name}`);
|
|
15891
|
+
const result = await withTimeout(deps.client.worktree.create({ worktreeCreateInput: { name: worktreeName } }), getSpawnTimeout(), `worktree.create for "${args.name}"`);
|
|
15883
15892
|
if (result.data) {
|
|
15884
15893
|
worktreeDir = result.data.directory;
|
|
15885
15894
|
worktreeBranch = result.data.branch;
|
|
15886
15895
|
}
|
|
15887
|
-
|
|
15896
|
+
log(`spawn:worktree:done name=${args.name} dir=${worktreeDir}`);
|
|
15897
|
+
} catch (err) {
|
|
15898
|
+
log(`spawn:worktree:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`);
|
|
15888
15899
|
try {
|
|
15889
15900
|
await deps.client.tui.showToast({
|
|
15890
15901
|
title: "Team",
|
|
@@ -15904,14 +15915,17 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15904
15915
|
] : undefined;
|
|
15905
15916
|
let childSessionId;
|
|
15906
15917
|
try {
|
|
15907
|
-
|
|
15918
|
+
log(`spawn:session:start name=${args.name}`);
|
|
15919
|
+
const createResult = await withTimeout(deps.client.session.create({
|
|
15908
15920
|
parentID: sessionId,
|
|
15909
15921
|
title: `${args.name} (@${args.agent} teammate)`,
|
|
15910
15922
|
...permission ? { permission } : {},
|
|
15911
15923
|
...worktreeDir ? { directory: worktreeDir } : {}
|
|
15912
|
-
});
|
|
15924
|
+
}), getSpawnTimeout(), `session.create for "${args.name}"`);
|
|
15913
15925
|
childSessionId = createResult.data?.id;
|
|
15926
|
+
log(`spawn:session:done name=${args.name} sessionId=${childSessionId}`);
|
|
15914
15927
|
} catch (err) {
|
|
15928
|
+
log(`spawn:session:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`);
|
|
15915
15929
|
if (worktreeDir) {
|
|
15916
15930
|
try {
|
|
15917
15931
|
await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
|
|
@@ -15949,11 +15963,13 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15949
15963
|
}
|
|
15950
15964
|
const contextStr = context.join(`
|
|
15951
15965
|
`);
|
|
15966
|
+
log(`spawn:promptAsync:fire name=${args.name} sessionId=${childSessionId}`);
|
|
15952
15967
|
deps.client.session.promptAsync({
|
|
15953
15968
|
sessionID: childSessionId,
|
|
15954
15969
|
parts: [{ type: "text", text: contextStr }],
|
|
15955
15970
|
agent: args.agent
|
|
15956
15971
|
}).catch(() => {
|
|
15972
|
+
log(`spawn:promptAsync:failed name=${args.name} \u2014 rolling back`);
|
|
15957
15973
|
try {
|
|
15958
15974
|
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId]);
|
|
15959
15975
|
deps.registry.unregister(childSessionId);
|
|
@@ -15973,14 +15989,11 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15973
15989
|
to: "lead",
|
|
15974
15990
|
content: `Teammate "${args.name}" failed to start and was removed. You may retry the spawn.`
|
|
15975
15991
|
});
|
|
15976
|
-
const team = deps.db.query("SELECT lead_session_id FROM team WHERE id = ?").get(teamInfo.teamId);
|
|
15977
|
-
if (team) {
|
|
15978
|
-
handleLeadIdleFlush(deps.db, deps.client, team.lead_session_id).catch(() => {});
|
|
15979
|
-
}
|
|
15980
15992
|
} catch {}
|
|
15981
15993
|
});
|
|
15982
15994
|
const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : "";
|
|
15983
15995
|
const planInfo = usePlanApproval ? " [plan mode \u2014 will send plan for approval]" : "";
|
|
15996
|
+
log(`spawn:done name=${args.name} sessionId=${childSessionId}`);
|
|
15984
15997
|
return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`;
|
|
15985
15998
|
}
|
|
15986
15999
|
|
|
@@ -16020,31 +16033,25 @@ async function executeTeamMessage(deps, args, sessionId) {
|
|
|
16020
16033
|
content: messageText
|
|
16021
16034
|
});
|
|
16022
16035
|
const isToLead = args.to === "lead";
|
|
16023
|
-
|
|
16024
|
-
|
|
16025
|
-
|
|
16026
|
-
|
|
16027
|
-
|
|
16028
|
-
|
|
16029
|
-
|
|
16030
|
-
|
|
16031
|
-
|
|
16032
|
-
try {
|
|
16033
|
-
const statusResult = await deps.client.session.status();
|
|
16034
|
-
const sessionStatus = statusResult.data?.[recipientSessionId];
|
|
16035
|
-
if (sessionStatus?.type === "busy") {
|
|
16036
|
-
recipientBusy = true;
|
|
16037
|
-
}
|
|
16038
|
-
} catch {}
|
|
16039
|
-
if (recipientBusy) {
|
|
16040
|
-
return `Message sent to ${args.to}. (queued \u2014 recipient is busy)`;
|
|
16036
|
+
if (isToLead) {
|
|
16037
|
+
log(`team_message:wake-lead from=${senderName} recipientSession=${recipientSessionId}`);
|
|
16038
|
+
deps.client.session.promptAsync({
|
|
16039
|
+
sessionID: recipientSessionId,
|
|
16040
|
+
parts: [{ type: "text", text: `[System: New team message from ${senderName}]` }]
|
|
16041
|
+
}).catch((err) => {
|
|
16042
|
+
log(`team_message:wake-lead:failed from=${senderName} err=${err instanceof Error ? err.message : String(err)}`);
|
|
16043
|
+
});
|
|
16044
|
+
return `Message sent to ${args.to}.`;
|
|
16041
16045
|
}
|
|
16046
|
+
const deliveryText = `[Team message from ${senderName}]: ${messageText}`;
|
|
16042
16047
|
deps.client.session.promptAsync({
|
|
16043
16048
|
sessionID: recipientSessionId,
|
|
16044
16049
|
parts: [{ type: "text", text: deliveryText }]
|
|
16045
16050
|
}).then(() => {
|
|
16046
16051
|
markDelivered(deps.db, msgId);
|
|
16047
|
-
}).catch(() => {
|
|
16052
|
+
}).catch((err) => {
|
|
16053
|
+
log(`team_message:deliver:failed to=${args.to} err=${err instanceof Error ? err.message : String(err)}`);
|
|
16054
|
+
});
|
|
16048
16055
|
return `Message sent to ${args.to}.`;
|
|
16049
16056
|
}
|
|
16050
16057
|
|
|
@@ -16080,7 +16087,9 @@ async function executeTeamBroadcast(deps, args, sessionId) {
|
|
|
16080
16087
|
delivered++;
|
|
16081
16088
|
if (delivered === 1)
|
|
16082
16089
|
markDelivered(deps.db, msgId);
|
|
16083
|
-
}).catch(() => {
|
|
16090
|
+
}).catch((err) => {
|
|
16091
|
+
log(`team_broadcast:deliver:failed to=${recipient.name} err=${err instanceof Error ? err.message : String(err)}`);
|
|
16092
|
+
});
|
|
16084
16093
|
}
|
|
16085
16094
|
return `Broadcast sent to ${recipients.length} recipient${recipients.length !== 1 ? "s" : ""}.`;
|
|
16086
16095
|
}
|
|
@@ -16498,17 +16507,29 @@ var plugin = async (input) => {
|
|
|
16498
16507
|
const tracker = new DescendantTracker;
|
|
16499
16508
|
const pluginTransport = input.client._client;
|
|
16500
16509
|
const rawClient = new OpencodeClient({ client: pluginTransport });
|
|
16510
|
+
initLog(rawClient);
|
|
16501
16511
|
const client3 = wrapThrowingClient(rawClient);
|
|
16502
16512
|
const deps = { db, registry: registry2, tracker, client: client3, directory: input.directory };
|
|
16503
|
-
|
|
16504
|
-
|
|
16505
|
-
const
|
|
16506
|
-
|
|
16507
|
-
|
|
16508
|
-
|
|
16513
|
+
if (!isWorktreeInstance(input.directory)) {
|
|
16514
|
+
log("init:recovery:start (main instance)");
|
|
16515
|
+
const recovery = await recoverStaleMembers(db, client3);
|
|
16516
|
+
if (recovery.interrupted > 0) {
|
|
16517
|
+
log(`init:recovery:interrupted=${recovery.interrupted}`);
|
|
16518
|
+
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();
|
|
16519
|
+
for (const m of members) {
|
|
16520
|
+
registry2.register(m.team_id, m.name, m.session_id);
|
|
16521
|
+
}
|
|
16522
|
+
}
|
|
16523
|
+
recoverUndeliveredMessages(db, client3, registry2).catch((err) => {
|
|
16524
|
+
log(`init:recover-messages:failed err=${err instanceof Error ? err.message : String(err)}`);
|
|
16525
|
+
});
|
|
16526
|
+
recoverOrphanedWorktrees(db, client3).catch((err) => {
|
|
16527
|
+
log(`init:recover-worktrees:failed err=${err instanceof Error ? err.message : String(err)}`);
|
|
16528
|
+
});
|
|
16529
|
+
log("init:recovery:done");
|
|
16530
|
+
} else {
|
|
16531
|
+
log(`init:skip-recovery (worktree instance: ${input.directory})`);
|
|
16509
16532
|
}
|
|
16510
|
-
recoverUndeliveredMessages(db, client3, registry2).catch(() => {});
|
|
16511
|
-
recoverOrphanedWorktrees(db, client3).catch(() => {});
|
|
16512
16533
|
const rateLimitEnv = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT;
|
|
16513
16534
|
const rateLimitCapacity = rateLimitEnv === "0" ? 0 : parseInt(rateLimitEnv ?? "", 10) || DEFAULT_RATE_LIMIT_CAPACITY;
|
|
16514
16535
|
const rateLimiter = new TokenBucket({
|
|
@@ -16559,7 +16580,19 @@ var plugin = async (input) => {
|
|
|
16559
16580
|
await notifyWorkingProgress(client3, db, transition.teamId);
|
|
16560
16581
|
}
|
|
16561
16582
|
if (statusType === "idle") {
|
|
16562
|
-
|
|
16583
|
+
const team = db.query("SELECT id FROM team WHERE lead_session_id = ? AND status = 'active'").get(sessionID);
|
|
16584
|
+
if (team) {
|
|
16585
|
+
const pending = db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND to_name = 'lead' AND delivered = 0").get(team.id);
|
|
16586
|
+
if (pending.c > 0) {
|
|
16587
|
+
log(`wake-lead: ${pending.c} pending messages, sending promptAsync`);
|
|
16588
|
+
client3.session.promptAsync({
|
|
16589
|
+
sessionID,
|
|
16590
|
+
parts: [{ type: "text", text: `[System: ${pending.c} new team message(s) available]` }]
|
|
16591
|
+
}).catch((err) => {
|
|
16592
|
+
log(`wake-lead:failed err=${err instanceof Error ? err.message : String(err)}`);
|
|
16593
|
+
});
|
|
16594
|
+
}
|
|
16595
|
+
}
|
|
16563
16596
|
}
|
|
16564
16597
|
}
|
|
16565
16598
|
if (event.type === "session.created") {
|
|
@@ -16583,7 +16616,9 @@ var plugin = async (input) => {
|
|
|
16583
16616
|
const teamInfo = findTeamBySession(db, registry2, input2.sessionID);
|
|
16584
16617
|
if (!teamInfo)
|
|
16585
16618
|
return;
|
|
16619
|
+
log(`system-prompt:transform role=${teamInfo.role} session=${input2.sessionID}`);
|
|
16586
16620
|
const prompt = teamInfo.role === "lead" ? buildLeadSystemPrompt(db, teamInfo.teamId) : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown");
|
|
16621
|
+
log(`system-prompt:injected role=${teamInfo.role} len=${prompt.length}`);
|
|
16587
16622
|
output.system.push(prompt);
|
|
16588
16623
|
},
|
|
16589
16624
|
"experimental.session.compacting": async (input2, output) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hueyexe/opencode-ensemble",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
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/hooks.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite"
|
|
2
2
|
import type { MemberRegistry, DescendantTracker } from "./state"
|
|
3
|
-
import type { PluginClient } from "./types"
|
|
4
|
-
import { flushPendingMessages } from "./messaging"
|
|
5
3
|
|
|
6
4
|
const TEAM_TOOL_PREFIX = "team_"
|
|
7
5
|
|
|
@@ -100,17 +98,3 @@ export function checkToolIsolation(
|
|
|
100
98
|
throw new Error("Team tools are not available to sub-agents. Report findings to your parent teammate via your normal output.")
|
|
101
99
|
}
|
|
102
100
|
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Check if a session going idle is a team lead with pending messages.
|
|
106
|
-
* If so, flush them via promptAsync. Called from the event hook on idle transitions.
|
|
107
|
-
*/
|
|
108
|
-
export async function handleLeadIdleFlush(
|
|
109
|
-
db: Database,
|
|
110
|
-
client: PluginClient,
|
|
111
|
-
sessionId: string,
|
|
112
|
-
): Promise<number> {
|
|
113
|
-
const team = db.query("SELECT id FROM team WHERE lead_session_id = ? AND status = 'active'").get(sessionId) as { id: string } | null
|
|
114
|
-
if (!team) return 0
|
|
115
|
-
return flushPendingMessages(db, client, team.id, sessionId)
|
|
116
|
-
}
|
package/src/index.ts
CHANGED
|
@@ -7,9 +7,11 @@ import { createDb, getDbPath } from "./db"
|
|
|
7
7
|
import { wrapThrowingClient } from "./client"
|
|
8
8
|
import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktrees } from "./recovery"
|
|
9
9
|
import { MemberRegistry, DescendantTracker } from "./state"
|
|
10
|
-
import {
|
|
10
|
+
import { isWorktreeInstance } from "./util"
|
|
11
|
+
import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation } from "./hooks"
|
|
11
12
|
import { notifyTeamEvent, notifyWorkingProgress } from "./notify"
|
|
12
13
|
import { buildLeadSystemPrompt, buildTeammateSystemPrompt, buildTeamCompactionContext } from "./system-prompt"
|
|
14
|
+
import { log, initLog } from "./log"
|
|
13
15
|
import { findTeamBySession } from "./types"
|
|
14
16
|
import { executeTeamCreate } from "./tools/team-create"
|
|
15
17
|
import { executeTeamSpawn } from "./tools/team-spawn"
|
|
@@ -55,30 +57,36 @@ const plugin: Plugin = async (input) => {
|
|
|
55
57
|
type V2Transport = ConstructorParameters<typeof OpencodeClient>[0] extends { client?: infer C } ? C : never
|
|
56
58
|
const pluginTransport = (input.client as unknown as { _client: V2Transport })._client
|
|
57
59
|
const rawClient = new OpencodeClient({ client: pluginTransport })
|
|
60
|
+
initLog(rawClient)
|
|
58
61
|
const client = wrapThrowingClient(rawClient)
|
|
59
62
|
const deps: ToolDeps = { db, registry, tracker, client, directory: input.directory }
|
|
60
63
|
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
64
|
+
// Recovery only runs for the main project instance — NOT for teammate worktree instances.
|
|
65
|
+
// Worktree instances are created during session.create. Running recovery there makes HTTP
|
|
66
|
+
// calls back to the server, which deadlocks because the server is still handling session.create.
|
|
67
|
+
if (!isWorktreeInstance(input.directory)) {
|
|
68
|
+
log("init:recovery:start (main instance)")
|
|
69
|
+
const recovery = await recoverStaleMembers(db, client)
|
|
70
|
+
if (recovery.interrupted > 0) {
|
|
71
|
+
log(`init:recovery:interrupted=${recovery.interrupted}`)
|
|
72
|
+
const members = db.query(
|
|
73
|
+
"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'"
|
|
74
|
+
).all() as Array<{ team_id: string; name: string; session_id: string }>
|
|
75
|
+
for (const m of members) {
|
|
76
|
+
registry.register(m.team_id, m.name, m.session_id)
|
|
77
|
+
}
|
|
70
78
|
}
|
|
71
|
-
}
|
|
72
79
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
recoverUndeliveredMessages(db, client, registry).catch((err) => {
|
|
81
|
+
log(`init:recover-messages:failed err=${err instanceof Error ? err.message : String(err)}`)
|
|
82
|
+
})
|
|
83
|
+
recoverOrphanedWorktrees(db, client).catch((err) => {
|
|
84
|
+
log(`init:recover-worktrees:failed err=${err instanceof Error ? err.message : String(err)}`)
|
|
85
|
+
})
|
|
86
|
+
log("init:recovery:done")
|
|
87
|
+
} else {
|
|
88
|
+
log(`init:skip-recovery (worktree instance: ${input.directory})`)
|
|
89
|
+
}
|
|
82
90
|
|
|
83
91
|
// Initialize rate limiter
|
|
84
92
|
// OPENCODE_ENSEMBLE_RATE_LIMIT=0 disables it entirely
|
|
@@ -141,9 +149,22 @@ const plugin: Plugin = async (input) => {
|
|
|
141
149
|
await notifyWorkingProgress(client, db, transition.teamId)
|
|
142
150
|
}
|
|
143
151
|
|
|
144
|
-
//
|
|
152
|
+
// Wake the lead when it goes idle and has pending messages.
|
|
153
|
+
// The system prompt transform delivers the actual message content.
|
|
145
154
|
if (statusType === "idle") {
|
|
146
|
-
|
|
155
|
+
const team = db.query("SELECT id FROM team WHERE lead_session_id = ? AND status = 'active'").get(sessionID) as { id: string } | null
|
|
156
|
+
if (team) {
|
|
157
|
+
const pending = db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND to_name = 'lead' AND delivered = 0").get(team.id) as { c: number }
|
|
158
|
+
if (pending.c > 0) {
|
|
159
|
+
log(`wake-lead: ${pending.c} pending messages, sending promptAsync`)
|
|
160
|
+
client.session.promptAsync({
|
|
161
|
+
sessionID,
|
|
162
|
+
parts: [{ type: "text", text: `[System: ${pending.c} new team message(s) available]` }],
|
|
163
|
+
}).catch((err) => {
|
|
164
|
+
log(`wake-lead:failed err=${err instanceof Error ? err.message : String(err)}`)
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
}
|
|
147
168
|
}
|
|
148
169
|
}
|
|
149
170
|
|
|
@@ -171,9 +192,11 @@ const plugin: Plugin = async (input) => {
|
|
|
171
192
|
if (!input.sessionID) return
|
|
172
193
|
const teamInfo = findTeamBySession(db, registry, input.sessionID)
|
|
173
194
|
if (!teamInfo) return
|
|
195
|
+
log(`system-prompt:transform role=${teamInfo.role} session=${input.sessionID}`)
|
|
174
196
|
const prompt = teamInfo.role === "lead"
|
|
175
197
|
? buildLeadSystemPrompt(db, teamInfo.teamId)
|
|
176
198
|
: buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown")
|
|
199
|
+
log(`system-prompt:injected role=${teamInfo.role} len=${prompt.length}`)
|
|
177
200
|
output.system.push(prompt)
|
|
178
201
|
},
|
|
179
202
|
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Plugin logger using the v2 SDK's structured logging API (flat params, no body wrapper). */
|
|
2
|
+
let _client: { app: { log: (params: { service: string; level: string; message: string }) => Promise<unknown> } } | null = null
|
|
3
|
+
|
|
4
|
+
/** Set the SDK client for logging. Called once during plugin init with the raw v2 client. */
|
|
5
|
+
export function initLog(client: unknown): void {
|
|
6
|
+
_client = client as typeof _client
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Log a message via the SDK's app.log API so it appears in OpenCode's structured logs. */
|
|
10
|
+
export function log(msg: string): void {
|
|
11
|
+
if (!_client) return
|
|
12
|
+
_client.app.log({ service: "ensemble", level: "info", message: msg }).catch(() => {})
|
|
13
|
+
}
|
package/src/messaging.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite"
|
|
2
|
-
import type { PluginClient } from "./types"
|
|
3
2
|
import { generateId } from "./util"
|
|
4
3
|
|
|
5
4
|
const MAX_CONTENT_BYTES = 10 * 1024 // 10KB
|
|
@@ -77,40 +76,3 @@ export function getUndeliveredMessages(db: Database, teamId: string): MessageRow
|
|
|
77
76
|
export function markDelivered(db: Database, messageId: string): void {
|
|
78
77
|
db.run("UPDATE team_message SET delivered = 1 WHERE id = ?", [messageId])
|
|
79
78
|
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Flush undelivered messages addressed to the lead for a given team.
|
|
83
|
-
* Batches all pending messages into a single promptAsync call to avoid
|
|
84
|
-
* interrupting the lead with multiple deliveries. Returns count of delivered messages.
|
|
85
|
-
*/
|
|
86
|
-
export async function flushPendingMessages(
|
|
87
|
-
db: Database,
|
|
88
|
-
client: PluginClient,
|
|
89
|
-
teamId: string,
|
|
90
|
-
leadSessionId: string,
|
|
91
|
-
): Promise<number> {
|
|
92
|
-
const pending = db.query(
|
|
93
|
-
"SELECT * FROM team_message WHERE team_id = ? AND delivered = 0 AND to_name = 'lead' ORDER BY time_created ASC"
|
|
94
|
-
).all(teamId) as MessageRow[]
|
|
95
|
-
|
|
96
|
-
if (pending.length === 0) return 0
|
|
97
|
-
|
|
98
|
-
const MAX_LEAD_MSG = 500
|
|
99
|
-
const parts = pending.map((msg) => {
|
|
100
|
-
if (msg.content.length > MAX_LEAD_MSG) {
|
|
101
|
-
return `[Team message from ${msg.from_name}]: ${msg.content.slice(0, MAX_LEAD_MSG)}... (use team_results to read full message)`
|
|
102
|
-
}
|
|
103
|
-
return `[Team message from ${msg.from_name}]: ${msg.content}`
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
try {
|
|
107
|
-
await client.session.promptAsync({
|
|
108
|
-
sessionID: leadSessionId,
|
|
109
|
-
parts: [{ type: "text", text: parts.join("\n") }],
|
|
110
|
-
})
|
|
111
|
-
for (const msg of pending) markDelivered(db, msg.id)
|
|
112
|
-
return pending.length
|
|
113
|
-
} catch {
|
|
114
|
-
return 0
|
|
115
|
-
}
|
|
116
|
-
}
|
package/src/recovery.ts
CHANGED
|
@@ -98,7 +98,8 @@ export async function recoverUndeliveredMessages(
|
|
|
98
98
|
let recipientSessionId: string | undefined
|
|
99
99
|
|
|
100
100
|
if (msg.to_name === "lead") {
|
|
101
|
-
|
|
101
|
+
// Skip lead-bound messages — the system prompt transform delivers them
|
|
102
|
+
continue
|
|
102
103
|
} else if (msg.to_name) {
|
|
103
104
|
const entry = registry.getByName(team.id, msg.to_name)
|
|
104
105
|
recipientSessionId = entry?.sessionId
|
package/src/system-prompt.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite"
|
|
2
|
+
import { markDelivered } from "./messaging"
|
|
2
3
|
|
|
3
4
|
/** Status display mapping from DB status to human-readable label. */
|
|
4
5
|
const STATUS_DISPLAY: Record<string, string> = {
|
|
@@ -40,16 +41,43 @@ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
|
|
|
40
41
|
? `Teammates: ${memberList}`
|
|
41
42
|
: "Teammates: none"
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
const pendingMessages = db.query(
|
|
45
|
+
"SELECT id, from_name, content FROM team_message WHERE team_id = ? AND to_name = 'lead' AND delivered = 0 ORDER BY time_created ASC"
|
|
46
|
+
).all(teamId) as Array<{ id: string; from_name: string; content: string }>
|
|
47
|
+
|
|
48
|
+
const lines = [
|
|
44
49
|
`You are leading team "${team.name}" with ${members.length} active teammates.`,
|
|
45
50
|
teammateLine,
|
|
46
51
|
`Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`,
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
if (pendingMessages.length > 0) {
|
|
55
|
+
lines.push("", "--- Team Messages ---")
|
|
56
|
+
const MAX_MSG = 500
|
|
57
|
+
for (const msg of pendingMessages) {
|
|
58
|
+
if (msg.content.length > MAX_MSG) {
|
|
59
|
+
lines.push(`[From ${msg.from_name}]: ${msg.content.slice(0, MAX_MSG)}... (use team_results to read full message)`)
|
|
60
|
+
} else {
|
|
61
|
+
lines.push(`[From ${msg.from_name}]: ${msg.content}`)
|
|
62
|
+
}
|
|
63
|
+
markDelivered(db, msg.id)
|
|
64
|
+
}
|
|
65
|
+
lines.push("--- End Messages ---")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
lines.push(
|
|
69
|
+
"",
|
|
70
|
+
"CRITICAL: Spawn teammates ONE AT A TIME. Send only ONE team_spawn call per response.",
|
|
71
|
+
"Wait for the tool result before spawning the next teammate.",
|
|
72
|
+
"Multiple team_spawn calls in a single response will cause timeouts.",
|
|
47
73
|
"",
|
|
48
74
|
"Teammates work asynchronously and message you when done.",
|
|
49
75
|
"Do NOT poll team_status or team_tasks_list repeatedly — wait for messages.",
|
|
50
|
-
"After spawning teammates, tell the user what you've set up and wait.",
|
|
76
|
+
"After spawning all teammates, tell the user what you've set up and wait.",
|
|
51
77
|
"When all teammates finish, summarize results and suggest next steps.",
|
|
52
|
-
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return lines.join("\n")
|
|
53
81
|
}
|
|
54
82
|
|
|
55
83
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
2
|
import { findTeamBySession } from "../types"
|
|
3
3
|
import { broadcastMessage, markDelivered } from "../messaging"
|
|
4
|
+
import { log } from "../log"
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Execute the team_broadcast tool. Sends a message to all team members + lead (excluding sender).
|
|
@@ -48,7 +49,9 @@ export async function executeTeamBroadcast(
|
|
|
48
49
|
}).then(() => {
|
|
49
50
|
delivered++
|
|
50
51
|
if (delivered === 1) markDelivered(deps.db, msgId)
|
|
51
|
-
}).catch(() => {
|
|
52
|
+
}).catch((err) => {
|
|
53
|
+
log(`team_broadcast:deliver:failed to=${recipient.name} err=${err instanceof Error ? err.message : String(err)}`)
|
|
54
|
+
})
|
|
52
55
|
}
|
|
53
56
|
|
|
54
57
|
return `Broadcast sent to ${recipients.length} recipient${recipients.length !== 1 ? "s" : ""}.`
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
2
|
import { findTeamBySession, resolveRecipientSession } from "../types"
|
|
3
3
|
import { sendMessage, markDelivered } from "../messaging"
|
|
4
|
+
import { log } from "../log"
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Execute the team_message tool. Sends a direct message to a teammate or lead.
|
|
@@ -56,43 +57,33 @@ export async function executeTeamMessage(
|
|
|
56
57
|
content: messageText,
|
|
57
58
|
})
|
|
58
59
|
|
|
59
|
-
// Deliver via promptAsync — truncate lead-bound messages over 500 chars
|
|
60
60
|
const isToLead = args.to === "lead"
|
|
61
|
-
const MAX_LEAD_MSG = 500
|
|
62
|
-
let deliveryText: string
|
|
63
|
-
if (isToLead && messageText.length > MAX_LEAD_MSG) {
|
|
64
|
-
const truncated = messageText.slice(0, MAX_LEAD_MSG)
|
|
65
|
-
deliveryText = `[Team message from ${senderName}]: ${truncated}... (use team_results to read full message)`
|
|
66
|
-
} else {
|
|
67
|
-
deliveryText = `[Team message from ${senderName}]: ${messageText}`
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Check if recipient is busy — if so, queue the message for later delivery.
|
|
71
|
-
// TOCTOU: status check and promptAsync are not atomic. A recipient can transition
|
|
72
|
-
// idle→busy between the check and delivery. This is accepted as benign — the idle-flush
|
|
73
|
-
// backstop catches anything that slips through, so no messages are lost.
|
|
74
|
-
let recipientBusy = false
|
|
75
|
-
try {
|
|
76
|
-
const statusResult = await deps.client.session.status()
|
|
77
|
-
const sessionStatus = statusResult.data?.[recipientSessionId]
|
|
78
|
-
if (sessionStatus?.type === "busy") {
|
|
79
|
-
recipientBusy = true
|
|
80
|
-
}
|
|
81
|
-
} catch { /* status check failed — deliver anyway as best effort */ }
|
|
82
61
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
62
|
+
// Lead-bound messages: store in DB, then wake the lead with a minimal promptAsync.
|
|
63
|
+
// The system prompt transform delivers the actual message content on the lead's next turn.
|
|
64
|
+
// This runs in the teammate's worktree instance — the event hook can't wake the lead
|
|
65
|
+
// because session.idle events are scoped per-instance.
|
|
66
|
+
if (isToLead) {
|
|
67
|
+
log(`team_message:wake-lead from=${senderName} recipientSession=${recipientSessionId}`)
|
|
68
|
+
deps.client.session.promptAsync({
|
|
69
|
+
sessionID: recipientSessionId,
|
|
70
|
+
parts: [{ type: "text", text: `[System: New team message from ${senderName}]` }],
|
|
71
|
+
}).catch((err) => {
|
|
72
|
+
log(`team_message:wake-lead:failed from=${senderName} err=${err instanceof Error ? err.message : String(err)}`)
|
|
73
|
+
})
|
|
74
|
+
return `Message sent to ${args.to}.`
|
|
86
75
|
}
|
|
87
76
|
|
|
88
|
-
//
|
|
89
|
-
|
|
77
|
+
// For member-to-member messages, fire-and-forget delivery is safe.
|
|
78
|
+
const deliveryText = `[Team message from ${senderName}]: ${messageText}`
|
|
90
79
|
deps.client.session.promptAsync({
|
|
91
80
|
sessionID: recipientSessionId,
|
|
92
81
|
parts: [{ type: "text", text: deliveryText }],
|
|
93
82
|
}).then(() => {
|
|
94
83
|
markDelivered(deps.db, msgId)
|
|
95
|
-
}).catch(() => {
|
|
84
|
+
}).catch((err) => {
|
|
85
|
+
log(`team_message:deliver:failed to=${args.to} err=${err instanceof Error ? err.message : String(err)}`)
|
|
86
|
+
})
|
|
96
87
|
|
|
97
88
|
return `Message sent to ${args.to}.`
|
|
98
89
|
}
|
package/src/tools/team-spawn.ts
CHANGED
|
@@ -2,7 +2,23 @@ import type { ToolDeps, PermissionRule } from "../types"
|
|
|
2
2
|
import { validateMemberName } from "../util"
|
|
3
3
|
import { findTeamBySession } from "../types"
|
|
4
4
|
import { sendMessage } from "../messaging"
|
|
5
|
-
import {
|
|
5
|
+
import { log } from "../log"
|
|
6
|
+
|
|
7
|
+
/** Timeout for worktree.create and session.create to prevent hanging on git lock contention. */
|
|
8
|
+
function getSpawnTimeout(): number {
|
|
9
|
+
return Number(process.env.SPAWN_TIMEOUT_MS) || 120_000
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Race a promise against a timeout. Throws if the timeout fires first. Cleans up timer on resolution. */
|
|
13
|
+
function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
|
|
14
|
+
let timer: ReturnType<typeof setTimeout>
|
|
15
|
+
return Promise.race([
|
|
16
|
+
promise.finally(() => clearTimeout(timer)),
|
|
17
|
+
new Promise<never>((_, reject) => {
|
|
18
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)
|
|
19
|
+
}),
|
|
20
|
+
])
|
|
21
|
+
}
|
|
6
22
|
|
|
7
23
|
/**
|
|
8
24
|
* Execute the team_spawn tool. Creates a child session and starts a teammate.
|
|
@@ -30,6 +46,8 @@ export async function executeTeamSpawn(
|
|
|
30
46
|
const useWorktree = args.worktree !== false
|
|
31
47
|
const usePlanApproval = args.plan_approval === true
|
|
32
48
|
|
|
49
|
+
log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`)
|
|
50
|
+
|
|
33
51
|
// Create worktree if enabled
|
|
34
52
|
let worktreeDir: string | null = null
|
|
35
53
|
let worktreeBranch: string | null = null
|
|
@@ -37,13 +55,18 @@ export async function executeTeamSpawn(
|
|
|
37
55
|
if (useWorktree) {
|
|
38
56
|
const worktreeName = `ensemble-${teamInfo.teamName}-${args.name}`
|
|
39
57
|
try {
|
|
40
|
-
|
|
58
|
+
log(`spawn:worktree:start name=${args.name}`)
|
|
59
|
+
const result = await withTimeout(
|
|
60
|
+
deps.client.worktree.create({ worktreeCreateInput: { name: worktreeName } }),
|
|
61
|
+
getSpawnTimeout(), `worktree.create for "${args.name}"`
|
|
62
|
+
)
|
|
41
63
|
if (result.data) {
|
|
42
64
|
worktreeDir = result.data.directory
|
|
43
65
|
worktreeBranch = result.data.branch
|
|
44
66
|
}
|
|
45
|
-
|
|
46
|
-
|
|
67
|
+
log(`spawn:worktree:done name=${args.name} dir=${worktreeDir}`)
|
|
68
|
+
} catch (err) {
|
|
69
|
+
log(`spawn:worktree:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`)
|
|
47
70
|
try {
|
|
48
71
|
await deps.client.tui.showToast({
|
|
49
72
|
title: "Team",
|
|
@@ -71,14 +94,20 @@ export async function executeTeamSpawn(
|
|
|
71
94
|
// Create child session — use worktree directory if available
|
|
72
95
|
let childSessionId: string | undefined
|
|
73
96
|
try {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
97
|
+
log(`spawn:session:start name=${args.name}`)
|
|
98
|
+
const createResult = await withTimeout(
|
|
99
|
+
deps.client.session.create({
|
|
100
|
+
parentID: sessionId,
|
|
101
|
+
title: `${args.name} (@${args.agent} teammate)`,
|
|
102
|
+
...(permission ? { permission } : {}),
|
|
103
|
+
...(worktreeDir ? { directory: worktreeDir } : {}),
|
|
104
|
+
}),
|
|
105
|
+
getSpawnTimeout(), `session.create for "${args.name}"`
|
|
106
|
+
)
|
|
80
107
|
childSessionId = createResult.data?.id
|
|
108
|
+
log(`spawn:session:done name=${args.name} sessionId=${childSessionId}`)
|
|
81
109
|
} catch (err) {
|
|
110
|
+
log(`spawn:session:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`)
|
|
82
111
|
// Rollback worktree if session creation failed
|
|
83
112
|
if (worktreeDir) {
|
|
84
113
|
try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
|
|
@@ -159,15 +188,13 @@ export async function executeTeamSpawn(
|
|
|
159
188
|
const contextStr = context.join("\n")
|
|
160
189
|
|
|
161
190
|
// Fire-and-forget: send prompt to teammate session.
|
|
162
|
-
|
|
163
|
-
// but awaiting it can block if the transport is slow or broken (e.g. proxy).
|
|
164
|
-
// The child session is already created and registered. If delivery fails,
|
|
165
|
-
// the async .catch() rolls back the member and the watchdog handles stale members.
|
|
191
|
+
log(`spawn:promptAsync:fire name=${args.name} sessionId=${childSessionId}`)
|
|
166
192
|
deps.client.session.promptAsync({
|
|
167
193
|
sessionID: childSessionId,
|
|
168
194
|
parts: [{ type: "text", text: contextStr }],
|
|
169
195
|
agent: args.agent,
|
|
170
196
|
}).catch(() => {
|
|
197
|
+
log(`spawn:promptAsync:failed name=${args.name} — rolling back`)
|
|
171
198
|
try {
|
|
172
199
|
// Async rollback: clean up DB (by session_id to avoid deleting a re-spawned member with the same name), registry, session, and worktree
|
|
173
200
|
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId])
|
|
@@ -184,21 +211,18 @@ export async function executeTeamSpawn(
|
|
|
184
211
|
duration: 5000,
|
|
185
212
|
}).catch(() => { /* TUI may not be available */ })
|
|
186
213
|
// Notify the lead model so it can react (retry, adjust plan, etc.)
|
|
214
|
+
// Message delivered via system prompt transform on the lead's next turn.
|
|
187
215
|
sendMessage(deps.db, {
|
|
188
216
|
teamId: teamInfo.teamId,
|
|
189
217
|
from: "system",
|
|
190
218
|
to: "lead",
|
|
191
219
|
content: `Teammate "${args.name}" failed to start and was removed. You may retry the spawn.`,
|
|
192
220
|
})
|
|
193
|
-
const team = deps.db.query("SELECT lead_session_id FROM team WHERE id = ?")
|
|
194
|
-
.get(teamInfo.teamId) as { lead_session_id: string } | null
|
|
195
|
-
if (team) {
|
|
196
|
-
handleLeadIdleFlush(deps.db, deps.client, team.lead_session_id).catch(() => {})
|
|
197
|
-
}
|
|
198
221
|
} catch { /* rollback failed — watchdog will clean up stale member */ }
|
|
199
222
|
})
|
|
200
223
|
|
|
201
224
|
const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : ""
|
|
202
225
|
const planInfo = usePlanApproval ? " [plan mode — will send plan for approval]" : ""
|
|
226
|
+
log(`spawn:done name=${args.name} sessionId=${childSessionId}`)
|
|
203
227
|
return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
|
|
204
228
|
}
|
package/src/util.ts
CHANGED
|
@@ -23,6 +23,14 @@ export function validateTeamName(name: string): string | undefined {
|
|
|
23
23
|
return undefined
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Detect if the plugin is loading inside a teammate's worktree instance.
|
|
28
|
+
* Worktree instances should skip recovery to avoid deadlocking the server.
|
|
29
|
+
*/
|
|
30
|
+
export function isWorktreeInstance(directory: string): boolean {
|
|
31
|
+
return directory.includes("/worktree/") && directory.includes("/ensemble-")
|
|
32
|
+
}
|
|
33
|
+
|
|
26
34
|
/**
|
|
27
35
|
* Validate a member name. Same rules as team name, plus "lead" is reserved.
|
|
28
36
|
* Returns an error message string if invalid, undefined if valid.
|