@hueyexe/opencode-ensemble 0.6.2 → 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 +163 -115
- package/package.json +3 -1
- package/src/client.ts +6 -6
- package/src/db.ts +1 -1
- package/src/hooks.ts +1 -20
- package/src/index.ts +55 -31
- package/src/log.ts +13 -0
- package/src/messaging.ts +0 -38
- package/src/notify.ts +0 -11
- package/src/recovery.ts +2 -1
- package/src/schema.ts +2 -1
- package/src/state.ts +5 -0
- package/src/system-prompt.ts +37 -9
- package/src/tools/team-broadcast.ts +12 -16
- package/src/tools/team-claim.ts +1 -1
- package/src/tools/team-message.ts +22 -29
- package/src/tools/team-spawn.ts +75 -27
- package/src/util.ts +8 -0
package/dist/index.js
CHANGED
|
@@ -15371,7 +15371,9 @@ var MIGRATIONS = [
|
|
|
15371
15371
|
function applyMigrations(db) {
|
|
15372
15372
|
const { user_version: current } = db.query("PRAGMA user_version").get();
|
|
15373
15373
|
for (let i = current;i < MIGRATIONS.length; i++) {
|
|
15374
|
-
|
|
15374
|
+
const migration = MIGRATIONS[i];
|
|
15375
|
+
if (migration)
|
|
15376
|
+
db.exec(migration);
|
|
15375
15377
|
db.exec(`PRAGMA user_version = ${i + 1}`);
|
|
15376
15378
|
}
|
|
15377
15379
|
}
|
|
@@ -15444,6 +15446,9 @@ function validateTeamName(name) {
|
|
|
15444
15446
|
return "Team name must be lowercase alphanumeric with hyphens only";
|
|
15445
15447
|
return;
|
|
15446
15448
|
}
|
|
15449
|
+
function isWorktreeInstance(directory) {
|
|
15450
|
+
return directory.includes("/worktree/") && directory.includes("/ensemble-");
|
|
15451
|
+
}
|
|
15447
15452
|
function validateMemberName(name) {
|
|
15448
15453
|
if (name.toLowerCase() === "lead")
|
|
15449
15454
|
return `Name "lead" is reserved`;
|
|
@@ -15478,30 +15483,6 @@ function getUndeliveredMessages(db, teamId) {
|
|
|
15478
15483
|
function markDelivered(db, messageId) {
|
|
15479
15484
|
db.run("UPDATE team_message SET delivered = 1 WHERE id = ?", [messageId]);
|
|
15480
15485
|
}
|
|
15481
|
-
async function flushPendingMessages(db, client3, teamId, leadSessionId) {
|
|
15482
|
-
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);
|
|
15483
|
-
if (pending.length === 0)
|
|
15484
|
-
return 0;
|
|
15485
|
-
const MAX_LEAD_MSG = 500;
|
|
15486
|
-
const parts = pending.map((msg) => {
|
|
15487
|
-
if (msg.content.length > MAX_LEAD_MSG) {
|
|
15488
|
-
return `[Team message from ${msg.from_name}]: ${msg.content.slice(0, MAX_LEAD_MSG)}... (use team_results to read full message)`;
|
|
15489
|
-
}
|
|
15490
|
-
return `[Team message from ${msg.from_name}]: ${msg.content}`;
|
|
15491
|
-
});
|
|
15492
|
-
try {
|
|
15493
|
-
await client3.session.promptAsync({
|
|
15494
|
-
sessionID: leadSessionId,
|
|
15495
|
-
parts: [{ type: "text", text: parts.join(`
|
|
15496
|
-
`) }]
|
|
15497
|
-
});
|
|
15498
|
-
for (const msg of pending)
|
|
15499
|
-
markDelivered(db, msg.id);
|
|
15500
|
-
return pending.length;
|
|
15501
|
-
} catch {
|
|
15502
|
-
return 0;
|
|
15503
|
-
}
|
|
15504
|
-
}
|
|
15505
15486
|
|
|
15506
15487
|
// src/recovery.ts
|
|
15507
15488
|
async function recoverStaleMembers(db, client3) {
|
|
@@ -15550,7 +15531,7 @@ async function recoverUndeliveredMessages(db, client3, registry2) {
|
|
|
15550
15531
|
for (const msg of messages) {
|
|
15551
15532
|
let recipientSessionId;
|
|
15552
15533
|
if (msg.to_name === "lead") {
|
|
15553
|
-
|
|
15534
|
+
continue;
|
|
15554
15535
|
} else if (msg.to_name) {
|
|
15555
15536
|
const entry = registry2.getByName(team.id, msg.to_name);
|
|
15556
15537
|
recipientSessionId = entry?.sessionId;
|
|
@@ -15611,6 +15592,9 @@ class MemberRegistry {
|
|
|
15611
15592
|
isTeamSession(sessionId) {
|
|
15612
15593
|
return this.bySession.has(sessionId);
|
|
15613
15594
|
}
|
|
15595
|
+
allSessionIds() {
|
|
15596
|
+
return new Set(this.bySession.keys());
|
|
15597
|
+
}
|
|
15614
15598
|
}
|
|
15615
15599
|
var DEFAULT_MAX_DEPTH = 10;
|
|
15616
15600
|
|
|
@@ -15680,20 +15664,11 @@ function checkToolIsolation(registry2, tracker, toolName, sessionId) {
|
|
|
15680
15664
|
return;
|
|
15681
15665
|
if (registry2.isTeamSession(sessionId))
|
|
15682
15666
|
return;
|
|
15683
|
-
const allTeamSessions =
|
|
15684
|
-
for (const entry of registry2["bySession"].keys()) {
|
|
15685
|
-
allTeamSessions.add(entry);
|
|
15686
|
-
}
|
|
15667
|
+
const allTeamSessions = registry2.allSessionIds();
|
|
15687
15668
|
if (allTeamSessions.size > 0 && tracker.isDescendantOf(sessionId, allTeamSessions)) {
|
|
15688
15669
|
throw new Error("Team tools are not available to sub-agents. Report findings to your parent teammate via your normal output.");
|
|
15689
15670
|
}
|
|
15690
15671
|
}
|
|
15691
|
-
async function handleLeadIdleFlush(db, client3, sessionId) {
|
|
15692
|
-
const team = db.query("SELECT id FROM team WHERE lead_session_id = ? AND status = 'active'").get(sessionId);
|
|
15693
|
-
if (!team)
|
|
15694
|
-
return 0;
|
|
15695
|
-
return flushPendingMessages(db, client3, team.id, sessionId);
|
|
15696
|
-
}
|
|
15697
15672
|
|
|
15698
15673
|
// src/notify.ts
|
|
15699
15674
|
var TOAST_CONFIG = {
|
|
@@ -15771,21 +15746,32 @@ function buildLeadSystemPrompt(db, teamId) {
|
|
|
15771
15746
|
for (const row of taskCounts) {
|
|
15772
15747
|
countMap[row.status] = row.count;
|
|
15773
15748
|
}
|
|
15774
|
-
const completed = countMap
|
|
15775
|
-
const inProgress = countMap
|
|
15776
|
-
const pending = countMap
|
|
15749
|
+
const completed = countMap.completed ?? 0;
|
|
15750
|
+
const inProgress = countMap.in_progress ?? 0;
|
|
15751
|
+
const pending = countMap.pending ?? 0;
|
|
15777
15752
|
const memberList = members.map((m) => `${m.name} [${STATUS_DISPLAY[m.status] ?? m.status}]`).join(", ");
|
|
15778
15753
|
const teammateLine = members.length > 0 ? `Teammates: ${memberList}` : "Teammates: none";
|
|
15779
|
-
|
|
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 = [
|
|
15780
15756
|
`You are leading team "${team.name}" with ${members.length} active teammates.`,
|
|
15781
15757
|
teammateLine,
|
|
15782
|
-
`Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending
|
|
15783
|
-
|
|
15784
|
-
|
|
15785
|
-
"
|
|
15786
|
-
|
|
15787
|
-
|
|
15788
|
-
|
|
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(`
|
|
15789
15775
|
`);
|
|
15790
15776
|
}
|
|
15791
15777
|
function buildTeammateSystemPrompt(db, teamId, memberName) {
|
|
@@ -15804,9 +15790,9 @@ function buildTeamCompactionContext(db, teamId, role, memberName) {
|
|
|
15804
15790
|
for (const row of taskCounts) {
|
|
15805
15791
|
countMap[row.status] = row.count;
|
|
15806
15792
|
}
|
|
15807
|
-
const completed = countMap
|
|
15808
|
-
const inProgress = countMap
|
|
15809
|
-
const pending = countMap
|
|
15793
|
+
const completed = countMap.completed ?? 0;
|
|
15794
|
+
const inProgress = countMap.in_progress ?? 0;
|
|
15795
|
+
const pending = countMap.pending ?? 0;
|
|
15810
15796
|
const roleLine = role === "lead" ? `[Team Context] You are the lead of team "${team.name}".` : `[Team Context] You are a teammate named "${memberName}" in team "${team.name}".`;
|
|
15811
15797
|
const memberList = members.map((m) => `${m.name} (${STATUS_DISPLAY[m.status] ?? m.status})`).join(", ");
|
|
15812
15798
|
const membersLine = members.length > 0 ? `Members: ${memberList}` : "Members: none";
|
|
@@ -15818,6 +15804,17 @@ function buildTeamCompactionContext(db, teamId, role, memberName) {
|
|
|
15818
15804
|
`);
|
|
15819
15805
|
}
|
|
15820
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
|
+
|
|
15821
15818
|
// src/types.ts
|
|
15822
15819
|
function findTeamBySession(db, registry2, sessionId) {
|
|
15823
15820
|
const entry = registry2.getBySession(sessionId);
|
|
@@ -15858,6 +15855,18 @@ async function executeTeamCreate(deps, args, sessionId) {
|
|
|
15858
15855
|
}
|
|
15859
15856
|
|
|
15860
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
|
+
}
|
|
15861
15870
|
async function executeTeamSpawn(deps, args, sessionId) {
|
|
15862
15871
|
const nameError = validateMemberName(args.name);
|
|
15863
15872
|
if (nameError)
|
|
@@ -15872,17 +15881,21 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15872
15881
|
throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
|
|
15873
15882
|
const useWorktree = args.worktree !== false;
|
|
15874
15883
|
const usePlanApproval = args.plan_approval === true;
|
|
15884
|
+
log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`);
|
|
15875
15885
|
let worktreeDir = null;
|
|
15876
15886
|
let worktreeBranch = null;
|
|
15877
15887
|
if (useWorktree) {
|
|
15878
15888
|
const worktreeName = `ensemble-${teamInfo.teamName}-${args.name}`;
|
|
15879
15889
|
try {
|
|
15880
|
-
|
|
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}"`);
|
|
15881
15892
|
if (result.data) {
|
|
15882
15893
|
worktreeDir = result.data.directory;
|
|
15883
15894
|
worktreeBranch = result.data.branch;
|
|
15884
15895
|
}
|
|
15885
|
-
|
|
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)}`);
|
|
15886
15899
|
try {
|
|
15887
15900
|
await deps.client.tui.showToast({
|
|
15888
15901
|
title: "Team",
|
|
@@ -15902,14 +15915,17 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15902
15915
|
] : undefined;
|
|
15903
15916
|
let childSessionId;
|
|
15904
15917
|
try {
|
|
15905
|
-
|
|
15918
|
+
log(`spawn:session:start name=${args.name}`);
|
|
15919
|
+
const createResult = await withTimeout(deps.client.session.create({
|
|
15906
15920
|
parentID: sessionId,
|
|
15907
15921
|
title: `${args.name} (@${args.agent} teammate)`,
|
|
15908
15922
|
...permission ? { permission } : {},
|
|
15909
15923
|
...worktreeDir ? { directory: worktreeDir } : {}
|
|
15910
|
-
});
|
|
15924
|
+
}), getSpawnTimeout(), `session.create for "${args.name}"`);
|
|
15911
15925
|
childSessionId = createResult.data?.id;
|
|
15926
|
+
log(`spawn:session:done name=${args.name} sessionId=${childSessionId}`);
|
|
15912
15927
|
} catch (err) {
|
|
15928
|
+
log(`spawn:session:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`);
|
|
15913
15929
|
if (worktreeDir) {
|
|
15914
15930
|
try {
|
|
15915
15931
|
await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
|
|
@@ -15947,27 +15963,37 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15947
15963
|
}
|
|
15948
15964
|
const contextStr = context.join(`
|
|
15949
15965
|
`);
|
|
15950
|
-
|
|
15951
|
-
|
|
15952
|
-
|
|
15953
|
-
|
|
15954
|
-
|
|
15955
|
-
|
|
15956
|
-
|
|
15957
|
-
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND name = ?", [teamInfo.teamId, args.name]);
|
|
15958
|
-
deps.registry.unregister(childSessionId);
|
|
15966
|
+
log(`spawn:promptAsync:fire name=${args.name} sessionId=${childSessionId}`);
|
|
15967
|
+
deps.client.session.promptAsync({
|
|
15968
|
+
sessionID: childSessionId,
|
|
15969
|
+
parts: [{ type: "text", text: contextStr }],
|
|
15970
|
+
agent: args.agent
|
|
15971
|
+
}).catch(() => {
|
|
15972
|
+
log(`spawn:promptAsync:failed name=${args.name} \u2014 rolling back`);
|
|
15959
15973
|
try {
|
|
15960
|
-
|
|
15974
|
+
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId]);
|
|
15975
|
+
deps.registry.unregister(childSessionId);
|
|
15976
|
+
deps.client.session.abort({ sessionID: childSessionId }).catch(() => {});
|
|
15977
|
+
if (worktreeDir) {
|
|
15978
|
+
deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => {});
|
|
15979
|
+
}
|
|
15980
|
+
deps.client.tui.showToast({
|
|
15981
|
+
title: "Team",
|
|
15982
|
+
message: `Teammate "${args.name}" failed to start and was removed`,
|
|
15983
|
+
variant: "error",
|
|
15984
|
+
duration: 5000
|
|
15985
|
+
}).catch(() => {});
|
|
15986
|
+
sendMessage(deps.db, {
|
|
15987
|
+
teamId: teamInfo.teamId,
|
|
15988
|
+
from: "system",
|
|
15989
|
+
to: "lead",
|
|
15990
|
+
content: `Teammate "${args.name}" failed to start and was removed. You may retry the spawn.`
|
|
15991
|
+
});
|
|
15961
15992
|
} catch {}
|
|
15962
|
-
|
|
15963
|
-
try {
|
|
15964
|
-
await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
|
|
15965
|
-
} catch {}
|
|
15966
|
-
}
|
|
15967
|
-
throw new Error(`Failed to send initial prompt to teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
15968
|
-
}
|
|
15993
|
+
});
|
|
15969
15994
|
const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : "";
|
|
15970
15995
|
const planInfo = usePlanApproval ? " [plan mode \u2014 will send plan for approval]" : "";
|
|
15996
|
+
log(`spawn:done name=${args.name} sessionId=${childSessionId}`);
|
|
15971
15997
|
return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`;
|
|
15972
15998
|
}
|
|
15973
15999
|
|
|
@@ -15976,7 +16002,7 @@ async function executeTeamMessage(deps, args, sessionId) {
|
|
|
15976
16002
|
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
|
|
15977
16003
|
if (!teamInfo)
|
|
15978
16004
|
throw new Error("This session is not in a team. Use team_create first.");
|
|
15979
|
-
const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName;
|
|
16005
|
+
const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
|
|
15980
16006
|
const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to);
|
|
15981
16007
|
if (!recipientSessionId)
|
|
15982
16008
|
throw new Error(`Recipient "${args.to}" not found in team "${teamInfo.teamName}"`);
|
|
@@ -16007,30 +16033,25 @@ async function executeTeamMessage(deps, args, sessionId) {
|
|
|
16007
16033
|
content: messageText
|
|
16008
16034
|
});
|
|
16009
16035
|
const isToLead = args.to === "lead";
|
|
16010
|
-
|
|
16011
|
-
|
|
16012
|
-
|
|
16013
|
-
|
|
16014
|
-
|
|
16015
|
-
|
|
16016
|
-
|
|
16017
|
-
|
|
16018
|
-
|
|
16019
|
-
try {
|
|
16020
|
-
const statusResult = await deps.client.session.status();
|
|
16021
|
-
const sessionStatus = statusResult.data?.[recipientSessionId];
|
|
16022
|
-
if (sessionStatus?.type === "busy") {
|
|
16023
|
-
recipientBusy = true;
|
|
16024
|
-
}
|
|
16025
|
-
} catch {}
|
|
16026
|
-
if (recipientBusy) {
|
|
16027
|
-
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}.`;
|
|
16028
16045
|
}
|
|
16029
|
-
|
|
16046
|
+
const deliveryText = `[Team message from ${senderName}]: ${messageText}`;
|
|
16047
|
+
deps.client.session.promptAsync({
|
|
16030
16048
|
sessionID: recipientSessionId,
|
|
16031
16049
|
parts: [{ type: "text", text: deliveryText }]
|
|
16050
|
+
}).then(() => {
|
|
16051
|
+
markDelivered(deps.db, msgId);
|
|
16052
|
+
}).catch((err) => {
|
|
16053
|
+
log(`team_message:deliver:failed to=${args.to} err=${err instanceof Error ? err.message : String(err)}`);
|
|
16032
16054
|
});
|
|
16033
|
-
markDelivered(deps.db, msgId);
|
|
16034
16055
|
return `Message sent to ${args.to}.`;
|
|
16035
16056
|
}
|
|
16036
16057
|
|
|
@@ -16039,7 +16060,7 @@ async function executeTeamBroadcast(deps, args, sessionId) {
|
|
|
16039
16060
|
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
|
|
16040
16061
|
if (!teamInfo)
|
|
16041
16062
|
throw new Error("This session is not in a team. Use team_create first.");
|
|
16042
|
-
const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName;
|
|
16063
|
+
const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
|
|
16043
16064
|
const msgId = broadcastMessage(deps.db, {
|
|
16044
16065
|
teamId: teamInfo.teamId,
|
|
16045
16066
|
from: senderName,
|
|
@@ -16059,18 +16080,18 @@ async function executeTeamBroadcast(deps, args, sessionId) {
|
|
|
16059
16080
|
}
|
|
16060
16081
|
let delivered = 0;
|
|
16061
16082
|
for (const recipient of recipients) {
|
|
16062
|
-
|
|
16063
|
-
|
|
16064
|
-
|
|
16065
|
-
|
|
16066
|
-
});
|
|
16083
|
+
deps.client.session.promptAsync({
|
|
16084
|
+
sessionID: recipient.sessionId,
|
|
16085
|
+
parts: [{ type: "text", text: `[Team broadcast from ${senderName}]: ${args.text}` }]
|
|
16086
|
+
}).then(() => {
|
|
16067
16087
|
delivered++;
|
|
16068
|
-
|
|
16069
|
-
|
|
16070
|
-
|
|
16071
|
-
|
|
16088
|
+
if (delivered === 1)
|
|
16089
|
+
markDelivered(deps.db, msgId);
|
|
16090
|
+
}).catch((err) => {
|
|
16091
|
+
log(`team_broadcast:deliver:failed to=${recipient.name} err=${err instanceof Error ? err.message : String(err)}`);
|
|
16092
|
+
});
|
|
16072
16093
|
}
|
|
16073
|
-
return `Broadcast sent to ${
|
|
16094
|
+
return `Broadcast sent to ${recipients.length} recipient${recipients.length !== 1 ? "s" : ""}.`;
|
|
16074
16095
|
}
|
|
16075
16096
|
|
|
16076
16097
|
// src/tools/team-shutdown.ts
|
|
@@ -16240,7 +16261,7 @@ async function executeTeamClaim(deps, args, sessionId) {
|
|
|
16240
16261
|
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId);
|
|
16241
16262
|
if (!teamInfo)
|
|
16242
16263
|
throw new Error("This session is not in a team.");
|
|
16243
|
-
const claimerName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName;
|
|
16264
|
+
const claimerName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
|
|
16244
16265
|
const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?").get(args.task_id, teamInfo.teamId);
|
|
16245
16266
|
if (!task)
|
|
16246
16267
|
throw new Error(`Task "${args.task_id}" not found`);
|
|
@@ -16484,18 +16505,31 @@ var plugin = async (input) => {
|
|
|
16484
16505
|
const db = createDb(dbPath);
|
|
16485
16506
|
const registry2 = new MemberRegistry;
|
|
16486
16507
|
const tracker = new DescendantTracker;
|
|
16487
|
-
const
|
|
16508
|
+
const pluginTransport = input.client._client;
|
|
16509
|
+
const rawClient = new OpencodeClient({ client: pluginTransport });
|
|
16510
|
+
initLog(rawClient);
|
|
16488
16511
|
const client3 = wrapThrowingClient(rawClient);
|
|
16489
16512
|
const deps = { db, registry: registry2, tracker, client: client3, directory: input.directory };
|
|
16490
|
-
|
|
16491
|
-
|
|
16492
|
-
const
|
|
16493
|
-
|
|
16494
|
-
|
|
16495
|
-
|
|
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})`);
|
|
16496
16532
|
}
|
|
16497
|
-
recoverUndeliveredMessages(db, client3, registry2).catch(() => {});
|
|
16498
|
-
recoverOrphanedWorktrees(db, client3).catch(() => {});
|
|
16499
16533
|
const rateLimitEnv = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT;
|
|
16500
16534
|
const rateLimitCapacity = rateLimitEnv === "0" ? 0 : parseInt(rateLimitEnv ?? "", 10) || DEFAULT_RATE_LIMIT_CAPACITY;
|
|
16501
16535
|
const rateLimiter = new TokenBucket({
|
|
@@ -16546,7 +16580,19 @@ var plugin = async (input) => {
|
|
|
16546
16580
|
await notifyWorkingProgress(client3, db, transition.teamId);
|
|
16547
16581
|
}
|
|
16548
16582
|
if (statusType === "idle") {
|
|
16549
|
-
|
|
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
|
+
}
|
|
16550
16596
|
}
|
|
16551
16597
|
}
|
|
16552
16598
|
if (event.type === "session.created") {
|
|
@@ -16570,7 +16616,9 @@ var plugin = async (input) => {
|
|
|
16570
16616
|
const teamInfo = findTeamBySession(db, registry2, input2.sessionID);
|
|
16571
16617
|
if (!teamInfo)
|
|
16572
16618
|
return;
|
|
16573
|
-
|
|
16619
|
+
log(`system-prompt:transform role=${teamInfo.role} session=${input2.sessionID}`);
|
|
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}`);
|
|
16574
16622
|
output.system.push(prompt);
|
|
16575
16623
|
},
|
|
16576
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",
|
|
@@ -26,12 +26,14 @@
|
|
|
26
26
|
"README.md"
|
|
27
27
|
],
|
|
28
28
|
"scripts": {
|
|
29
|
+
"lint": "bunx biome lint src/",
|
|
29
30
|
"typecheck": "tsc --noEmit",
|
|
30
31
|
"test": "bun test",
|
|
31
32
|
"build": "bun build src/index.ts --outdir dist --target bun",
|
|
32
33
|
"prepublishOnly": "bun run typecheck && bun test && bun run build"
|
|
33
34
|
},
|
|
34
35
|
"devDependencies": {
|
|
36
|
+
"@biomejs/biome": "^2.4.9",
|
|
35
37
|
"@types/bun": "latest"
|
|
36
38
|
},
|
|
37
39
|
"peerDependencies": {
|
package/src/client.ts
CHANGED
|
@@ -6,11 +6,11 @@ function extractError(err: unknown): string {
|
|
|
6
6
|
return String(err)
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
type
|
|
9
|
+
/** Generic async SDK method type. Actual type safety enforced by PluginClient interface. */
|
|
10
|
+
type SdkMethod = (...args: unknown[]) => Promise<unknown>
|
|
11
11
|
|
|
12
12
|
/** Wrap a single async SDK method to throw on { error } responses. */
|
|
13
|
-
function throwing(fn:
|
|
13
|
+
function throwing(fn: SdkMethod): SdkMethod {
|
|
14
14
|
return async (...args: unknown[]) => {
|
|
15
15
|
const result = await fn(...args)
|
|
16
16
|
if (result && typeof result === "object" && "error" in result && result.error !== undefined) {
|
|
@@ -22,9 +22,9 @@ function throwing(fn: AnyFn): AnyFn {
|
|
|
22
22
|
|
|
23
23
|
/** Shape of the raw v2 SDK client — just the methods we wrap. */
|
|
24
24
|
interface RawClient {
|
|
25
|
-
session: { create:
|
|
26
|
-
tui: { showToast:
|
|
27
|
-
worktree: { create:
|
|
25
|
+
session: { create: SdkMethod; promptAsync: SdkMethod; abort: SdkMethod; status: SdkMethod }
|
|
26
|
+
tui: { showToast: SdkMethod; selectSession: SdkMethod }
|
|
27
|
+
worktree: { create: SdkMethod; remove: SdkMethod; list: SdkMethod; reset: SdkMethod }
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/**
|
package/src/db.ts
CHANGED
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
|
|
|
@@ -94,26 +92,9 @@ export function checkToolIsolation(
|
|
|
94
92
|
if (registry.isTeamSession(sessionId)) return
|
|
95
93
|
|
|
96
94
|
// Check if this session is a descendant of any team member
|
|
97
|
-
const allTeamSessions =
|
|
98
|
-
for (const entry of registry["bySession"].keys()) {
|
|
99
|
-
allTeamSessions.add(entry)
|
|
100
|
-
}
|
|
95
|
+
const allTeamSessions = registry.allSessionIds()
|
|
101
96
|
|
|
102
97
|
if (allTeamSessions.size > 0 && tracker.isDescendantOf(sessionId, allTeamSessions)) {
|
|
103
98
|
throw new Error("Team tools are not available to sub-agents. Report findings to your parent teammate via your normal output.")
|
|
104
99
|
}
|
|
105
100
|
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Check if a session going idle is a team lead with pending messages.
|
|
109
|
-
* If so, flush them via promptAsync. Called from the event hook on idle transitions.
|
|
110
|
-
*/
|
|
111
|
-
export async function handleLeadIdleFlush(
|
|
112
|
-
db: Database,
|
|
113
|
-
client: PluginClient,
|
|
114
|
-
sessionId: string,
|
|
115
|
-
): Promise<number> {
|
|
116
|
-
const team = db.query("SELECT id FROM team WHERE lead_session_id = ? AND status = 'active'").get(sessionId) as { id: string } | null
|
|
117
|
-
if (!team) return 0
|
|
118
|
-
return flushPendingMessages(db, client, team.id, sessionId)
|
|
119
|
-
}
|
package/src/index.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
2
|
import { tool } from "@opencode-ai/plugin"
|
|
3
|
-
import { OpencodeClient
|
|
4
|
-
import path from "path"
|
|
3
|
+
import { OpencodeClient } from "@opencode-ai/sdk/v2"
|
|
4
|
+
import path from "node:path"
|
|
5
5
|
import { mkdirSync } from "node:fs"
|
|
6
6
|
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"
|
|
@@ -24,7 +26,7 @@ import { executeTeamClaim } from "./tools/team-claim"
|
|
|
24
26
|
import { executeTeamResults } from "./tools/team-results"
|
|
25
27
|
import { executeTeamStatus } from "./tools/team-status"
|
|
26
28
|
import { executeTeamView } from "./tools/team-view"
|
|
27
|
-
import type { ToolDeps,
|
|
29
|
+
import type { ToolDeps, } from "./types"
|
|
28
30
|
import { TokenBucket } from "./rate-limit"
|
|
29
31
|
import { Watchdog } from "./watchdog"
|
|
30
32
|
|
|
@@ -49,35 +51,42 @@ const plugin: Plugin = async (input) => {
|
|
|
49
51
|
const registry = new MemberRegistry()
|
|
50
52
|
const tracker = new DescendantTracker()
|
|
51
53
|
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
const
|
|
54
|
+
// Extract the working HeyAPI transport from the plugin-provided v1 client and pass it
|
|
55
|
+
// to the v2 OpencodeClient. The plugin framework provides a v1 client which stores its
|
|
56
|
+
// HeyAPI transport as `_client` (underscore). The v2 constructor accepts it as `client`.
|
|
57
|
+
type V2Transport = ConstructorParameters<typeof OpencodeClient>[0] extends { client?: infer C } ? C : never
|
|
58
|
+
const pluginTransport = (input.client as unknown as { _client: V2Transport })._client
|
|
59
|
+
const rawClient = new OpencodeClient({ client: pluginTransport })
|
|
60
|
+
initLog(rawClient)
|
|
57
61
|
const client = wrapThrowingClient(rawClient)
|
|
58
62
|
const deps: ToolDeps = { db, registry, tracker, client, directory: input.directory }
|
|
59
63
|
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
+
}
|
|
69
78
|
}
|
|
70
|
-
}
|
|
71
79
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
+
}
|
|
81
90
|
|
|
82
91
|
// Initialize rate limiter
|
|
83
92
|
// OPENCODE_ENSEMBLE_RATE_LIMIT=0 disables it entirely
|
|
@@ -140,9 +149,22 @@ const plugin: Plugin = async (input) => {
|
|
|
140
149
|
await notifyWorkingProgress(client, db, transition.teamId)
|
|
141
150
|
}
|
|
142
151
|
|
|
143
|
-
//
|
|
152
|
+
// Wake the lead when it goes idle and has pending messages.
|
|
153
|
+
// The system prompt transform delivers the actual message content.
|
|
144
154
|
if (statusType === "idle") {
|
|
145
|
-
|
|
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
|
+
}
|
|
146
168
|
}
|
|
147
169
|
}
|
|
148
170
|
|
|
@@ -170,9 +192,11 @@ const plugin: Plugin = async (input) => {
|
|
|
170
192
|
if (!input.sessionID) return
|
|
171
193
|
const teamInfo = findTeamBySession(db, registry, input.sessionID)
|
|
172
194
|
if (!teamInfo) return
|
|
195
|
+
log(`system-prompt:transform role=${teamInfo.role} session=${input.sessionID}`)
|
|
173
196
|
const prompt = teamInfo.role === "lead"
|
|
174
197
|
? buildLeadSystemPrompt(db, teamInfo.teamId)
|
|
175
|
-
: buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName
|
|
198
|
+
: buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown")
|
|
199
|
+
log(`system-prompt:injected role=${teamInfo.role} len=${prompt.length}`)
|
|
176
200
|
output.system.push(prompt)
|
|
177
201
|
},
|
|
178
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/notify.ts
CHANGED
|
@@ -3,17 +3,6 @@ import type { PluginClient } from "./types"
|
|
|
3
3
|
|
|
4
4
|
type TeamEventType = "spawn" | "message" | "completed" | "error" | "shutdown"
|
|
5
5
|
|
|
6
|
-
interface SpawnEvent { memberName: string; agent: string }
|
|
7
|
-
interface MessageEvent { from: string; to: string }
|
|
8
|
-
interface MemberEvent { memberName: string }
|
|
9
|
-
|
|
10
|
-
type TeamEventData =
|
|
11
|
-
| { type: "spawn"; data: SpawnEvent }
|
|
12
|
-
| { type: "message"; data: MessageEvent }
|
|
13
|
-
| { type: "completed"; data: MemberEvent }
|
|
14
|
-
| { type: "error"; data: MemberEvent }
|
|
15
|
-
| { type: "shutdown"; data: MemberEvent }
|
|
16
|
-
|
|
17
6
|
const TOAST_CONFIG: Record<TeamEventType, { variant: "info" | "success" | "warning" | "error"; duration: number }> = {
|
|
18
7
|
spawn: { variant: "success", duration: 3000 },
|
|
19
8
|
message: { variant: "info", duration: 3000 },
|
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/schema.ts
CHANGED
|
@@ -88,7 +88,8 @@ export function applyMigrations(db: Database): void {
|
|
|
88
88
|
const { user_version: current } = db.query("PRAGMA user_version").get() as { user_version: number }
|
|
89
89
|
|
|
90
90
|
for (let i = current; i < MIGRATIONS.length; i++) {
|
|
91
|
-
|
|
91
|
+
const migration = MIGRATIONS[i]
|
|
92
|
+
if (migration) db.exec(migration)
|
|
92
93
|
db.exec(`PRAGMA user_version = ${i + 1}`)
|
|
93
94
|
}
|
|
94
95
|
}
|
package/src/state.ts
CHANGED
|
@@ -59,6 +59,11 @@ export class MemberRegistry {
|
|
|
59
59
|
isTeamSession(sessionId: string): boolean {
|
|
60
60
|
return this.bySession.has(sessionId)
|
|
61
61
|
}
|
|
62
|
+
|
|
63
|
+
/** Get all registered session IDs. */
|
|
64
|
+
allSessionIds(): Set<string> {
|
|
65
|
+
return new Set(this.bySession.keys())
|
|
66
|
+
}
|
|
62
67
|
}
|
|
63
68
|
|
|
64
69
|
const DEFAULT_MAX_DEPTH = 10
|
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> = {
|
|
@@ -28,9 +29,9 @@ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
|
|
|
28
29
|
countMap[row.status] = row.count
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
const completed = countMap
|
|
32
|
-
const inProgress = countMap
|
|
33
|
-
const pending = countMap
|
|
32
|
+
const completed = countMap.completed ?? 0
|
|
33
|
+
const inProgress = countMap.in_progress ?? 0
|
|
34
|
+
const pending = countMap.pending ?? 0
|
|
34
35
|
|
|
35
36
|
const memberList = members
|
|
36
37
|
.map((m) => `${m.name} [${STATUS_DISPLAY[m.status] ?? m.status}]`)
|
|
@@ -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
|
/**
|
|
@@ -87,9 +115,9 @@ export function buildTeamCompactionContext(
|
|
|
87
115
|
countMap[row.status] = row.count
|
|
88
116
|
}
|
|
89
117
|
|
|
90
|
-
const completed = countMap
|
|
91
|
-
const inProgress = countMap
|
|
92
|
-
const pending = countMap
|
|
118
|
+
const completed = countMap.completed ?? 0
|
|
119
|
+
const inProgress = countMap.in_progress ?? 0
|
|
120
|
+
const pending = countMap.pending ?? 0
|
|
93
121
|
|
|
94
122
|
const roleLine = role === "lead"
|
|
95
123
|
? `[Team Context] You are the lead of team "${team.name}".`
|
|
@@ -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).
|
|
@@ -13,7 +14,7 @@ export async function executeTeamBroadcast(
|
|
|
13
14
|
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
14
15
|
if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
|
|
15
16
|
|
|
16
|
-
const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName
|
|
17
|
+
const senderName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
|
|
17
18
|
|
|
18
19
|
const msgId = broadcastMessage(deps.db, {
|
|
19
20
|
teamId: teamInfo.teamId,
|
|
@@ -39,24 +40,19 @@ export async function executeTeamBroadcast(
|
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
//
|
|
43
|
+
// Fire-and-forget: deliver to all recipients. Message is already persisted in DB.
|
|
43
44
|
let delivered = 0
|
|
44
45
|
for (const recipient of recipients) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
})
|
|
46
|
+
deps.client.session.promptAsync({
|
|
47
|
+
sessionID: recipient.sessionId,
|
|
48
|
+
parts: [{ type: "text", text: `[Team broadcast from ${senderName}]: ${args.text}` }],
|
|
49
|
+
}).then(() => {
|
|
50
50
|
delivered++
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
// Only mark delivered if at least one recipient received it
|
|
57
|
-
if (delivered > 0) {
|
|
58
|
-
markDelivered(deps.db, msgId)
|
|
51
|
+
if (delivered === 1) markDelivered(deps.db, msgId)
|
|
52
|
+
}).catch((err) => {
|
|
53
|
+
log(`team_broadcast:deliver:failed to=${recipient.name} err=${err instanceof Error ? err.message : String(err)}`)
|
|
54
|
+
})
|
|
59
55
|
}
|
|
60
56
|
|
|
61
|
-
return `Broadcast sent to ${
|
|
57
|
+
return `Broadcast sent to ${recipients.length} recipient${recipients.length !== 1 ? "s" : ""}.`
|
|
62
58
|
}
|
package/src/tools/team-claim.ts
CHANGED
|
@@ -13,7 +13,7 @@ export async function executeTeamClaim(
|
|
|
13
13
|
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
14
14
|
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
15
15
|
|
|
16
|
-
const claimerName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName
|
|
16
|
+
const claimerName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
|
|
17
17
|
|
|
18
18
|
const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?")
|
|
19
19
|
.get(args.task_id, teamInfo.teamId) as Record<string, unknown> | null
|
|
@@ -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.
|
|
@@ -14,7 +15,7 @@ export async function executeTeamMessage(
|
|
|
14
15
|
const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
|
|
15
16
|
if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
|
|
16
17
|
|
|
17
|
-
const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName
|
|
18
|
+
const senderName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
|
|
18
19
|
|
|
19
20
|
const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to)
|
|
20
21
|
if (!recipientSessionId) throw new Error(`Recipient "${args.to}" not found in team "${teamInfo.teamName}"`)
|
|
@@ -56,41 +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
|
-
|
|
77
|
+
// For member-to-member messages, fire-and-forget delivery is safe.
|
|
78
|
+
const deliveryText = `[Team message from ${senderName}]: ${messageText}`
|
|
79
|
+
deps.client.session.promptAsync({
|
|
89
80
|
sessionID: recipientSessionId,
|
|
90
81
|
parts: [{ type: "text", text: deliveryText }],
|
|
82
|
+
}).then(() => {
|
|
83
|
+
markDelivered(deps.db, msgId)
|
|
84
|
+
}).catch((err) => {
|
|
85
|
+
log(`team_message:deliver:failed to=${args.to} err=${err instanceof Error ? err.message : String(err)}`)
|
|
91
86
|
})
|
|
92
87
|
|
|
93
|
-
markDelivered(deps.db, msgId)
|
|
94
|
-
|
|
95
88
|
return `Message sent to ${args.to}.`
|
|
96
89
|
}
|
package/src/tools/team-spawn.ts
CHANGED
|
@@ -1,6 +1,24 @@
|
|
|
1
1
|
import type { ToolDeps, PermissionRule } from "../types"
|
|
2
|
-
import {
|
|
2
|
+
import { validateMemberName } from "../util"
|
|
3
3
|
import { findTeamBySession } from "../types"
|
|
4
|
+
import { sendMessage } from "../messaging"
|
|
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
|
+
}
|
|
4
22
|
|
|
5
23
|
/**
|
|
6
24
|
* Execute the team_spawn tool. Creates a child session and starts a teammate.
|
|
@@ -28,6 +46,8 @@ export async function executeTeamSpawn(
|
|
|
28
46
|
const useWorktree = args.worktree !== false
|
|
29
47
|
const usePlanApproval = args.plan_approval === true
|
|
30
48
|
|
|
49
|
+
log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`)
|
|
50
|
+
|
|
31
51
|
// Create worktree if enabled
|
|
32
52
|
let worktreeDir: string | null = null
|
|
33
53
|
let worktreeBranch: string | null = null
|
|
@@ -35,13 +55,18 @@ export async function executeTeamSpawn(
|
|
|
35
55
|
if (useWorktree) {
|
|
36
56
|
const worktreeName = `ensemble-${teamInfo.teamName}-${args.name}`
|
|
37
57
|
try {
|
|
38
|
-
|
|
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
|
+
)
|
|
39
63
|
if (result.data) {
|
|
40
64
|
worktreeDir = result.data.directory
|
|
41
65
|
worktreeBranch = result.data.branch
|
|
42
66
|
}
|
|
43
|
-
|
|
44
|
-
|
|
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)}`)
|
|
45
70
|
try {
|
|
46
71
|
await deps.client.tui.showToast({
|
|
47
72
|
title: "Team",
|
|
@@ -69,14 +94,20 @@ export async function executeTeamSpawn(
|
|
|
69
94
|
// Create child session — use worktree directory if available
|
|
70
95
|
let childSessionId: string | undefined
|
|
71
96
|
try {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
+
)
|
|
78
107
|
childSessionId = createResult.data?.id
|
|
108
|
+
log(`spawn:session:done name=${args.name} sessionId=${childSessionId}`)
|
|
79
109
|
} catch (err) {
|
|
110
|
+
log(`spawn:session:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`)
|
|
80
111
|
// Rollback worktree if session creation failed
|
|
81
112
|
if (worktreeDir) {
|
|
82
113
|
try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
|
|
@@ -156,25 +187,42 @@ export async function executeTeamSpawn(
|
|
|
156
187
|
|
|
157
188
|
const contextStr = context.join("\n")
|
|
158
189
|
|
|
159
|
-
// Fire-and-forget: send prompt to teammate session
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
190
|
+
// Fire-and-forget: send prompt to teammate session.
|
|
191
|
+
log(`spawn:promptAsync:fire name=${args.name} sessionId=${childSessionId}`)
|
|
192
|
+
deps.client.session.promptAsync({
|
|
193
|
+
sessionID: childSessionId,
|
|
194
|
+
parts: [{ type: "text", text: contextStr }],
|
|
195
|
+
agent: args.agent,
|
|
196
|
+
}).catch(() => {
|
|
197
|
+
log(`spawn:promptAsync:failed name=${args.name} — rolling back`)
|
|
198
|
+
try {
|
|
199
|
+
// Async rollback: clean up DB (by session_id to avoid deleting a re-spawned member with the same name), registry, session, and worktree
|
|
200
|
+
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId])
|
|
201
|
+
deps.registry.unregister(childSessionId)
|
|
202
|
+
deps.client.session.abort({ sessionID: childSessionId }).catch(() => { /* best effort */ })
|
|
203
|
+
if (worktreeDir) {
|
|
204
|
+
deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => { /* best effort */ })
|
|
205
|
+
}
|
|
206
|
+
// Notify user that spawn failed so the lead doesn't think the teammate is active
|
|
207
|
+
deps.client.tui.showToast({
|
|
208
|
+
title: "Team",
|
|
209
|
+
message: `Teammate "${args.name}" failed to start and was removed`,
|
|
210
|
+
variant: "error",
|
|
211
|
+
duration: 5000,
|
|
212
|
+
}).catch(() => { /* TUI may not be available */ })
|
|
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.
|
|
215
|
+
sendMessage(deps.db, {
|
|
216
|
+
teamId: teamInfo.teamId,
|
|
217
|
+
from: "system",
|
|
218
|
+
to: "lead",
|
|
219
|
+
content: `Teammate "${args.name}" failed to start and was removed. You may retry the spawn.`,
|
|
220
|
+
})
|
|
221
|
+
} catch { /* rollback failed — watchdog will clean up stale member */ }
|
|
222
|
+
})
|
|
176
223
|
|
|
177
224
|
const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : ""
|
|
178
225
|
const planInfo = usePlanApproval ? " [plan mode — will send plan for approval]" : ""
|
|
226
|
+
log(`spawn:done name=${args.name} sessionId=${childSessionId}`)
|
|
179
227
|
return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
|
|
180
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.
|