@hueyexe/opencode-ensemble 0.6.3 → 0.6.5
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 +165 -84
- package/package.json +1 -1
- package/src/client.ts +6 -0
- package/src/hooks.ts +0 -16
- package/src/index.ts +50 -24
- package/src/log.ts +13 -0
- package/src/messaging.ts +0 -38
- package/src/recovery.ts +2 -1
- package/src/schema.ts +2 -0
- package/src/system-prompt.ts +31 -3
- package/src/tools/team-broadcast.ts +4 -1
- package/src/tools/team-cleanup.ts +10 -3
- package/src/tools/team-message.ts +19 -28
- package/src/tools/team-spawn.ts +94 -26
- package/src/types.ts +5 -0
- package/src/util.ts +8 -0
package/dist/index.js
CHANGED
|
@@ -15366,7 +15366,8 @@ var MIGRATIONS = [
|
|
|
15366
15366
|
ALTER TABLE team_member ADD COLUMN worktree_branch TEXT;`,
|
|
15367
15367
|
`ALTER TABLE team_member ADD COLUMN plan_approval TEXT NOT NULL DEFAULT 'none'
|
|
15368
15368
|
CHECK(plan_approval IN ('none', 'pending', 'approved', 'rejected'));`,
|
|
15369
|
-
`ALTER TABLE team ADD COLUMN lead_agent TEXT
|
|
15369
|
+
`ALTER TABLE team ADD COLUMN lead_agent TEXT;`,
|
|
15370
|
+
`ALTER TABLE team_member ADD COLUMN workspace_id TEXT;`
|
|
15370
15371
|
];
|
|
15371
15372
|
function applyMigrations(db) {
|
|
15372
15373
|
const { user_version: current } = db.query("PRAGMA user_version").get();
|
|
@@ -15426,6 +15427,11 @@ function wrapThrowingClient(raw) {
|
|
|
15426
15427
|
remove: throwing(r.worktree.remove.bind(r.worktree)),
|
|
15427
15428
|
list: throwing(r.worktree.list.bind(r.worktree)),
|
|
15428
15429
|
reset: throwing(r.worktree.reset.bind(r.worktree))
|
|
15430
|
+
},
|
|
15431
|
+
workspace: {
|
|
15432
|
+
create: throwing(r.experimental.workspace.create.bind(r.experimental.workspace)),
|
|
15433
|
+
remove: throwing(r.experimental.workspace.remove.bind(r.experimental.workspace)),
|
|
15434
|
+
list: throwing(r.experimental.workspace.list.bind(r.experimental.workspace))
|
|
15429
15435
|
}
|
|
15430
15436
|
};
|
|
15431
15437
|
}
|
|
@@ -15446,6 +15452,9 @@ function validateTeamName(name) {
|
|
|
15446
15452
|
return "Team name must be lowercase alphanumeric with hyphens only";
|
|
15447
15453
|
return;
|
|
15448
15454
|
}
|
|
15455
|
+
function isWorktreeInstance(directory) {
|
|
15456
|
+
return directory.includes("/worktree/") && directory.includes("/ensemble-");
|
|
15457
|
+
}
|
|
15449
15458
|
function validateMemberName(name) {
|
|
15450
15459
|
if (name.toLowerCase() === "lead")
|
|
15451
15460
|
return `Name "lead" is reserved`;
|
|
@@ -15480,30 +15489,6 @@ function getUndeliveredMessages(db, teamId) {
|
|
|
15480
15489
|
function markDelivered(db, messageId) {
|
|
15481
15490
|
db.run("UPDATE team_message SET delivered = 1 WHERE id = ?", [messageId]);
|
|
15482
15491
|
}
|
|
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
15492
|
|
|
15508
15493
|
// src/recovery.ts
|
|
15509
15494
|
async function recoverStaleMembers(db, client3) {
|
|
@@ -15552,7 +15537,7 @@ async function recoverUndeliveredMessages(db, client3, registry2) {
|
|
|
15552
15537
|
for (const msg of messages) {
|
|
15553
15538
|
let recipientSessionId;
|
|
15554
15539
|
if (msg.to_name === "lead") {
|
|
15555
|
-
|
|
15540
|
+
continue;
|
|
15556
15541
|
} else if (msg.to_name) {
|
|
15557
15542
|
const entry = registry2.getByName(team.id, msg.to_name);
|
|
15558
15543
|
recipientSessionId = entry?.sessionId;
|
|
@@ -15690,12 +15675,6 @@ function checkToolIsolation(registry2, tracker, toolName, sessionId) {
|
|
|
15690
15675
|
throw new Error("Team tools are not available to sub-agents. Report findings to your parent teammate via your normal output.");
|
|
15691
15676
|
}
|
|
15692
15677
|
}
|
|
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
15678
|
|
|
15700
15679
|
// src/notify.ts
|
|
15701
15680
|
var TOAST_CONFIG = {
|
|
@@ -15778,16 +15757,27 @@ function buildLeadSystemPrompt(db, teamId) {
|
|
|
15778
15757
|
const pending = countMap.pending ?? 0;
|
|
15779
15758
|
const memberList = members.map((m) => `${m.name} [${STATUS_DISPLAY[m.status] ?? m.status}]`).join(", ");
|
|
15780
15759
|
const teammateLine = members.length > 0 ? `Teammates: ${memberList}` : "Teammates: none";
|
|
15781
|
-
|
|
15760
|
+
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);
|
|
15761
|
+
const lines = [
|
|
15782
15762
|
`You are leading team "${team.name}" with ${members.length} active teammates.`,
|
|
15783
15763
|
teammateLine,
|
|
15784
|
-
`Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending
|
|
15785
|
-
|
|
15786
|
-
|
|
15787
|
-
"
|
|
15788
|
-
|
|
15789
|
-
|
|
15790
|
-
|
|
15764
|
+
`Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`
|
|
15765
|
+
];
|
|
15766
|
+
if (pendingMessages.length > 0) {
|
|
15767
|
+
lines.push("", "--- Team Messages ---");
|
|
15768
|
+
const MAX_MSG = 500;
|
|
15769
|
+
for (const msg of pendingMessages) {
|
|
15770
|
+
if (msg.content.length > MAX_MSG) {
|
|
15771
|
+
lines.push(`[From ${msg.from_name}]: ${msg.content.slice(0, MAX_MSG)}... (use team_results to read full message)`);
|
|
15772
|
+
} else {
|
|
15773
|
+
lines.push(`[From ${msg.from_name}]: ${msg.content}`);
|
|
15774
|
+
}
|
|
15775
|
+
markDelivered(db, msg.id);
|
|
15776
|
+
}
|
|
15777
|
+
lines.push("--- End Messages ---");
|
|
15778
|
+
}
|
|
15779
|
+
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.");
|
|
15780
|
+
return lines.join(`
|
|
15791
15781
|
`);
|
|
15792
15782
|
}
|
|
15793
15783
|
function buildTeammateSystemPrompt(db, teamId, memberName) {
|
|
@@ -15820,6 +15810,17 @@ function buildTeamCompactionContext(db, teamId, role, memberName) {
|
|
|
15820
15810
|
`);
|
|
15821
15811
|
}
|
|
15822
15812
|
|
|
15813
|
+
// src/log.ts
|
|
15814
|
+
var _client = null;
|
|
15815
|
+
function initLog(client3) {
|
|
15816
|
+
_client = client3;
|
|
15817
|
+
}
|
|
15818
|
+
function log(msg) {
|
|
15819
|
+
if (!_client)
|
|
15820
|
+
return;
|
|
15821
|
+
_client.app.log({ service: "ensemble", level: "info", message: msg }).catch(() => {});
|
|
15822
|
+
}
|
|
15823
|
+
|
|
15823
15824
|
// src/types.ts
|
|
15824
15825
|
function findTeamBySession(db, registry2, sessionId) {
|
|
15825
15826
|
const entry = registry2.getBySession(sessionId);
|
|
@@ -15860,6 +15861,18 @@ async function executeTeamCreate(deps, args, sessionId) {
|
|
|
15860
15861
|
}
|
|
15861
15862
|
|
|
15862
15863
|
// src/tools/team-spawn.ts
|
|
15864
|
+
function getSpawnTimeout() {
|
|
15865
|
+
return Number(process.env.SPAWN_TIMEOUT_MS) || 120000;
|
|
15866
|
+
}
|
|
15867
|
+
function withTimeout(promise2, ms, label) {
|
|
15868
|
+
let timer;
|
|
15869
|
+
return Promise.race([
|
|
15870
|
+
promise2.finally(() => clearTimeout(timer)),
|
|
15871
|
+
new Promise((_, reject) => {
|
|
15872
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
15873
|
+
})
|
|
15874
|
+
]);
|
|
15875
|
+
}
|
|
15863
15876
|
async function executeTeamSpawn(deps, args, sessionId) {
|
|
15864
15877
|
const nameError = validateMemberName(args.name);
|
|
15865
15878
|
if (nameError)
|
|
@@ -15874,17 +15887,21 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15874
15887
|
throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
|
|
15875
15888
|
const useWorktree = args.worktree !== false;
|
|
15876
15889
|
const usePlanApproval = args.plan_approval === true;
|
|
15890
|
+
log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`);
|
|
15877
15891
|
let worktreeDir = null;
|
|
15878
15892
|
let worktreeBranch = null;
|
|
15879
15893
|
if (useWorktree) {
|
|
15880
15894
|
const worktreeName = `ensemble-${teamInfo.teamName}-${args.name}`;
|
|
15881
15895
|
try {
|
|
15882
|
-
|
|
15896
|
+
log(`spawn:worktree:start name=${args.name}`);
|
|
15897
|
+
const result = await withTimeout(deps.client.worktree.create({ worktreeCreateInput: { name: worktreeName } }), getSpawnTimeout(), `worktree.create for "${args.name}"`);
|
|
15883
15898
|
if (result.data) {
|
|
15884
15899
|
worktreeDir = result.data.directory;
|
|
15885
15900
|
worktreeBranch = result.data.branch;
|
|
15886
15901
|
}
|
|
15887
|
-
|
|
15902
|
+
log(`spawn:worktree:done name=${args.name} dir=${worktreeDir}`);
|
|
15903
|
+
} catch (err) {
|
|
15904
|
+
log(`spawn:worktree:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`);
|
|
15888
15905
|
try {
|
|
15889
15906
|
await deps.client.tui.showToast({
|
|
15890
15907
|
title: "Team",
|
|
@@ -15895,6 +15912,19 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15895
15912
|
} catch {}
|
|
15896
15913
|
}
|
|
15897
15914
|
}
|
|
15915
|
+
let workspaceId = null;
|
|
15916
|
+
if (worktreeDir && worktreeBranch) {
|
|
15917
|
+
try {
|
|
15918
|
+
log(`spawn:workspace:start name=${args.name}`);
|
|
15919
|
+
const wsResult = await withTimeout(deps.client.workspace.create({ branch: worktreeBranch }), getSpawnTimeout(), `workspace.create for "${args.name}"`);
|
|
15920
|
+
if (wsResult.data) {
|
|
15921
|
+
workspaceId = wsResult.data.id;
|
|
15922
|
+
}
|
|
15923
|
+
log(`spawn:workspace:done name=${args.name} id=${workspaceId}`);
|
|
15924
|
+
} catch (err) {
|
|
15925
|
+
log(`spawn:workspace:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`);
|
|
15926
|
+
}
|
|
15927
|
+
}
|
|
15898
15928
|
const isReadOnly = args.agent === "plan" || args.agent === "explore";
|
|
15899
15929
|
const TEAM_TOOLS = ["team_message", "team_broadcast", "team_tasks_list", "team_tasks_add", "team_tasks_complete", "team_claim"];
|
|
15900
15930
|
const permission = isReadOnly ? [
|
|
@@ -15904,14 +15934,22 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15904
15934
|
] : undefined;
|
|
15905
15935
|
let childSessionId;
|
|
15906
15936
|
try {
|
|
15907
|
-
|
|
15937
|
+
log(`spawn:session:start name=${args.name}`);
|
|
15938
|
+
const createResult = await withTimeout(deps.client.session.create({
|
|
15908
15939
|
parentID: sessionId,
|
|
15909
15940
|
title: `${args.name} (@${args.agent} teammate)`,
|
|
15910
15941
|
...permission ? { permission } : {},
|
|
15911
|
-
...
|
|
15912
|
-
});
|
|
15942
|
+
...workspaceId ? { workspaceID: workspaceId } : {}
|
|
15943
|
+
}), getSpawnTimeout(), `session.create for "${args.name}"`);
|
|
15913
15944
|
childSessionId = createResult.data?.id;
|
|
15945
|
+
log(`spawn:session:done name=${args.name} sessionId=${childSessionId}`);
|
|
15914
15946
|
} catch (err) {
|
|
15947
|
+
log(`spawn:session:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`);
|
|
15948
|
+
if (workspaceId) {
|
|
15949
|
+
try {
|
|
15950
|
+
await deps.client.workspace.remove({ id: workspaceId });
|
|
15951
|
+
} catch {}
|
|
15952
|
+
}
|
|
15915
15953
|
if (worktreeDir) {
|
|
15916
15954
|
try {
|
|
15917
15955
|
await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
|
|
@@ -15920,6 +15958,11 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15920
15958
|
throw new Error(`Failed to create session for teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`);
|
|
15921
15959
|
}
|
|
15922
15960
|
if (!childSessionId) {
|
|
15961
|
+
if (workspaceId) {
|
|
15962
|
+
try {
|
|
15963
|
+
await deps.client.workspace.remove({ id: workspaceId });
|
|
15964
|
+
} catch {}
|
|
15965
|
+
}
|
|
15923
15966
|
if (worktreeDir) {
|
|
15924
15967
|
try {
|
|
15925
15968
|
await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
|
|
@@ -15929,15 +15972,19 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15929
15972
|
}
|
|
15930
15973
|
const planApproval = usePlanApproval ? "pending" : "none";
|
|
15931
15974
|
const now = Date.now();
|
|
15932
|
-
deps.db.run(`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, plan_approval, time_created, time_updated)
|
|
15933
|
-
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, planApproval, now, now]);
|
|
15975
|
+
deps.db.run(`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, workspace_id, plan_approval, time_created, time_updated)
|
|
15976
|
+
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]);
|
|
15934
15977
|
deps.registry.register(teamInfo.teamId, args.name, childSessionId);
|
|
15935
15978
|
const context = [
|
|
15936
15979
|
`You are "${args.name}", a teammate in team "${teamInfo.teamName}".`,
|
|
15937
15980
|
`Your agent type is "${args.agent}".`
|
|
15938
15981
|
];
|
|
15939
|
-
if (worktreeBranch) {
|
|
15940
|
-
context.push(`You are working on branch "${worktreeBranch}" in your own worktree
|
|
15982
|
+
if (worktreeBranch && worktreeDir && !workspaceId) {
|
|
15983
|
+
context.push(`You are working on branch "${worktreeBranch}" in your own worktree at: ${worktreeDir}`, `Your changes are isolated from other teammates.`, `IMPORTANT: All file operations and shell commands MUST target your worktree directory.`, `Before running shell commands, cd to: ${worktreeDir}`);
|
|
15984
|
+
} else if (worktreeBranch && worktreeDir) {
|
|
15985
|
+
context.push(`You are working on branch "${worktreeBranch}" in your own isolated worktree.`, `Your changes are isolated from other teammates.`);
|
|
15986
|
+
} else if (worktreeBranch) {
|
|
15987
|
+
context.push(`You are working on branch "${worktreeBranch}". Your changes are isolated from other teammates.`);
|
|
15941
15988
|
}
|
|
15942
15989
|
if (usePlanApproval) {
|
|
15943
15990
|
context.push("", "IMPORTANT: You are in PLAN MODE.", "Read and explore the codebase, then send your implementation plan to the lead via team_message.", "Do NOT write or modify any files until the lead approves your plan.", "Wait for the lead's approval message before proceeding with implementation.");
|
|
@@ -15949,15 +15996,20 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15949
15996
|
}
|
|
15950
15997
|
const contextStr = context.join(`
|
|
15951
15998
|
`);
|
|
15999
|
+
log(`spawn:promptAsync:fire name=${args.name} sessionId=${childSessionId}`);
|
|
15952
16000
|
deps.client.session.promptAsync({
|
|
15953
16001
|
sessionID: childSessionId,
|
|
15954
16002
|
parts: [{ type: "text", text: contextStr }],
|
|
15955
16003
|
agent: args.agent
|
|
15956
16004
|
}).catch(() => {
|
|
16005
|
+
log(`spawn:promptAsync:failed name=${args.name} \u2014 rolling back`);
|
|
15957
16006
|
try {
|
|
15958
16007
|
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId]);
|
|
15959
16008
|
deps.registry.unregister(childSessionId);
|
|
15960
16009
|
deps.client.session.abort({ sessionID: childSessionId }).catch(() => {});
|
|
16010
|
+
if (workspaceId) {
|
|
16011
|
+
deps.client.workspace.remove({ id: workspaceId }).catch(() => {});
|
|
16012
|
+
}
|
|
15961
16013
|
if (worktreeDir) {
|
|
15962
16014
|
deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => {});
|
|
15963
16015
|
}
|
|
@@ -15973,14 +16025,11 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
15973
16025
|
to: "lead",
|
|
15974
16026
|
content: `Teammate "${args.name}" failed to start and was removed. You may retry the spawn.`
|
|
15975
16027
|
});
|
|
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
16028
|
} catch {}
|
|
15981
16029
|
});
|
|
15982
16030
|
const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : "";
|
|
15983
16031
|
const planInfo = usePlanApproval ? " [plan mode \u2014 will send plan for approval]" : "";
|
|
16032
|
+
log(`spawn:done name=${args.name} sessionId=${childSessionId}`);
|
|
15984
16033
|
return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`;
|
|
15985
16034
|
}
|
|
15986
16035
|
|
|
@@ -16020,31 +16069,25 @@ async function executeTeamMessage(deps, args, sessionId) {
|
|
|
16020
16069
|
content: messageText
|
|
16021
16070
|
});
|
|
16022
16071
|
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)`;
|
|
16072
|
+
if (isToLead) {
|
|
16073
|
+
log(`team_message:wake-lead from=${senderName} recipientSession=${recipientSessionId}`);
|
|
16074
|
+
deps.client.session.promptAsync({
|
|
16075
|
+
sessionID: recipientSessionId,
|
|
16076
|
+
parts: [{ type: "text", text: `[System: New team message from ${senderName}]` }]
|
|
16077
|
+
}).catch((err) => {
|
|
16078
|
+
log(`team_message:wake-lead:failed from=${senderName} err=${err instanceof Error ? err.message : String(err)}`);
|
|
16079
|
+
});
|
|
16080
|
+
return `Message sent to ${args.to}.`;
|
|
16041
16081
|
}
|
|
16082
|
+
const deliveryText = `[Team message from ${senderName}]: ${messageText}`;
|
|
16042
16083
|
deps.client.session.promptAsync({
|
|
16043
16084
|
sessionID: recipientSessionId,
|
|
16044
16085
|
parts: [{ type: "text", text: deliveryText }]
|
|
16045
16086
|
}).then(() => {
|
|
16046
16087
|
markDelivered(deps.db, msgId);
|
|
16047
|
-
}).catch(() => {
|
|
16088
|
+
}).catch((err) => {
|
|
16089
|
+
log(`team_message:deliver:failed to=${args.to} err=${err instanceof Error ? err.message : String(err)}`);
|
|
16090
|
+
});
|
|
16048
16091
|
return `Message sent to ${args.to}.`;
|
|
16049
16092
|
}
|
|
16050
16093
|
|
|
@@ -16080,7 +16123,9 @@ async function executeTeamBroadcast(deps, args, sessionId) {
|
|
|
16080
16123
|
delivered++;
|
|
16081
16124
|
if (delivered === 1)
|
|
16082
16125
|
markDelivered(deps.db, msgId);
|
|
16083
|
-
}).catch(() => {
|
|
16126
|
+
}).catch((err) => {
|
|
16127
|
+
log(`team_broadcast:deliver:failed to=${recipient.name} err=${err instanceof Error ? err.message : String(err)}`);
|
|
16128
|
+
});
|
|
16084
16129
|
}
|
|
16085
16130
|
return `Broadcast sent to ${recipients.length} recipient${recipients.length !== 1 ? "s" : ""}.`;
|
|
16086
16131
|
}
|
|
@@ -16140,7 +16185,7 @@ async function executeTeamCleanup(deps, args, sessionId) {
|
|
|
16140
16185
|
throw new Error("This session is not in a team.");
|
|
16141
16186
|
if (teamInfo.role !== "lead")
|
|
16142
16187
|
throw new Error("Only the team lead can clean up the team.");
|
|
16143
|
-
const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch FROM team_member WHERE team_id = ?").all(teamInfo.teamId);
|
|
16188
|
+
const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch, workspace_id FROM team_member WHERE team_id = ?").all(teamInfo.teamId);
|
|
16144
16189
|
const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
|
|
16145
16190
|
if (active.length > 0 && !args.force) {
|
|
16146
16191
|
const names = active.map((m) => m.name).join(", ");
|
|
@@ -16155,9 +16200,16 @@ async function executeTeamCleanup(deps, args, sessionId) {
|
|
|
16155
16200
|
}
|
|
16156
16201
|
const branches = [];
|
|
16157
16202
|
for (const member of members) {
|
|
16203
|
+
if (member.workspace_id) {
|
|
16204
|
+
try {
|
|
16205
|
+
await deps.client.workspace.remove({ id: member.workspace_id });
|
|
16206
|
+
deps.db.run("UPDATE team_member SET workspace_id = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name]);
|
|
16207
|
+
} catch {}
|
|
16208
|
+
}
|
|
16158
16209
|
if (member.worktree_dir) {
|
|
16159
16210
|
try {
|
|
16160
16211
|
await deps.client.worktree.remove({ worktreeRemoveInput: { directory: member.worktree_dir } });
|
|
16212
|
+
deps.db.run("UPDATE team_member SET worktree_dir = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name]);
|
|
16161
16213
|
} catch {}
|
|
16162
16214
|
}
|
|
16163
16215
|
if (member.worktree_branch) {
|
|
@@ -16498,17 +16550,29 @@ var plugin = async (input) => {
|
|
|
16498
16550
|
const tracker = new DescendantTracker;
|
|
16499
16551
|
const pluginTransport = input.client._client;
|
|
16500
16552
|
const rawClient = new OpencodeClient({ client: pluginTransport });
|
|
16553
|
+
initLog(rawClient);
|
|
16501
16554
|
const client3 = wrapThrowingClient(rawClient);
|
|
16502
16555
|
const deps = { db, registry: registry2, tracker, client: client3, directory: input.directory };
|
|
16503
|
-
|
|
16504
|
-
|
|
16505
|
-
const
|
|
16506
|
-
|
|
16507
|
-
|
|
16508
|
-
|
|
16556
|
+
if (!isWorktreeInstance(input.directory)) {
|
|
16557
|
+
log("init:recovery:start (main instance)");
|
|
16558
|
+
const recovery = await recoverStaleMembers(db, client3);
|
|
16559
|
+
if (recovery.interrupted > 0) {
|
|
16560
|
+
log(`init:recovery:interrupted=${recovery.interrupted}`);
|
|
16561
|
+
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();
|
|
16562
|
+
for (const m of members) {
|
|
16563
|
+
registry2.register(m.team_id, m.name, m.session_id);
|
|
16564
|
+
}
|
|
16565
|
+
}
|
|
16566
|
+
recoverUndeliveredMessages(db, client3, registry2).catch((err) => {
|
|
16567
|
+
log(`init:recover-messages:failed err=${err instanceof Error ? err.message : String(err)}`);
|
|
16568
|
+
});
|
|
16569
|
+
recoverOrphanedWorktrees(db, client3).catch((err) => {
|
|
16570
|
+
log(`init:recover-worktrees:failed err=${err instanceof Error ? err.message : String(err)}`);
|
|
16571
|
+
});
|
|
16572
|
+
log("init:recovery:done");
|
|
16573
|
+
} else {
|
|
16574
|
+
log(`init:skip-recovery (worktree instance: ${input.directory})`);
|
|
16509
16575
|
}
|
|
16510
|
-
recoverUndeliveredMessages(db, client3, registry2).catch(() => {});
|
|
16511
|
-
recoverOrphanedWorktrees(db, client3).catch(() => {});
|
|
16512
16576
|
const rateLimitEnv = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT;
|
|
16513
16577
|
const rateLimitCapacity = rateLimitEnv === "0" ? 0 : parseInt(rateLimitEnv ?? "", 10) || DEFAULT_RATE_LIMIT_CAPACITY;
|
|
16514
16578
|
const rateLimiter = new TokenBucket({
|
|
@@ -16559,7 +16623,19 @@ var plugin = async (input) => {
|
|
|
16559
16623
|
await notifyWorkingProgress(client3, db, transition.teamId);
|
|
16560
16624
|
}
|
|
16561
16625
|
if (statusType === "idle") {
|
|
16562
|
-
|
|
16626
|
+
const team = db.query("SELECT id FROM team WHERE lead_session_id = ? AND status = 'active'").get(sessionID);
|
|
16627
|
+
if (team) {
|
|
16628
|
+
const pending = db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND to_name = 'lead' AND delivered = 0").get(team.id);
|
|
16629
|
+
if (pending.c > 0) {
|
|
16630
|
+
log(`wake-lead: ${pending.c} pending messages, sending promptAsync`);
|
|
16631
|
+
client3.session.promptAsync({
|
|
16632
|
+
sessionID,
|
|
16633
|
+
parts: [{ type: "text", text: `[System: ${pending.c} new team message(s) available]` }]
|
|
16634
|
+
}).catch((err) => {
|
|
16635
|
+
log(`wake-lead:failed err=${err instanceof Error ? err.message : String(err)}`);
|
|
16636
|
+
});
|
|
16637
|
+
}
|
|
16638
|
+
}
|
|
16563
16639
|
}
|
|
16564
16640
|
}
|
|
16565
16641
|
if (event.type === "session.created") {
|
|
@@ -16583,7 +16659,9 @@ var plugin = async (input) => {
|
|
|
16583
16659
|
const teamInfo = findTeamBySession(db, registry2, input2.sessionID);
|
|
16584
16660
|
if (!teamInfo)
|
|
16585
16661
|
return;
|
|
16662
|
+
log(`system-prompt:transform role=${teamInfo.role} session=${input2.sessionID}`);
|
|
16586
16663
|
const prompt = teamInfo.role === "lead" ? buildLeadSystemPrompt(db, teamInfo.teamId) : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown");
|
|
16664
|
+
log(`system-prompt:injected role=${teamInfo.role} len=${prompt.length}`);
|
|
16587
16665
|
output.system.push(prompt);
|
|
16588
16666
|
},
|
|
16589
16667
|
"experimental.session.compacting": async (input2, output) => {
|
|
@@ -16603,10 +16681,13 @@ var plugin = async (input) => {
|
|
|
16603
16681
|
output.env.ENSEMBLE_ROLE = teamInfo.role;
|
|
16604
16682
|
if (teamInfo.memberName) {
|
|
16605
16683
|
output.env.ENSEMBLE_MEMBER = teamInfo.memberName;
|
|
16606
|
-
const member = db.query("SELECT worktree_branch FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, teamInfo.memberName);
|
|
16684
|
+
const member = db.query("SELECT worktree_branch, worktree_dir FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, teamInfo.memberName);
|
|
16607
16685
|
if (member?.worktree_branch) {
|
|
16608
16686
|
output.env.ENSEMBLE_BRANCH = member.worktree_branch;
|
|
16609
16687
|
}
|
|
16688
|
+
if (member?.worktree_dir) {
|
|
16689
|
+
output.env.ENSEMBLE_WORKTREE_DIR = member.worktree_dir;
|
|
16690
|
+
}
|
|
16610
16691
|
}
|
|
16611
16692
|
},
|
|
16612
16693
|
tool: {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hueyexe/opencode-ensemble",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
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/client.ts
CHANGED
|
@@ -25,6 +25,7 @@ interface RawClient {
|
|
|
25
25
|
session: { create: SdkMethod; promptAsync: SdkMethod; abort: SdkMethod; status: SdkMethod }
|
|
26
26
|
tui: { showToast: SdkMethod; selectSession: SdkMethod }
|
|
27
27
|
worktree: { create: SdkMethod; remove: SdkMethod; list: SdkMethod; reset: SdkMethod }
|
|
28
|
+
experimental: { workspace: { create: SdkMethod; remove: SdkMethod; list: SdkMethod } }
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
/**
|
|
@@ -50,5 +51,10 @@ export function wrapThrowingClient(raw: unknown): PluginClient {
|
|
|
50
51
|
list: throwing(r.worktree.list.bind(r.worktree)),
|
|
51
52
|
reset: throwing(r.worktree.reset.bind(r.worktree)),
|
|
52
53
|
},
|
|
54
|
+
workspace: {
|
|
55
|
+
create: throwing(r.experimental.workspace.create.bind(r.experimental.workspace)),
|
|
56
|
+
remove: throwing(r.experimental.workspace.remove.bind(r.experimental.workspace)),
|
|
57
|
+
list: throwing(r.experimental.workspace.list.bind(r.experimental.workspace)),
|
|
58
|
+
},
|
|
53
59
|
} as PluginClient
|
|
54
60
|
}
|
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
|
|
|
@@ -194,11 +217,14 @@ const plugin: Plugin = async (input) => {
|
|
|
194
217
|
output.env.ENSEMBLE_ROLE = teamInfo.role
|
|
195
218
|
if (teamInfo.memberName) {
|
|
196
219
|
output.env.ENSEMBLE_MEMBER = teamInfo.memberName
|
|
197
|
-
const member = db.query("SELECT worktree_branch FROM team_member WHERE team_id = ? AND name = ?")
|
|
198
|
-
.get(teamInfo.teamId, teamInfo.memberName) as { worktree_branch: string | null } | null
|
|
220
|
+
const member = db.query("SELECT worktree_branch, worktree_dir FROM team_member WHERE team_id = ? AND name = ?")
|
|
221
|
+
.get(teamInfo.teamId, teamInfo.memberName) as { worktree_branch: string | null; worktree_dir: string | null } | null
|
|
199
222
|
if (member?.worktree_branch) {
|
|
200
223
|
output.env.ENSEMBLE_BRANCH = member.worktree_branch
|
|
201
224
|
}
|
|
225
|
+
if (member?.worktree_dir) {
|
|
226
|
+
output.env.ENSEMBLE_WORKTREE_DIR = member.worktree_dir
|
|
227
|
+
}
|
|
202
228
|
}
|
|
203
229
|
},
|
|
204
230
|
|
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/schema.ts
CHANGED
|
@@ -78,6 +78,8 @@ export const MIGRATIONS: string[] = [
|
|
|
78
78
|
CHECK(plan_approval IN ('none', 'pending', 'approved', 'rejected'));`,
|
|
79
79
|
// Migration 5: Track lead's agent mode so message delivery preserves it
|
|
80
80
|
`ALTER TABLE team ADD COLUMN lead_agent TEXT;`,
|
|
81
|
+
// Migration 6: Track workspace ID for worktree-session binding
|
|
82
|
+
`ALTER TABLE team_member ADD COLUMN workspace_id TEXT;`,
|
|
81
83
|
]
|
|
82
84
|
|
|
83
85
|
/**
|
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" : ""}.`
|
|
@@ -15,8 +15,8 @@ export async function executeTeamCleanup(
|
|
|
15
15
|
if (!teamInfo) throw new Error("This session is not in a team.")
|
|
16
16
|
if (teamInfo.role !== "lead") throw new Error("Only the team lead can clean up the team.")
|
|
17
17
|
|
|
18
|
-
const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch FROM team_member WHERE team_id = ?")
|
|
19
|
-
.all(teamInfo.teamId) as Array<{ name: string; session_id: string; status: string; worktree_dir: string | null; worktree_branch: string | null }>
|
|
18
|
+
const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch, workspace_id FROM team_member WHERE team_id = ?")
|
|
19
|
+
.all(teamInfo.teamId) as Array<{ name: string; session_id: string; status: string; worktree_dir: string | null; worktree_branch: string | null; workspace_id: string | null }>
|
|
20
20
|
|
|
21
21
|
const active = members.filter(m => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error")
|
|
22
22
|
|
|
@@ -34,12 +34,19 @@ export async function executeTeamCleanup(
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
// Remove worktrees and collect branches for merging
|
|
37
|
+
// Remove workspaces, worktrees, and collect branches for merging
|
|
38
38
|
const branches: string[] = []
|
|
39
39
|
for (const member of members) {
|
|
40
|
+
if (member.workspace_id) {
|
|
41
|
+
try {
|
|
42
|
+
await deps.client.workspace.remove({ id: member.workspace_id })
|
|
43
|
+
deps.db.run("UPDATE team_member SET workspace_id = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name])
|
|
44
|
+
} catch { /* best effort — workspace may already be gone */ }
|
|
45
|
+
}
|
|
40
46
|
if (member.worktree_dir) {
|
|
41
47
|
try {
|
|
42
48
|
await deps.client.worktree.remove({ worktreeRemoveInput: { directory: member.worktree_dir } })
|
|
49
|
+
deps.db.run("UPDATE team_member SET worktree_dir = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name])
|
|
43
50
|
} catch { /* best effort — worktree may already be gone */ }
|
|
44
51
|
}
|
|
45
52
|
if (member.worktree_branch) {
|
|
@@ -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",
|
|
@@ -55,6 +78,26 @@ export async function executeTeamSpawn(
|
|
|
55
78
|
}
|
|
56
79
|
}
|
|
57
80
|
|
|
81
|
+
// Create workspace from worktree branch — links session to worktree directory.
|
|
82
|
+
// OQ-workspace: assumes workspace.create({ branch }) auto-links to the worktree at that branch.
|
|
83
|
+
let workspaceId: string | null = null
|
|
84
|
+
if (worktreeDir && worktreeBranch) {
|
|
85
|
+
try {
|
|
86
|
+
log(`spawn:workspace:start name=${args.name}`)
|
|
87
|
+
const wsResult = await withTimeout(
|
|
88
|
+
deps.client.workspace.create({ branch: worktreeBranch }),
|
|
89
|
+
getSpawnTimeout(), `workspace.create for "${args.name}"`
|
|
90
|
+
)
|
|
91
|
+
if (wsResult.data) {
|
|
92
|
+
workspaceId = wsResult.data.id
|
|
93
|
+
}
|
|
94
|
+
log(`spawn:workspace:done name=${args.name} id=${workspaceId}`)
|
|
95
|
+
} catch (err) {
|
|
96
|
+
log(`spawn:workspace:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`)
|
|
97
|
+
// Non-fatal — prompt-based CWD instruction is the fallback
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
58
101
|
// Permission rules on session.create are the hard gate (server-enforced).
|
|
59
102
|
// For read-only agents, deny write tools and explicitly allow team tools
|
|
60
103
|
// so the agent's built-in restrictive permissions don't block them.
|
|
@@ -68,18 +111,28 @@ export async function executeTeamSpawn(
|
|
|
68
111
|
]
|
|
69
112
|
: undefined
|
|
70
113
|
|
|
71
|
-
// Create child session —
|
|
114
|
+
// Create child session — bind to workspace if available (server-enforced CWD isolation).
|
|
115
|
+
// Falls back to no workspace binding if workspace.create failed.
|
|
72
116
|
let childSessionId: string | undefined
|
|
73
117
|
try {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
118
|
+
log(`spawn:session:start name=${args.name}`)
|
|
119
|
+
const createResult = await withTimeout(
|
|
120
|
+
deps.client.session.create({
|
|
121
|
+
parentID: sessionId,
|
|
122
|
+
title: `${args.name} (@${args.agent} teammate)`,
|
|
123
|
+
...(permission ? { permission } : {}),
|
|
124
|
+
...(workspaceId ? { workspaceID: workspaceId } : {}),
|
|
125
|
+
}),
|
|
126
|
+
getSpawnTimeout(), `session.create for "${args.name}"`
|
|
127
|
+
)
|
|
80
128
|
childSessionId = createResult.data?.id
|
|
129
|
+
log(`spawn:session:done name=${args.name} sessionId=${childSessionId}`)
|
|
81
130
|
} catch (err) {
|
|
82
|
-
|
|
131
|
+
log(`spawn:session:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`)
|
|
132
|
+
// Rollback workspace and worktree if session creation failed
|
|
133
|
+
if (workspaceId) {
|
|
134
|
+
try { await deps.client.workspace.remove({ id: workspaceId }) } catch { /* best effort */ }
|
|
135
|
+
}
|
|
83
136
|
if (worktreeDir) {
|
|
84
137
|
try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
|
|
85
138
|
}
|
|
@@ -87,6 +140,9 @@ export async function executeTeamSpawn(
|
|
|
87
140
|
}
|
|
88
141
|
|
|
89
142
|
if (!childSessionId) {
|
|
143
|
+
if (workspaceId) {
|
|
144
|
+
try { await deps.client.workspace.remove({ id: workspaceId }) } catch { /* best effort */ }
|
|
145
|
+
}
|
|
90
146
|
if (worktreeDir) {
|
|
91
147
|
try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
|
|
92
148
|
}
|
|
@@ -97,9 +153,9 @@ export async function executeTeamSpawn(
|
|
|
97
153
|
const planApproval = usePlanApproval ? "pending" : "none"
|
|
98
154
|
const now = Date.now()
|
|
99
155
|
deps.db.run(
|
|
100
|
-
`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, plan_approval, time_created, time_updated)
|
|
101
|
-
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?)`,
|
|
102
|
-
[teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, planApproval, now, now]
|
|
156
|
+
`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, workspace_id, plan_approval, time_created, time_updated)
|
|
157
|
+
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
158
|
+
[teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]
|
|
103
159
|
)
|
|
104
160
|
|
|
105
161
|
// Register in memory
|
|
@@ -111,8 +167,22 @@ export async function executeTeamSpawn(
|
|
|
111
167
|
`Your agent type is "${args.agent}".`,
|
|
112
168
|
]
|
|
113
169
|
|
|
114
|
-
if (worktreeBranch) {
|
|
115
|
-
|
|
170
|
+
if (worktreeBranch && worktreeDir && !workspaceId) {
|
|
171
|
+
// Workspace binding failed — fallback to prompt-based CWD instruction
|
|
172
|
+
context.push(
|
|
173
|
+
`You are working on branch "${worktreeBranch}" in your own worktree at: ${worktreeDir}`,
|
|
174
|
+
`Your changes are isolated from other teammates.`,
|
|
175
|
+
`IMPORTANT: All file operations and shell commands MUST target your worktree directory.`,
|
|
176
|
+
`Before running shell commands, cd to: ${worktreeDir}`,
|
|
177
|
+
)
|
|
178
|
+
} else if (worktreeBranch && worktreeDir) {
|
|
179
|
+
// Workspace binding active — server handles CWD
|
|
180
|
+
context.push(
|
|
181
|
+
`You are working on branch "${worktreeBranch}" in your own isolated worktree.`,
|
|
182
|
+
`Your changes are isolated from other teammates.`,
|
|
183
|
+
)
|
|
184
|
+
} else if (worktreeBranch) {
|
|
185
|
+
context.push(`You are working on branch "${worktreeBranch}". Your changes are isolated from other teammates.`)
|
|
116
186
|
}
|
|
117
187
|
|
|
118
188
|
// Plan approval mode — teammate must send plan before writing
|
|
@@ -159,20 +229,21 @@ export async function executeTeamSpawn(
|
|
|
159
229
|
const contextStr = context.join("\n")
|
|
160
230
|
|
|
161
231
|
// 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.
|
|
232
|
+
log(`spawn:promptAsync:fire name=${args.name} sessionId=${childSessionId}`)
|
|
166
233
|
deps.client.session.promptAsync({
|
|
167
234
|
sessionID: childSessionId,
|
|
168
235
|
parts: [{ type: "text", text: contextStr }],
|
|
169
236
|
agent: args.agent,
|
|
170
237
|
}).catch(() => {
|
|
238
|
+
log(`spawn:promptAsync:failed name=${args.name} — rolling back`)
|
|
171
239
|
try {
|
|
172
240
|
// Async rollback: clean up DB (by session_id to avoid deleting a re-spawned member with the same name), registry, session, and worktree
|
|
173
241
|
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId])
|
|
174
242
|
deps.registry.unregister(childSessionId)
|
|
175
243
|
deps.client.session.abort({ sessionID: childSessionId }).catch(() => { /* best effort */ })
|
|
244
|
+
if (workspaceId) {
|
|
245
|
+
deps.client.workspace.remove({ id: workspaceId }).catch(() => { /* best effort */ })
|
|
246
|
+
}
|
|
176
247
|
if (worktreeDir) {
|
|
177
248
|
deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => { /* best effort */ })
|
|
178
249
|
}
|
|
@@ -184,21 +255,18 @@ export async function executeTeamSpawn(
|
|
|
184
255
|
duration: 5000,
|
|
185
256
|
}).catch(() => { /* TUI may not be available */ })
|
|
186
257
|
// Notify the lead model so it can react (retry, adjust plan, etc.)
|
|
258
|
+
// Message delivered via system prompt transform on the lead's next turn.
|
|
187
259
|
sendMessage(deps.db, {
|
|
188
260
|
teamId: teamInfo.teamId,
|
|
189
261
|
from: "system",
|
|
190
262
|
to: "lead",
|
|
191
263
|
content: `Teammate "${args.name}" failed to start and was removed. You may retry the spawn.`,
|
|
192
264
|
})
|
|
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
265
|
} catch { /* rollback failed — watchdog will clean up stale member */ }
|
|
199
266
|
})
|
|
200
267
|
|
|
201
268
|
const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : ""
|
|
202
269
|
const planInfo = usePlanApproval ? " [plan mode — will send plan for approval]" : ""
|
|
270
|
+
log(`spawn:done name=${args.name} sessionId=${childSessionId}`)
|
|
203
271
|
return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
|
|
204
272
|
}
|
package/src/types.ts
CHANGED
|
@@ -62,6 +62,11 @@ export interface PluginClient {
|
|
|
62
62
|
list(): Promise<{ data?: Array<{ name: string; branch: string; directory: string }> }>
|
|
63
63
|
reset(options: { worktreeResetInput?: { directory: string } }): Promise<unknown>
|
|
64
64
|
}
|
|
65
|
+
workspace: {
|
|
66
|
+
create(options: { id?: string; type?: string; branch?: string | null; extra?: unknown | null }): Promise<{ data?: { id: string; type: string; branch: string | null; directory: string | null; projectID: string } }>
|
|
67
|
+
remove(options: { id: string }): Promise<unknown>
|
|
68
|
+
list(): Promise<{ data?: Array<{ id: string; type: string; branch: string | null; directory: string | null; projectID: string }> }>
|
|
69
|
+
}
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
/**
|
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.
|