@hueyexe/opencode-ensemble 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +423 -170
- package/package.json +1 -1
- package/src/dashboard-js-render.ts +5 -2
- package/src/dashboard.ts +62 -5
- package/src/hooks.ts +86 -8
- package/src/index.ts +64 -17
- package/src/member-model.ts +30 -0
- package/src/notify.ts +44 -0
- package/src/progress.ts +26 -7
- package/src/recovery.ts +106 -3
- package/src/schema.ts +18 -0
- package/src/tasks.ts +19 -0
- package/src/tools/team-claim.ts +33 -18
- package/src/tools/team-create.ts +15 -2
- package/src/tools/team-merge.ts +30 -3
- package/src/tools/team-message.ts +54 -6
- package/src/tools/team-shutdown.ts +6 -0
- package/src/tools/team-spawn.ts +56 -24
- package/src/tools/team-status.ts +38 -4
- package/src/tools/team-tasks-complete.ts +27 -3
- package/src/types.ts +3 -0
- package/src/watchdog.ts +91 -17
package/dist/index.js
CHANGED
|
@@ -17608,7 +17608,12 @@ var MIGRATIONS = [
|
|
|
17608
17608
|
CREATE INDEX IF NOT EXISTS team_message_to_idx ON team_message(to_name);
|
|
17609
17609
|
CREATE INDEX IF NOT EXISTS team_message_undelivered_idx ON team_message(team_id, delivered) WHERE delivered = 0;
|
|
17610
17610
|
CREATE INDEX IF NOT EXISTS team_message_unread_idx ON team_message(team_id, read) WHERE read = 0;
|
|
17611
|
-
PRAGMA foreign_keys=ON
|
|
17611
|
+
PRAGMA foreign_keys=ON;`,
|
|
17612
|
+
`ALTER TABLE team_member ADD COLUMN last_nudged_at INTEGER;`,
|
|
17613
|
+
`ALTER TABLE team_member ADD COLUMN retry_until INTEGER;
|
|
17614
|
+
ALTER TABLE team_member ADD COLUMN retry_attempt INTEGER;
|
|
17615
|
+
ALTER TABLE team_member ADD COLUMN retry_provider TEXT;
|
|
17616
|
+
ALTER TABLE team_member ADD COLUMN retry_message TEXT;`
|
|
17612
17617
|
];
|
|
17613
17618
|
function applyMigrations(db) {
|
|
17614
17619
|
const { user_version: current } = db.query("PRAGMA user_version").get();
|
|
@@ -17807,10 +17812,59 @@ function hasReportedCompletion(db, teamId, memberName) {
|
|
|
17807
17812
|
return row?.reported_to_lead === 1;
|
|
17808
17813
|
}
|
|
17809
17814
|
|
|
17815
|
+
// src/tasks.ts
|
|
17816
|
+
function releaseMemberTasks(db, teamId, memberName) {
|
|
17817
|
+
const result = db.run("UPDATE team_task SET status = 'pending', assignee = NULL, time_updated = ? WHERE team_id = ? AND assignee = ? AND status = 'in_progress'", [Date.now(), teamId, memberName]);
|
|
17818
|
+
return result.changes;
|
|
17819
|
+
}
|
|
17820
|
+
|
|
17821
|
+
// src/member-model.ts
|
|
17822
|
+
function parseModelId(model) {
|
|
17823
|
+
const slash = model.indexOf("/");
|
|
17824
|
+
if (slash <= 0 || slash === model.length - 1)
|
|
17825
|
+
return;
|
|
17826
|
+
return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) };
|
|
17827
|
+
}
|
|
17828
|
+
function getMemberModel(db, teamId, memberName) {
|
|
17829
|
+
const row = db.query("SELECT model FROM team_member WHERE team_id = ? AND name = ?").get(teamId, memberName);
|
|
17830
|
+
if (!row?.model)
|
|
17831
|
+
return;
|
|
17832
|
+
return parseModelId(row.model);
|
|
17833
|
+
}
|
|
17834
|
+
|
|
17810
17835
|
// src/recovery.ts
|
|
17811
17836
|
init_merge_helper();
|
|
17812
17837
|
init_process();
|
|
17813
|
-
async function
|
|
17838
|
+
async function isSessionAlive(client3, sessionId, timeoutMs = 5000) {
|
|
17839
|
+
try {
|
|
17840
|
+
await Promise.race([
|
|
17841
|
+
client3.session.get({ sessionID: sessionId }),
|
|
17842
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("isSessionAlive timed out")), timeoutMs))
|
|
17843
|
+
]);
|
|
17844
|
+
return true;
|
|
17845
|
+
} catch (err) {
|
|
17846
|
+
if (err instanceof Error && err.message === "isSessionAlive timed out") {
|
|
17847
|
+
log(`recovery:team:liveness-check-timeout session=${sessionId}`);
|
|
17848
|
+
return true;
|
|
17849
|
+
}
|
|
17850
|
+
return false;
|
|
17851
|
+
}
|
|
17852
|
+
}
|
|
17853
|
+
async function recoverOrphanedTeams(db, client3, cwd, registry2) {
|
|
17854
|
+
const active = db.query(`SELECT id, lead_session_id FROM team
|
|
17855
|
+
WHERE status = 'active' AND (? IS NULL OR project_id = ? OR project_id = 'default')`).all(cwd ?? null, cwd ?? null);
|
|
17856
|
+
let archived = 0;
|
|
17857
|
+
for (const team of active) {
|
|
17858
|
+
if (await isSessionAlive(client3, team.lead_session_id))
|
|
17859
|
+
continue;
|
|
17860
|
+
db.run("UPDATE team SET status = 'archived', time_updated = ? WHERE id = ?", [Date.now(), team.id]);
|
|
17861
|
+
registry2?.unregisterTeam(team.id);
|
|
17862
|
+
archived++;
|
|
17863
|
+
log(`recovery:team:orphaned team_id=${team.id} lead_session=${team.lead_session_id}`);
|
|
17864
|
+
}
|
|
17865
|
+
return { archived };
|
|
17866
|
+
}
|
|
17867
|
+
async function recoverStaleMembers(db, client3, cwd, abortTimeoutMs = 5000) {
|
|
17814
17868
|
const stale = db.query(`SELECT tm.session_id, tm.worktree_branch, tm.name, tm.team_id, t.name as team_name, p.name as project_name
|
|
17815
17869
|
FROM team_member tm
|
|
17816
17870
|
JOIN team t ON tm.team_id = t.id
|
|
@@ -17820,6 +17874,11 @@ async function recoverStaleMembers(db, client3, cwd) {
|
|
|
17820
17874
|
const result = db.run(`UPDATE team_member SET status = 'error', execution_status = 'idle', time_updated = ?
|
|
17821
17875
|
WHERE status = 'busy'
|
|
17822
17876
|
AND team_id IN (SELECT id FROM team WHERE status = 'active' AND (? IS NULL OR project_id = ? OR project_id = 'default'))`, [Date.now(), cwd ?? null, cwd ?? null]);
|
|
17877
|
+
for (const member of stale) {
|
|
17878
|
+
const released = releaseMemberTasks(db, member.team_id, member.name);
|
|
17879
|
+
if (released > 0)
|
|
17880
|
+
log(`recovery:tasks:released name=${member.name} count=${released}`);
|
|
17881
|
+
}
|
|
17823
17882
|
if (client3) {
|
|
17824
17883
|
for (const member of stale) {
|
|
17825
17884
|
if (cwd && member.worktree_branch && !member.worktree_branch.startsWith("ensemble/preserved/")) {
|
|
@@ -17831,8 +17890,15 @@ async function recoverStaleMembers(db, client3, cwd) {
|
|
|
17831
17890
|
}
|
|
17832
17891
|
}
|
|
17833
17892
|
try {
|
|
17834
|
-
await
|
|
17835
|
-
|
|
17893
|
+
await Promise.race([
|
|
17894
|
+
client3.session.abort({ sessionID: member.session_id }),
|
|
17895
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("session.abort timed out")), abortTimeoutMs))
|
|
17896
|
+
]);
|
|
17897
|
+
} catch (err) {
|
|
17898
|
+
if (err instanceof Error && err.message === "session.abort timed out") {
|
|
17899
|
+
log(`recovery:stale-members:abort-timeout session=${member.session_id}`);
|
|
17900
|
+
}
|
|
17901
|
+
}
|
|
17836
17902
|
}
|
|
17837
17903
|
}
|
|
17838
17904
|
return { interrupted: result.changes };
|
|
@@ -17881,9 +17947,11 @@ async function recoverUndeliveredMessages(db, client3, registry2) {
|
|
|
17881
17947
|
continue;
|
|
17882
17948
|
}
|
|
17883
17949
|
try {
|
|
17950
|
+
const recipientModel = getMemberModel(db, team.id, msg.to_name);
|
|
17884
17951
|
await client3.session.promptAsync({
|
|
17885
17952
|
sessionID: recipientSessionId,
|
|
17886
|
-
parts: [{ type: "text", text: `[Recovered team message from ${msg.from_name}]: ${msg.content}` }]
|
|
17953
|
+
parts: [{ type: "text", text: `[Recovered team message from ${msg.from_name}]: ${msg.content}` }],
|
|
17954
|
+
...recipientModel ? { model: recipientModel } : {}
|
|
17887
17955
|
});
|
|
17888
17956
|
markDelivered(db, msg.id);
|
|
17889
17957
|
redelivered++;
|
|
@@ -18096,6 +18164,77 @@ class PendingPurgeApprovals {
|
|
|
18096
18164
|
}
|
|
18097
18165
|
}
|
|
18098
18166
|
|
|
18167
|
+
// src/notify.ts
|
|
18168
|
+
var TOAST_CONFIG = {
|
|
18169
|
+
spawn: { variant: "success", duration: 3000 },
|
|
18170
|
+
message: { variant: "info", duration: 3000 },
|
|
18171
|
+
completed: { variant: "info", duration: 4000 },
|
|
18172
|
+
error: { variant: "error", duration: 5000 },
|
|
18173
|
+
shutdown: { variant: "info", duration: 3000 }
|
|
18174
|
+
};
|
|
18175
|
+
function formatMessage(type, data) {
|
|
18176
|
+
switch (type) {
|
|
18177
|
+
case "spawn":
|
|
18178
|
+
return `Teammate ${data.memberName} spawned (${data.agent})`;
|
|
18179
|
+
case "message":
|
|
18180
|
+
return `Message from ${data.from}`;
|
|
18181
|
+
case "completed":
|
|
18182
|
+
return `${data.memberName} finished work`;
|
|
18183
|
+
case "error":
|
|
18184
|
+
return `${data.memberName} encountered an error`;
|
|
18185
|
+
case "shutdown":
|
|
18186
|
+
return `${data.memberName} shut down`;
|
|
18187
|
+
}
|
|
18188
|
+
}
|
|
18189
|
+
async function notifyTeamEvent(client3, type, data) {
|
|
18190
|
+
const config2 = TOAST_CONFIG[type];
|
|
18191
|
+
try {
|
|
18192
|
+
await client3.tui.showToast({
|
|
18193
|
+
title: "Team",
|
|
18194
|
+
message: formatMessage(type, data),
|
|
18195
|
+
variant: config2.variant,
|
|
18196
|
+
duration: config2.duration
|
|
18197
|
+
});
|
|
18198
|
+
} catch {}
|
|
18199
|
+
}
|
|
18200
|
+
async function notifyWorkingProgress(client3, db, teamId) {
|
|
18201
|
+
const members = db.query("SELECT name, status FROM team_member WHERE team_id = ? ORDER BY time_created ASC").all(teamId);
|
|
18202
|
+
if (members.length === 0)
|
|
18203
|
+
return;
|
|
18204
|
+
const busy = members.filter((m) => m.status === "busy");
|
|
18205
|
+
try {
|
|
18206
|
+
if (busy.length === 0) {
|
|
18207
|
+
await client3.tui.showToast({
|
|
18208
|
+
title: "Team",
|
|
18209
|
+
message: "All teammates finished",
|
|
18210
|
+
variant: "success",
|
|
18211
|
+
duration: 4000
|
|
18212
|
+
});
|
|
18213
|
+
} else {
|
|
18214
|
+
const names = busy.map((m) => m.name).join(", ");
|
|
18215
|
+
await client3.tui.showToast({
|
|
18216
|
+
title: "Team",
|
|
18217
|
+
message: `Working: ${names} (${busy.length}/${members.length})`,
|
|
18218
|
+
variant: "info",
|
|
18219
|
+
duration: 5000
|
|
18220
|
+
});
|
|
18221
|
+
}
|
|
18222
|
+
} catch {}
|
|
18223
|
+
}
|
|
18224
|
+
function notifyLead(client3, db, teamId, content) {
|
|
18225
|
+
const id = sendMessage(db, { teamId, from: "system", to: "lead", content });
|
|
18226
|
+
const team = db.query("SELECT lead_session_id FROM team WHERE id = ?").get(teamId);
|
|
18227
|
+
if (!team?.lead_session_id)
|
|
18228
|
+
return id;
|
|
18229
|
+
client3.session.promptAsync({
|
|
18230
|
+
sessionID: team.lead_session_id,
|
|
18231
|
+
parts: [{ type: "text", text: "[System: New team message from system]" }]
|
|
18232
|
+
}).catch((err) => {
|
|
18233
|
+
log(`notifyLead:wake:failed team=${teamId} err=${err instanceof Error ? err.message : String(err)}`);
|
|
18234
|
+
});
|
|
18235
|
+
return id;
|
|
18236
|
+
}
|
|
18237
|
+
|
|
18099
18238
|
// src/types.ts
|
|
18100
18239
|
function findTeamBySession(db, registry2, sessionId) {
|
|
18101
18240
|
const entry = registry2.getBySession(sessionId);
|
|
@@ -18139,7 +18278,7 @@ function resolveRecipientSession(db, registry2, teamId, recipientName) {
|
|
|
18139
18278
|
|
|
18140
18279
|
// src/hooks.ts
|
|
18141
18280
|
var TEAM_TOOL_PREFIX = "team_";
|
|
18142
|
-
function handleSessionStatusEvent(db, registry2, sessionId, status) {
|
|
18281
|
+
function handleSessionStatusEvent(db, registry2, sessionId, status, retryPayload) {
|
|
18143
18282
|
const entry = registry2.getBySession(sessionId);
|
|
18144
18283
|
if (!entry)
|
|
18145
18284
|
return;
|
|
@@ -18154,6 +18293,9 @@ function handleSessionStatusEvent(db, registry2, sessionId, status) {
|
|
|
18154
18293
|
if (member.status === newStatus)
|
|
18155
18294
|
return;
|
|
18156
18295
|
db.run("UPDATE team_member SET status = ?, execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?", [newStatus, Date.now(), entry.teamId, entry.memberName]);
|
|
18296
|
+
if (newStatus === "shutdown") {
|
|
18297
|
+
releaseMemberTasks(db, entry.teamId, entry.memberName);
|
|
18298
|
+
}
|
|
18157
18299
|
if (member.status === "busy" && newStatus === "ready") {
|
|
18158
18300
|
const leadMsgCount = db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND from_name = ? AND to_name = 'lead'").get(entry.teamId, entry.memberName).c;
|
|
18159
18301
|
if (leadMsgCount > 0) {
|
|
@@ -18166,10 +18308,26 @@ function handleSessionStatusEvent(db, registry2, sessionId, status) {
|
|
|
18166
18308
|
db.run("UPDATE team_member SET status = 'busy', execution_status = 'running', reported_to_lead = 0, time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), entry.teamId, entry.memberName]);
|
|
18167
18309
|
return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: "busy" };
|
|
18168
18310
|
}
|
|
18311
|
+
if (member.status === "busy" && member.execution_status === "starting") {
|
|
18312
|
+
db.run("UPDATE team_member SET execution_status = 'running', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), entry.teamId, entry.memberName]);
|
|
18313
|
+
return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: "busy" };
|
|
18314
|
+
}
|
|
18169
18315
|
if (member.status === "shutdown_requested") {
|
|
18170
18316
|
return { memberName: entry.memberName, teamId: entry.teamId, from: "shutdown_requested", to: "busy_while_shutdown" };
|
|
18171
18317
|
}
|
|
18172
18318
|
} else if (status === "retry") {
|
|
18319
|
+
if (retryPayload) {
|
|
18320
|
+
const next = typeof retryPayload.next === "number" && Number.isFinite(retryPayload.next) ? retryPayload.next : null;
|
|
18321
|
+
const attempt = typeof retryPayload.attempt === "number" && Number.isFinite(retryPayload.attempt) ? retryPayload.attempt : null;
|
|
18322
|
+
db.run("UPDATE team_member SET retry_until = ?, retry_attempt = ?, retry_provider = ?, retry_message = ? WHERE team_id = ? AND name = ?", [
|
|
18323
|
+
next,
|
|
18324
|
+
attempt,
|
|
18325
|
+
retryPayload.action?.provider ?? null,
|
|
18326
|
+
retryPayload.action?.message ?? retryPayload.message ?? null,
|
|
18327
|
+
entry.teamId,
|
|
18328
|
+
entry.memberName
|
|
18329
|
+
]);
|
|
18330
|
+
}
|
|
18173
18331
|
return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: "retry" };
|
|
18174
18332
|
}
|
|
18175
18333
|
return;
|
|
@@ -18205,77 +18363,14 @@ function shouldNudgeIdleMember(db, teamId, memberName) {
|
|
|
18205
18363
|
const msg = db.query("SELECT id FROM team_message WHERE team_id = ? AND from_name = ? AND (to_name = 'lead' OR to_name IS NULL) LIMIT 1").get(teamId, memberName);
|
|
18206
18364
|
return !msg;
|
|
18207
18365
|
}
|
|
18208
|
-
function handleSessionErrorEvent(db, registry2, sessionId, error45) {
|
|
18366
|
+
function handleSessionErrorEvent(db, registry2, client3, sessionId, error45) {
|
|
18209
18367
|
if (!sessionId)
|
|
18210
18368
|
return;
|
|
18211
18369
|
const teamInfo = findTeamBySession(db, registry2, sessionId);
|
|
18212
18370
|
if (!teamInfo || teamInfo.role !== "member" || !teamInfo.memberName)
|
|
18213
18371
|
return;
|
|
18214
18372
|
const errMsg = error45?.data?.message ?? error45?.name ?? "unknown error";
|
|
18215
|
-
|
|
18216
|
-
teamId: teamInfo.teamId,
|
|
18217
|
-
from: "system",
|
|
18218
|
-
to: "lead",
|
|
18219
|
-
content: `Teammate "${teamInfo.memberName}" had a session error: ${errMsg}. Check their session for details. They may be stuck and need investigation or shutdown.`
|
|
18220
|
-
});
|
|
18221
|
-
}
|
|
18222
|
-
|
|
18223
|
-
// src/notify.ts
|
|
18224
|
-
var TOAST_CONFIG = {
|
|
18225
|
-
spawn: { variant: "success", duration: 3000 },
|
|
18226
|
-
message: { variant: "info", duration: 3000 },
|
|
18227
|
-
completed: { variant: "info", duration: 4000 },
|
|
18228
|
-
error: { variant: "error", duration: 5000 },
|
|
18229
|
-
shutdown: { variant: "info", duration: 3000 }
|
|
18230
|
-
};
|
|
18231
|
-
function formatMessage(type, data) {
|
|
18232
|
-
switch (type) {
|
|
18233
|
-
case "spawn":
|
|
18234
|
-
return `Teammate ${data.memberName} spawned (${data.agent})`;
|
|
18235
|
-
case "message":
|
|
18236
|
-
return `Message from ${data.from}`;
|
|
18237
|
-
case "completed":
|
|
18238
|
-
return `${data.memberName} finished work`;
|
|
18239
|
-
case "error":
|
|
18240
|
-
return `${data.memberName} encountered an error`;
|
|
18241
|
-
case "shutdown":
|
|
18242
|
-
return `${data.memberName} shut down`;
|
|
18243
|
-
}
|
|
18244
|
-
}
|
|
18245
|
-
async function notifyTeamEvent(client3, type, data) {
|
|
18246
|
-
const config2 = TOAST_CONFIG[type];
|
|
18247
|
-
try {
|
|
18248
|
-
await client3.tui.showToast({
|
|
18249
|
-
title: "Team",
|
|
18250
|
-
message: formatMessage(type, data),
|
|
18251
|
-
variant: config2.variant,
|
|
18252
|
-
duration: config2.duration
|
|
18253
|
-
});
|
|
18254
|
-
} catch {}
|
|
18255
|
-
}
|
|
18256
|
-
async function notifyWorkingProgress(client3, db, teamId) {
|
|
18257
|
-
const members = db.query("SELECT name, status FROM team_member WHERE team_id = ? ORDER BY time_created ASC").all(teamId);
|
|
18258
|
-
if (members.length === 0)
|
|
18259
|
-
return;
|
|
18260
|
-
const busy = members.filter((m) => m.status === "busy");
|
|
18261
|
-
try {
|
|
18262
|
-
if (busy.length === 0) {
|
|
18263
|
-
await client3.tui.showToast({
|
|
18264
|
-
title: "Team",
|
|
18265
|
-
message: "All teammates finished",
|
|
18266
|
-
variant: "success",
|
|
18267
|
-
duration: 4000
|
|
18268
|
-
});
|
|
18269
|
-
} else {
|
|
18270
|
-
const names = busy.map((m) => m.name).join(", ");
|
|
18271
|
-
await client3.tui.showToast({
|
|
18272
|
-
title: "Team",
|
|
18273
|
-
message: `Working: ${names} (${busy.length}/${members.length})`,
|
|
18274
|
-
variant: "info",
|
|
18275
|
-
duration: 5000
|
|
18276
|
-
});
|
|
18277
|
-
}
|
|
18278
|
-
} catch {}
|
|
18373
|
+
notifyLead(client3, db, teamInfo.teamId, `Teammate "${teamInfo.memberName}" had a session error: ${errMsg}. Check their session for details. They may be stuck and need investigation or shutdown.`);
|
|
18279
18374
|
}
|
|
18280
18375
|
|
|
18281
18376
|
// src/result-parser.ts
|
|
@@ -18544,6 +18639,7 @@ class ProgressTracker {
|
|
|
18544
18639
|
steps = new Map;
|
|
18545
18640
|
lastMessageAt = new Map;
|
|
18546
18641
|
lastTaskAt = new Map;
|
|
18642
|
+
busySince = new Map;
|
|
18547
18643
|
peerMessages = new Map;
|
|
18548
18644
|
reported = new Set;
|
|
18549
18645
|
chattyReported = new Set;
|
|
@@ -18598,14 +18694,18 @@ class ProgressTracker {
|
|
|
18598
18694
|
const recent = records.slice(-minSteps);
|
|
18599
18695
|
return recent.every((r) => r.outputTokens < threshold);
|
|
18600
18696
|
}
|
|
18601
|
-
|
|
18697
|
+
recordBusyStart(sessionId) {
|
|
18698
|
+
this.busySince.set(sessionId, Date.now());
|
|
18699
|
+
}
|
|
18700
|
+
lastActivityAt(sessionId) {
|
|
18602
18701
|
const records = this.steps.get(sessionId);
|
|
18603
|
-
|
|
18702
|
+
const lastStepAt = records && records.length > 0 ? records[records.length - 1].timestamp : 0;
|
|
18703
|
+
return Math.max(this.lastMessageAt.get(sessionId) ?? 0, this.lastTaskAt.get(sessionId) ?? 0, lastStepAt, this.busySince.get(sessionId) ?? 0);
|
|
18704
|
+
}
|
|
18705
|
+
isTimeStalled(sessionId, thresholdMs) {
|
|
18706
|
+
const baseline = this.lastActivityAt(sessionId);
|
|
18707
|
+
if (baseline === 0)
|
|
18604
18708
|
return false;
|
|
18605
|
-
const msgAt = this.lastMessageAt.get(sessionId) ?? 0;
|
|
18606
|
-
const taskAt = this.lastTaskAt.get(sessionId) ?? 0;
|
|
18607
|
-
const lastStep = records[records.length - 1]?.timestamp ?? 0;
|
|
18608
|
-
const baseline = Math.max(msgAt, taskAt, lastStep);
|
|
18609
18709
|
return Date.now() - baseline >= thresholdMs;
|
|
18610
18710
|
}
|
|
18611
18711
|
markReported(sessionId) {
|
|
@@ -18621,6 +18721,7 @@ class ProgressTracker {
|
|
|
18621
18721
|
this.steps.delete(sessionId);
|
|
18622
18722
|
this.lastMessageAt.delete(sessionId);
|
|
18623
18723
|
this.lastTaskAt.delete(sessionId);
|
|
18724
|
+
this.busySince.delete(sessionId);
|
|
18624
18725
|
this.peerMessages.delete(sessionId);
|
|
18625
18726
|
this.reported.delete(sessionId);
|
|
18626
18727
|
this.chattyReported.delete(sessionId);
|
|
@@ -19163,7 +19264,7 @@ function rAgents(t){
|
|
|
19163
19264
|
'<div class="flex items-center gap-2">'+
|
|
19164
19265
|
'<span class="w-[8px] h-[8px] rounded-full '+s.d+(m.status==='busy'?' pulse':'')+' shrink-0"></span>'+
|
|
19165
19266
|
'<span class="font-mono font-semibold text-[14px] truncate">'+E(m.name)+'</span>'+
|
|
19166
|
-
'<span class="text-[10px] px-1.5 py-[1px] rounded '+s.t+' bg-base-800/80 shrink-0">'+s.l+'</span>'+
|
|
19267
|
+
'<span class="text-[10px] px-1.5 py-[1px] rounded '+s.t+' bg-base-800/80 shrink-0">'+s.l+(m.lastNudgedAt?', nudged '+relT(m.lastNudgedAt):'')+'</span>'+
|
|
19167
19268
|
spark+
|
|
19168
19269
|
'<span class="text-[10px] text-txt-500 ml-auto shrink-0">'+E(m.agent)+'</span>'+
|
|
19169
19270
|
(m.model?chip(E(m.model),'muted'):'')+
|
|
@@ -19172,6 +19273,7 @@ function rAgents(t){
|
|
|
19172
19273
|
(mp?'<div class="mt-1 text-[12px] text-txt-400 truncate">Latest: '+E(mp)+'</div>':'')+
|
|
19173
19274
|
'<div class="mt-2 flex items-center gap-1.5 flex-wrap">'+
|
|
19174
19275
|
chip('status '+d,'muted')+chip('msg '+mi,'muted')+chip(E(m.executionStatus||m.status),m.status==='busy'?'blue':m.status==='error'?'red':'muted')+
|
|
19276
|
+
(m.isRetrying?chip('retrying'+(m.retryAttempt!=null?' (attempt '+m.retryAttempt+')':''),'amber'):'')+
|
|
19175
19277
|
(m.worktreeBranch?chip(E(m.worktreeBranch),'muted'):'')+
|
|
19176
19278
|
'</div></button>';
|
|
19177
19279
|
}).join('');
|
|
@@ -19195,6 +19297,8 @@ function openDrawer(name){
|
|
|
19195
19297
|
meta.push(chip(E(m.executionStatus||m.status),m.status==='busy'?'blue':m.status==='error'?'red':'muted'));
|
|
19196
19298
|
if(m.planApproval&&m.planApproval!=='none')meta.push(chip(E(m.planApproval),m.planApproval==='approved'?'green':m.planApproval==='rejected'?'red':'amber'));
|
|
19197
19299
|
meta.push(chip('spawned '+relT(m.timeCreated),'muted'));
|
|
19300
|
+
if(m.lastNudgedAt)meta.push(chip('nudged '+relT(m.lastNudgedAt),'amber'));
|
|
19301
|
+
if(m.isRetrying)meta.push(chip('retrying'+(m.retryAttempt!=null?' (attempt '+m.retryAttempt+')':'')+(m.retryMessage?': '+E(m.retryMessage):''),'amber'));
|
|
19198
19302
|
if(m.worktreeBranch)meta.push(chip(E(m.worktreeBranch),'muted'));
|
|
19199
19303
|
h+='<div class="flex flex-wrap gap-1.5 mb-4 pb-4 border-b border-base-800/50">'+meta.join('')+'</div>';
|
|
19200
19304
|
// Prompt
|
|
@@ -19262,7 +19366,7 @@ function rDrawerActivityUpdate(){
|
|
|
19262
19366
|
el.innerHTML='<div class="text-txt-500 text-[12px]">No activity recorded</div>';
|
|
19263
19367
|
return;
|
|
19264
19368
|
}
|
|
19265
|
-
var html=drawerActivity.map(function(a){
|
|
19369
|
+
var html=drawerActivity.slice().reverse().map(function(a){
|
|
19266
19370
|
var icon='\\u25CF',color='text-txt-500',label=a.type;
|
|
19267
19371
|
if(a.type==='tool_call'){icon='\\u25B8';color='text-blue-400';label=a.tool||'tool'}
|
|
19268
19372
|
if(a.type==='tool_result'){icon='\\u25B8';color=a.error?'text-red-400':'text-emerald-400';label=a.tool||'tool'}
|
|
@@ -19458,10 +19562,11 @@ function parseDependsOn(value) {
|
|
|
19458
19562
|
}
|
|
19459
19563
|
return [];
|
|
19460
19564
|
}
|
|
19565
|
+
var ENSEMBLE_STATE_VERSION = 1;
|
|
19461
19566
|
function buildState(db) {
|
|
19462
19567
|
const projects = db.query("SELECT id, name, path, status, time_created, time_updated FROM project ORDER BY time_updated DESC").all();
|
|
19463
|
-
const teams = db.query("SELECT id, name, project_id, status, lead_agent, time_created, time_updated FROM team ORDER BY time_created DESC").all();
|
|
19464
|
-
const memberStmt = db.query("SELECT name, agent, status, execution_status, session_id, worktree_branch, prompt, model, plan_approval, time_created, time_updated FROM team_member WHERE team_id = ?");
|
|
19568
|
+
const teams = db.query("SELECT id, name, project_id, lead_session_id, status, lead_agent, time_created, time_updated FROM team ORDER BY time_created DESC").all();
|
|
19569
|
+
const memberStmt = db.query("SELECT name, agent, status, execution_status, session_id, worktree_branch, prompt, model, plan_approval, time_created, time_updated, last_nudged_at, retry_until, retry_attempt, retry_provider, retry_message FROM team_member WHERE team_id = ?");
|
|
19465
19570
|
const taskStmt = db.query("SELECT id, content, status, priority, assignee, depends_on, time_created, time_updated FROM team_task WHERE team_id = ?");
|
|
19466
19571
|
const msgStmt = db.query("SELECT id, from_name, to_name, content, delivered, read, time_created FROM team_message WHERE team_id = ? ORDER BY time_created DESC LIMIT 50");
|
|
19467
19572
|
const mappedTeams = teams.map((t) => {
|
|
@@ -19476,12 +19581,19 @@ function buildState(db) {
|
|
|
19476
19581
|
model: m.model,
|
|
19477
19582
|
planApproval: m.plan_approval,
|
|
19478
19583
|
timeCreated: m.time_created,
|
|
19479
|
-
timeUpdated: m.time_updated
|
|
19584
|
+
timeUpdated: m.time_updated,
|
|
19585
|
+
lastNudgedAt: m.last_nudged_at,
|
|
19586
|
+
isRetrying: m.retry_until !== null && m.retry_until > Date.now(),
|
|
19587
|
+
retryUntil: m.retry_until,
|
|
19588
|
+
retryAttempt: m.retry_attempt,
|
|
19589
|
+
retryProvider: m.retry_provider,
|
|
19590
|
+
retryMessage: m.retry_message
|
|
19480
19591
|
}));
|
|
19481
19592
|
return {
|
|
19482
19593
|
id: t.id,
|
|
19483
19594
|
name: t.name,
|
|
19484
19595
|
projectId: t.project_id,
|
|
19596
|
+
leadSessionId: t.lead_session_id,
|
|
19485
19597
|
status: t.status,
|
|
19486
19598
|
leadAgent: t.lead_agent,
|
|
19487
19599
|
timeCreated: t.time_created,
|
|
@@ -19514,6 +19626,7 @@ function buildState(db) {
|
|
|
19514
19626
|
teamsByProject.set(projectId, [...teamsByProject.get(projectId) ?? [], team]);
|
|
19515
19627
|
});
|
|
19516
19628
|
return {
|
|
19629
|
+
version: ENSEMBLE_STATE_VERSION,
|
|
19517
19630
|
projects: projects.flatMap((project) => {
|
|
19518
19631
|
const projectTeams = teamsByProject.get(project.id) ?? [];
|
|
19519
19632
|
if (projectTeams.length === 0)
|
|
@@ -19543,10 +19656,25 @@ function sendJson(res, data) {
|
|
|
19543
19656
|
});
|
|
19544
19657
|
res.end(JSON.stringify(data));
|
|
19545
19658
|
}
|
|
19659
|
+
function parseMessageTime(time3) {
|
|
19660
|
+
if (typeof time3 === "object" && time3 !== null) {
|
|
19661
|
+
const created = time3.created;
|
|
19662
|
+
if (typeof created === "number" && Number.isFinite(created))
|
|
19663
|
+
return created;
|
|
19664
|
+
}
|
|
19665
|
+
if (typeof time3 === "number" && Number.isFinite(time3))
|
|
19666
|
+
return time3;
|
|
19667
|
+
if (typeof time3 === "string") {
|
|
19668
|
+
const ms = new Date(time3).getTime();
|
|
19669
|
+
if (!Number.isNaN(ms))
|
|
19670
|
+
return ms;
|
|
19671
|
+
}
|
|
19672
|
+
return Date.now();
|
|
19673
|
+
}
|
|
19546
19674
|
function parseMessageParts(parts, msgInfo) {
|
|
19547
19675
|
const entries = [];
|
|
19548
19676
|
const info = msgInfo ?? {};
|
|
19549
|
-
const timestamp =
|
|
19677
|
+
const timestamp = parseMessageTime(info.time);
|
|
19550
19678
|
for (const part of parts) {
|
|
19551
19679
|
if (typeof part !== "object" || part === null)
|
|
19552
19680
|
continue;
|
|
@@ -19697,9 +19825,14 @@ async function executeTeamCreate(deps, args, sessionId) {
|
|
|
19697
19825
|
throw new Error(projectNameError);
|
|
19698
19826
|
}
|
|
19699
19827
|
const projectId = deps.directory;
|
|
19700
|
-
const existing = deps.db.query("SELECT id FROM team WHERE name = ? AND project_id = ? AND status = 'active'").get(args.name, projectId);
|
|
19701
|
-
if (existing)
|
|
19702
|
-
|
|
19828
|
+
const existing = deps.db.query("SELECT id, lead_session_id FROM team WHERE name = ? AND project_id = ? AND status = 'active'").get(args.name, projectId);
|
|
19829
|
+
if (existing) {
|
|
19830
|
+
if (await isSessionAlive(deps.client, existing.lead_session_id)) {
|
|
19831
|
+
throw new Error(`Team "${args.name}" already exists`);
|
|
19832
|
+
}
|
|
19833
|
+
deps.db.run("UPDATE team SET status = 'archived', time_updated = ? WHERE id = ?", [Date.now(), existing.id]);
|
|
19834
|
+
deps.registry.unregisterTeam(existing.id);
|
|
19835
|
+
}
|
|
19703
19836
|
const lead = findTeamBySession(deps.db, deps.registry, sessionId);
|
|
19704
19837
|
if (lead)
|
|
19705
19838
|
throw new Error(`This session already belongs to team "${lead.teamName}"`);
|
|
@@ -19773,15 +19906,33 @@ function requireCanPurgeArchivedTeams(deps, sessionId) {
|
|
|
19773
19906
|
}
|
|
19774
19907
|
}
|
|
19775
19908
|
|
|
19909
|
+
// src/tools/team-claim.ts
|
|
19910
|
+
function claimTask(db, teamId, taskId, assignee) {
|
|
19911
|
+
const task = db.query("SELECT content, status, assignee FROM team_task WHERE id = ? AND team_id = ?").get(taskId, teamId);
|
|
19912
|
+
if (!task)
|
|
19913
|
+
throw new Error(`Task "${taskId}" not found`);
|
|
19914
|
+
if (task.status === "blocked")
|
|
19915
|
+
throw new Error(`Task "${taskId}" is blocked by unresolved dependencies`);
|
|
19916
|
+
if (task.status !== "pending")
|
|
19917
|
+
throw new Error(`Task "${taskId}" is not pending (status: ${task.status})`);
|
|
19918
|
+
if (task.assignee)
|
|
19919
|
+
throw new Error(`Task "${taskId}" is already claimed by ${task.assignee}`);
|
|
19920
|
+
const result = db.run("UPDATE team_task SET status = 'in_progress', assignee = ?, time_updated = ? WHERE id = ? AND status = 'pending' AND assignee IS NULL", [assignee, Date.now(), taskId]);
|
|
19921
|
+
if (result.changes === 0) {
|
|
19922
|
+
throw new Error(`Task "${taskId}" is already claimed (race condition)`);
|
|
19923
|
+
}
|
|
19924
|
+
return task.content;
|
|
19925
|
+
}
|
|
19926
|
+
async function executeTeamClaim(deps, args, sessionId) {
|
|
19927
|
+
const teamInfo = requireTeamMember(deps, sessionId);
|
|
19928
|
+
const claimerName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
|
|
19929
|
+
const content = claimTask(deps.db, teamInfo.teamId, args.task_id, claimerName);
|
|
19930
|
+
return `Claimed task: ${content}`;
|
|
19931
|
+
}
|
|
19932
|
+
|
|
19776
19933
|
// src/tools/team-spawn.ts
|
|
19777
19934
|
init_merge_helper();
|
|
19778
19935
|
var spawnFailures = new Map;
|
|
19779
|
-
function parseModelId(model) {
|
|
19780
|
-
const slash = model.indexOf("/");
|
|
19781
|
-
if (slash <= 0 || slash === model.length - 1)
|
|
19782
|
-
return;
|
|
19783
|
-
return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) };
|
|
19784
|
-
}
|
|
19785
19936
|
function resolveModel(explicitModel, agentType, teamMemberCount, config2) {
|
|
19786
19937
|
if (explicitModel)
|
|
19787
19938
|
return explicitModel;
|
|
@@ -19813,6 +19964,7 @@ function withTimeout(promise2, ms, label) {
|
|
|
19813
19964
|
]);
|
|
19814
19965
|
}
|
|
19815
19966
|
async function executeTeamSpawn(deps, args, sessionId) {
|
|
19967
|
+
const agent = args.agent ?? "build";
|
|
19816
19968
|
const nameError = validateMemberName(args.name);
|
|
19817
19969
|
if (nameError)
|
|
19818
19970
|
throw new Error(nameError);
|
|
@@ -19824,10 +19976,10 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
19824
19976
|
const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.name);
|
|
19825
19977
|
if (existing)
|
|
19826
19978
|
throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`);
|
|
19827
|
-
const isReadOnly =
|
|
19979
|
+
const isReadOnly = agent === "plan" || agent === "explore";
|
|
19828
19980
|
const useWorktree = args.worktree !== false && !isReadOnly && !isWorktreeDirectory(deps.directory);
|
|
19829
19981
|
const usePlanApproval = args.plan_approval === true;
|
|
19830
|
-
log(`spawn:start name=${args.name} agent=${
|
|
19982
|
+
log(`spawn:start name=${args.name} agent=${agent} worktree=${useWorktree}`);
|
|
19831
19983
|
let worktreeDir = null;
|
|
19832
19984
|
let worktreeBranch = null;
|
|
19833
19985
|
if (useWorktree) {
|
|
@@ -19883,7 +20035,7 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
19883
20035
|
log(`spawn:session:start name=${args.name}`);
|
|
19884
20036
|
const createResult = await withTimeout(deps.client.session.create({
|
|
19885
20037
|
parentID: sessionId,
|
|
19886
|
-
title: `${args.name} (@${
|
|
20038
|
+
title: `${args.name} (@${agent} teammate)`,
|
|
19887
20039
|
permission,
|
|
19888
20040
|
...workspaceId ? { workspaceID: workspaceId } : {}
|
|
19889
20041
|
}), getSpawnTimeout(), `session.create for "${args.name}"`);
|
|
@@ -19922,15 +20074,27 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
19922
20074
|
const planApproval = usePlanApproval ? "pending" : "none";
|
|
19923
20075
|
const now = Date.now();
|
|
19924
20076
|
const memberCount = deps.db.query("SELECT COUNT(*) as c FROM team_member WHERE team_id = ?").get(teamInfo.teamId).c;
|
|
19925
|
-
const resolvedModel = resolveModel(args.model,
|
|
20077
|
+
const resolvedModel = resolveModel(args.model, agent, memberCount, deps.config);
|
|
19926
20078
|
if (resolvedModel)
|
|
19927
20079
|
log(`spawn:model name=${args.name} model=${resolvedModel}`);
|
|
19928
20080
|
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)
|
|
19929
|
-
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId,
|
|
20081
|
+
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, agent, resolvedModel ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]);
|
|
20082
|
+
deps.progressTracker.recordBusyStart(childSessionId);
|
|
19930
20083
|
deps.registry.register(teamInfo.teamId, args.name, childSessionId);
|
|
20084
|
+
let claimedTaskContent = null;
|
|
20085
|
+
let claimWarning = null;
|
|
20086
|
+
if (args.claim_task && !isReadOnly) {
|
|
20087
|
+
try {
|
|
20088
|
+
claimedTaskContent = claimTask(deps.db, teamInfo.teamId, args.claim_task, args.name);
|
|
20089
|
+
log(`spawn:claim:ok name=${args.name} task=${args.claim_task}`);
|
|
20090
|
+
} catch (err) {
|
|
20091
|
+
claimWarning = err instanceof Error ? err.message : String(err);
|
|
20092
|
+
log(`spawn:claim:failed name=${args.name} task=${args.claim_task} err=${claimWarning}`);
|
|
20093
|
+
}
|
|
20094
|
+
}
|
|
19931
20095
|
const context = [
|
|
19932
20096
|
`You are "${args.name}", a teammate in team "${teamInfo.teamName}".`,
|
|
19933
|
-
`Your agent type is "${
|
|
20097
|
+
`Your agent type is "${agent}".`
|
|
19934
20098
|
];
|
|
19935
20099
|
const otherMembers = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name != ? AND status NOT IN ('shutdown', 'error')").all(teamInfo.teamId, args.name);
|
|
19936
20100
|
if (otherMembers.length > 0) {
|
|
@@ -19973,7 +20137,7 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
19973
20137
|
const lastStep = !isReadOnly && worktreeBranch ? "4" : !isReadOnly ? "3" : "2";
|
|
19974
20138
|
context.push(`${lastStep}. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.`, "", "If you are blocked:", "- Send ONE message to the lead via team_message describing the specific blocker.", "- Do NOT attempt workarounds or make assumptions. Wait for the lead's response.", "", "Your plain text output is NOT visible to the team. You MUST use team_message to communicate.");
|
|
19975
20139
|
context.push("", "Your task:", args.prompt);
|
|
19976
|
-
if (
|
|
20140
|
+
if (claimedTaskContent) {
|
|
19977
20141
|
context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`);
|
|
19978
20142
|
}
|
|
19979
20143
|
const contextStr = context.join(`
|
|
@@ -19986,7 +20150,7 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
19986
20150
|
deps.client.session.promptAsync({
|
|
19987
20151
|
sessionID: childSessionId,
|
|
19988
20152
|
parts: [{ type: "text", text: contextStr }],
|
|
19989
|
-
agent
|
|
20153
|
+
agent,
|
|
19990
20154
|
...modelParam ? { model: modelParam } : {}
|
|
19991
20155
|
}).catch((err) => {
|
|
19992
20156
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
@@ -19994,6 +20158,9 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
19994
20158
|
try {
|
|
19995
20159
|
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId]);
|
|
19996
20160
|
deps.registry.unregister(childSessionId);
|
|
20161
|
+
if (claimedTaskContent) {
|
|
20162
|
+
deps.db.run("UPDATE team_task SET status = 'pending', assignee = NULL, time_updated = ? WHERE id = ? AND assignee = ? AND status = 'in_progress'", [Date.now(), args.claim_task, args.name]);
|
|
20163
|
+
}
|
|
19997
20164
|
deps.client.session.abort({ sessionID: childSessionId }).catch(() => {});
|
|
19998
20165
|
if (workspaceId) {
|
|
19999
20166
|
deps.client.workspace.remove({ id: workspaceId }).catch(() => {});
|
|
@@ -20008,25 +20175,46 @@ async function executeTeamSpawn(deps, args, sessionId) {
|
|
|
20008
20175
|
variant: "error",
|
|
20009
20176
|
duration: 8000
|
|
20010
20177
|
}).catch(() => {});
|
|
20011
|
-
|
|
20012
|
-
teamId: teamInfo.teamId,
|
|
20013
|
-
from: "system",
|
|
20014
|
-
to: "lead",
|
|
20015
|
-
content: `Teammate "${args.name}" failed to start and was removed${modelInfo}. Error: ${errMsg}. You may retry the spawn.`
|
|
20016
|
-
});
|
|
20178
|
+
notifyLead(deps.client, deps.db, teamInfo.teamId, `Teammate "${args.name}" failed to start and was removed${modelInfo}. Error: ${errMsg}. You may retry the spawn.`);
|
|
20017
20179
|
} catch {}
|
|
20018
20180
|
});
|
|
20019
20181
|
const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : "";
|
|
20020
20182
|
const planInfo = usePlanApproval ? " [plan mode — will send plan for approval]" : "";
|
|
20183
|
+
const claimInfo = claimedTaskContent ? ` (claimed task: ${args.claim_task})` : claimWarning ? ` (could not claim task: ${claimWarning})` : "";
|
|
20021
20184
|
spawnFailures.delete(teamInfo.teamId);
|
|
20022
20185
|
log(`spawn:done name=${args.name} sessionId=${childSessionId}`);
|
|
20023
|
-
return `Teammate "${args.name}" spawned (agent: ${
|
|
20186
|
+
return `Teammate "${args.name}" spawned (agent: ${agent})${branchInfo}${planInfo}${claimInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`;
|
|
20024
20187
|
}
|
|
20025
20188
|
|
|
20026
20189
|
// src/tools/team-message.ts
|
|
20027
20190
|
async function executeTeamMessage(deps, args, sessionId) {
|
|
20028
20191
|
const teamInfo = requireTeamMember(deps, sessionId);
|
|
20192
|
+
if (args.force && teamInfo.role !== "lead") {
|
|
20193
|
+
throw new Error("Only the lead can force-deliver a message to a completed teammate.");
|
|
20194
|
+
}
|
|
20029
20195
|
const senderName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
|
|
20196
|
+
if (args.model !== undefined) {
|
|
20197
|
+
if (teamInfo.role !== "lead")
|
|
20198
|
+
throw new Error("Only the lead can update a teammate's model.");
|
|
20199
|
+
const parsed = parseModelId(args.model);
|
|
20200
|
+
if (!parsed)
|
|
20201
|
+
throw new Error(`Invalid model "${args.model}" — expected "provider/model" format (e.g. "anthropic/claude-sonnet").`);
|
|
20202
|
+
const member = deps.db.query("SELECT status FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.to);
|
|
20203
|
+
if (!member)
|
|
20204
|
+
throw new Error(`Teammate "${args.to}" not found in team "${teamInfo.teamName}".`);
|
|
20205
|
+
if (member.status === "shutdown" || member.status === "error") {
|
|
20206
|
+
throw new Error(`Teammate "${args.to}" is shut down — spawn a new teammate instead of updating the model.`);
|
|
20207
|
+
}
|
|
20208
|
+
deps.db.run("UPDATE team_member SET model = ?, time_updated = ? WHERE team_id = ? AND name = ?", [args.model, Date.now(), teamInfo.teamId, args.to]);
|
|
20209
|
+
log(`team_message:model-update to=${args.to} model=${args.model}`);
|
|
20210
|
+
if (!args.text) {
|
|
20211
|
+
return `Updated ${args.to}'s model to ${args.model}. It applies on their next turn.`;
|
|
20212
|
+
}
|
|
20213
|
+
}
|
|
20214
|
+
if (args.text === undefined) {
|
|
20215
|
+
throw new Error("team_message requires either 'text' or 'model'.");
|
|
20216
|
+
}
|
|
20217
|
+
const text = args.text;
|
|
20030
20218
|
const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to);
|
|
20031
20219
|
if (!recipientSessionId && args.to !== "lead") {
|
|
20032
20220
|
if (args.approve || args.reject) {
|
|
@@ -20036,14 +20224,14 @@ async function executeTeamMessage(deps, args, sessionId) {
|
|
|
20036
20224
|
teamId: teamInfo.teamId,
|
|
20037
20225
|
from: senderName,
|
|
20038
20226
|
to: args.to,
|
|
20039
|
-
content:
|
|
20227
|
+
content: text
|
|
20040
20228
|
});
|
|
20041
20229
|
log(`team_message:queued from=${senderName} to=${args.to} (recipient not yet spawned)`);
|
|
20042
20230
|
return `Message queued for ${args.to} — they haven't been spawned yet. It will be delivered when they join the team.`;
|
|
20043
20231
|
}
|
|
20044
20232
|
if (!recipientSessionId)
|
|
20045
20233
|
throw new Error(`Recipient "${args.to}" not found in team "${teamInfo.teamName}"`);
|
|
20046
|
-
let messageText =
|
|
20234
|
+
let messageText = text;
|
|
20047
20235
|
if (args.approve || args.reject) {
|
|
20048
20236
|
if (args.approve && args.reject) {
|
|
20049
20237
|
throw new Error("Cannot both approve and reject a plan.");
|
|
@@ -20080,13 +20268,15 @@ async function executeTeamMessage(deps, args, sessionId) {
|
|
|
20080
20268
|
});
|
|
20081
20269
|
return `Message sent to ${args.to}.`;
|
|
20082
20270
|
}
|
|
20083
|
-
if (hasReportedCompletion(deps.db, teamInfo.teamId, args.to)) {
|
|
20084
|
-
return `Message stored for ${args.to} (teammate has completed their task — message will not wake them).`;
|
|
20271
|
+
if (hasReportedCompletion(deps.db, teamInfo.teamId, args.to) && !args.force) {
|
|
20272
|
+
return `Message stored for ${args.to} (teammate has completed their task — message will not wake them). Pass force:true to re-activate them.`;
|
|
20085
20273
|
}
|
|
20086
20274
|
const deliveryText = `[Team message from ${senderName}]: ${messageText}`;
|
|
20275
|
+
const recipientModel = getMemberModel(deps.db, teamInfo.teamId, args.to);
|
|
20087
20276
|
deps.client.session.promptAsync({
|
|
20088
20277
|
sessionID: recipientSessionId,
|
|
20089
|
-
parts: [{ type: "text", text: deliveryText }]
|
|
20278
|
+
parts: [{ type: "text", text: deliveryText }],
|
|
20279
|
+
...recipientModel ? { model: recipientModel } : {}
|
|
20090
20280
|
}).then(() => {
|
|
20091
20281
|
markDelivered(deps.db, msgId);
|
|
20092
20282
|
}).catch((err) => {
|
|
@@ -20201,6 +20391,9 @@ async function preserveAndAbort(deps, teamId, memberName, sessionId, worktreeBra
|
|
|
20201
20391
|
await deps.client.session.abort({ sessionID: sessionId });
|
|
20202
20392
|
} catch {}
|
|
20203
20393
|
deps.db.run("UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), teamId, memberName]);
|
|
20394
|
+
const released = releaseMemberTasks(deps.db, teamId, memberName);
|
|
20395
|
+
if (released > 0)
|
|
20396
|
+
log(`shutdown:tasks:released name=${memberName} count=${released}`);
|
|
20204
20397
|
}
|
|
20205
20398
|
async function getBranchStatus(deps, teamId, memberName, worktreeDir, isDirty, commitCount) {
|
|
20206
20399
|
const row = deps.db.query("SELECT worktree_branch FROM team_member WHERE team_id = ? AND name = ?").get(teamId, memberName);
|
|
@@ -20670,9 +20863,9 @@ Review with: git diff`);
|
|
|
20670
20863
|
|
|
20671
20864
|
// src/tools/team-merge.ts
|
|
20672
20865
|
init_merge_helper();
|
|
20673
|
-
async function executeTeamMerge(deps, args, sessionId, merge2 = mergeBranch, delBranch = deleteBranch, overlapCheck = getOverlappingFiles) {
|
|
20866
|
+
async function executeTeamMerge(deps, args, sessionId, merge2 = mergeBranch, delBranch = deleteBranch, overlapCheck = getOverlappingFiles, commitCount = countBranchCommits, isDirty = checkWorktreeDirty) {
|
|
20674
20867
|
const teamInfo = requireLead(deps, sessionId);
|
|
20675
|
-
const member = deps.db.query("SELECT status, worktree_branch FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
|
|
20868
|
+
const member = deps.db.query("SELECT status, worktree_branch, worktree_dir FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, args.member);
|
|
20676
20869
|
if (!member)
|
|
20677
20870
|
throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`);
|
|
20678
20871
|
if (member.status !== "shutdown" && member.status !== "error") {
|
|
@@ -20683,6 +20876,21 @@ async function executeTeamMerge(deps, args, sessionId, merge2 = mergeBranch, del
|
|
|
20683
20876
|
}
|
|
20684
20877
|
const branch = member.worktree_branch;
|
|
20685
20878
|
log(`merge:start member=${args.member} branch=${branch}`);
|
|
20879
|
+
const commits = await commitCount(branch, deps.directory);
|
|
20880
|
+
if (commits === 0) {
|
|
20881
|
+
const dirty = member.worktree_dir ? await isDirty(member.worktree_dir).catch(() => false) : false;
|
|
20882
|
+
if (dirty) {
|
|
20883
|
+
return [
|
|
20884
|
+
`Nothing to merge for "${args.member}" — their branch (${branch}) has no commits.`,
|
|
20885
|
+
`Their worktree still has uncommitted changes that were never captured on the branch.`,
|
|
20886
|
+
`This work will be permanently lost if the worktree is removed (e.g. via team_cleanup).`,
|
|
20887
|
+
member.worktree_dir ? `Worktree: ${member.worktree_dir}` : ``,
|
|
20888
|
+
`Recover manually (copy files out of the worktree) before shutting down/cleaning up, or re-spawn and instruct them to commit their changes before reporting done.`
|
|
20889
|
+
].filter(Boolean).join(`
|
|
20890
|
+
`);
|
|
20891
|
+
}
|
|
20892
|
+
return `Nothing to merge for "${args.member}" — their branch (${branch}) has no commits and no uncommitted changes. They made no changes.`;
|
|
20893
|
+
}
|
|
20686
20894
|
try {
|
|
20687
20895
|
const overlap = await overlapCheck(branch, deps.directory);
|
|
20688
20896
|
if (overlap.length > 0) {
|
|
@@ -20754,11 +20962,28 @@ async function executeTeamTasksAdd(deps, args, sessionId) {
|
|
|
20754
20962
|
// src/tools/team-tasks-complete.ts
|
|
20755
20963
|
async function executeTeamTasksComplete(deps, args, sessionId) {
|
|
20756
20964
|
const teamInfo = requireTeamMember(deps, sessionId);
|
|
20757
|
-
const
|
|
20965
|
+
const caller = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
|
|
20966
|
+
const task = deps.db.query("SELECT id, content, status, assignee FROM team_task WHERE id = ? AND team_id = ?").get(args.task_id, teamInfo.teamId);
|
|
20758
20967
|
if (!task)
|
|
20759
20968
|
throw new Error(`Task "${args.task_id}" not found`);
|
|
20969
|
+
if (task.status === "completed")
|
|
20970
|
+
throw new Error(`Task "${args.task_id}" is already completed`);
|
|
20971
|
+
if (task.status === "cancelled")
|
|
20972
|
+
throw new Error(`Task "${args.task_id}" was cancelled`);
|
|
20973
|
+
if (task.status === "blocked")
|
|
20974
|
+
throw new Error(`Task "${args.task_id}" is blocked by unresolved dependencies`);
|
|
20975
|
+
if (task.status === "in_progress" && task.assignee && teamInfo.role !== "lead" && caller !== task.assignee) {
|
|
20976
|
+
throw new Error(`Task "${args.task_id}" is claimed by ${task.assignee}`);
|
|
20977
|
+
}
|
|
20760
20978
|
const now = Date.now();
|
|
20761
|
-
|
|
20979
|
+
if (task.assignee) {
|
|
20980
|
+
deps.db.run("UPDATE team_task SET status = 'completed', time_updated = ? WHERE id = ?", [now, args.task_id]);
|
|
20981
|
+
} else {
|
|
20982
|
+
const result = deps.db.run("UPDATE team_task SET status = 'completed', assignee = ?, time_updated = ? WHERE id = ? AND assignee IS NULL", [caller, now, args.task_id]);
|
|
20983
|
+
if (result.changes === 0) {
|
|
20984
|
+
throw new Error(`Task "${args.task_id}" was just completed by another teammate`);
|
|
20985
|
+
}
|
|
20986
|
+
}
|
|
20762
20987
|
const allTasks = deps.db.query("SELECT id, depends_on, status FROM team_task WHERE team_id = ?").all(teamInfo.teamId);
|
|
20763
20988
|
let unblocked = 0;
|
|
20764
20989
|
for (const t of allTasks) {
|
|
@@ -20796,26 +21021,6 @@ async function executeTeamTasksComplete(deps, args, sessionId) {
|
|
|
20796
21021
|
return `Completed task: ${task.content}${unblockedMsg}`;
|
|
20797
21022
|
}
|
|
20798
21023
|
|
|
20799
|
-
// src/tools/team-claim.ts
|
|
20800
|
-
async function executeTeamClaim(deps, args, sessionId) {
|
|
20801
|
-
const teamInfo = requireTeamMember(deps, sessionId);
|
|
20802
|
-
const claimerName = teamInfo.role === "lead" ? "lead" : teamInfo.memberName ?? "unknown";
|
|
20803
|
-
const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?").get(args.task_id, teamInfo.teamId);
|
|
20804
|
-
if (!task)
|
|
20805
|
-
throw new Error(`Task "${args.task_id}" not found`);
|
|
20806
|
-
if (task.status === "blocked")
|
|
20807
|
-
throw new Error(`Task "${args.task_id}" is blocked by unresolved dependencies`);
|
|
20808
|
-
if (task.status !== "pending")
|
|
20809
|
-
throw new Error(`Task "${args.task_id}" is not pending (status: ${task.status})`);
|
|
20810
|
-
if (task.assignee)
|
|
20811
|
-
throw new Error(`Task "${args.task_id}" is already claimed by ${task.assignee}`);
|
|
20812
|
-
const result = deps.db.run("UPDATE team_task SET status = 'in_progress', assignee = ?, time_updated = ? WHERE id = ? AND status = 'pending' AND assignee IS NULL", [claimerName, Date.now(), args.task_id]);
|
|
20813
|
-
if (result.changes === 0) {
|
|
20814
|
-
throw new Error(`Task "${args.task_id}" is already claimed (race condition)`);
|
|
20815
|
-
}
|
|
20816
|
-
return `Claimed task: ${task.content}`;
|
|
20817
|
-
}
|
|
20818
|
-
|
|
20819
21024
|
// src/tools/team-results.ts
|
|
20820
21025
|
async function executeTeamResults(deps, args, sessionId) {
|
|
20821
21026
|
const team = requireTeamMember(deps, sessionId);
|
|
@@ -20838,7 +21043,16 @@ ${r.content}`;
|
|
|
20838
21043
|
|
|
20839
21044
|
// src/tools/team-status.ts
|
|
20840
21045
|
var lastCallTime = new Map;
|
|
21046
|
+
var lastKnownState = new Map;
|
|
20841
21047
|
var RATE_LIMIT_MS = 30000;
|
|
21048
|
+
function computeStateMarker(deps, teamId) {
|
|
21049
|
+
const row = deps.db.query(`SELECT MAX(x) as marker FROM (
|
|
21050
|
+
SELECT MAX(time_updated) as x FROM team_member WHERE team_id = ?
|
|
21051
|
+
UNION SELECT MAX(time_updated) FROM team_task WHERE team_id = ?
|
|
21052
|
+
UNION SELECT MAX(time_created) FROM team_message WHERE team_id = ?
|
|
21053
|
+
)`).get(teamId, teamId, teamId);
|
|
21054
|
+
return row?.marker ?? 0;
|
|
21055
|
+
}
|
|
20842
21056
|
function formatDuration(ms) {
|
|
20843
21057
|
if (ms < 60000)
|
|
20844
21058
|
return `${Math.floor(ms / 1000)}s`;
|
|
@@ -20850,11 +21064,13 @@ async function executeTeamStatus(deps, sessionId) {
|
|
|
20850
21064
|
const teamInfo = requireTeamMember(deps, sessionId);
|
|
20851
21065
|
const now = Date.now();
|
|
20852
21066
|
const last = lastCallTime.get(teamInfo.teamId);
|
|
20853
|
-
|
|
21067
|
+
const currentMarker = computeStateMarker(deps, teamInfo.teamId);
|
|
21068
|
+
if (last !== undefined && now - last < RATE_LIMIT_MS && lastKnownState.get(teamInfo.teamId) === currentMarker) {
|
|
20854
21069
|
return "No changes since last check.";
|
|
20855
21070
|
}
|
|
20856
21071
|
lastCallTime.set(teamInfo.teamId, now);
|
|
20857
|
-
|
|
21072
|
+
lastKnownState.set(teamInfo.teamId, currentMarker);
|
|
21073
|
+
const members = deps.db.query("SELECT name, session_id, agent, status, execution_status, worktree_branch, worktree_dir, plan_approval, time_updated, last_nudged_at, retry_until, retry_attempt FROM team_member WHERE team_id = ? ORDER BY time_created ASC").all(teamInfo.teamId);
|
|
20858
21074
|
const tasks = deps.db.query("SELECT status FROM team_task WHERE team_id = ?").all(teamInfo.teamId);
|
|
20859
21075
|
const lines = [];
|
|
20860
21076
|
lines.push(`Team: ${teamInfo.teamName} (you are the ${teamInfo.role})`);
|
|
@@ -20868,9 +21084,12 @@ async function executeTeamStatus(deps, sessionId) {
|
|
|
20868
21084
|
const duration3 = formatDuration(now - m.time_updated);
|
|
20869
21085
|
const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : "";
|
|
20870
21086
|
const plan = m.plan_approval !== "none" ? `, plan: ${m.plan_approval}` : "";
|
|
21087
|
+
const nudged = m.last_nudged_at ? `, nudged ${formatDuration(now - m.last_nudged_at)} ago` : "";
|
|
21088
|
+
const isRetrying = m.retry_until !== null && m.retry_until > now;
|
|
21089
|
+
const retrying = isRetrying ? `, retrying (attempt ${m.retry_attempt}, ~${formatDuration(m.retry_until - now)})` : "";
|
|
20871
21090
|
const lastMsg = deps.db.query("SELECT MAX(time_created) as last_msg FROM team_message WHERE team_id = ? AND from_name = ?").get(teamInfo.teamId, m.name);
|
|
20872
21091
|
const msgInfo = lastMsg?.last_msg ? `last msg: ${formatDuration(now - lastMsg.last_msg)} ago` : "no messages yet";
|
|
20873
|
-
lines.push(` ${m.name} [${statusIcon} ${duration3}, ${msgInfo}${plan}] agent: ${m.agent}${branch}`);
|
|
21092
|
+
lines.push(` ${m.name} [${statusIcon} ${duration3}, ${msgInfo}${plan}${nudged}${retrying}] agent: ${m.agent}${branch}`);
|
|
20874
21093
|
const task = deps.db.query("SELECT content FROM team_task WHERE team_id = ? AND assignee = ? AND status = 'in_progress' LIMIT 1").get(teamInfo.teamId, m.name);
|
|
20875
21094
|
if (task) {
|
|
20876
21095
|
const truncated = task.content.length > 80 ? `${task.content.slice(0, 80)}...` : task.content;
|
|
@@ -21054,18 +21273,32 @@ class Watchdog {
|
|
|
21054
21273
|
const timeStalled = this.progressTracker.isTimeStalled(member.session_id, this.stallThresholdMs);
|
|
21055
21274
|
if (!tokenStalled && !timeStalled)
|
|
21056
21275
|
continue;
|
|
21057
|
-
this.
|
|
21276
|
+
if (hasReportedCompletion(this.db, member.team_id, member.name)) {
|
|
21277
|
+
log(`watchdog:stall:skip member=${member.name} team=${member.team_id} reason=already-reported-completion`);
|
|
21278
|
+
continue;
|
|
21279
|
+
}
|
|
21058
21280
|
const reason = tokenStalled ? "low output tokens" : "no communication";
|
|
21281
|
+
const recentNudgeRow = this.db.query("SELECT last_nudged_at FROM team_member WHERE team_id = ? AND name = ?").get(member.team_id, member.name);
|
|
21282
|
+
if (recentNudgeRow?.last_nudged_at) {
|
|
21283
|
+
const nudgedAt = recentNudgeRow.last_nudged_at;
|
|
21284
|
+
const activeSinceNudge = (this.progressTracker?.lastActivityAt(member.session_id) ?? 0) >= nudgedAt;
|
|
21285
|
+
if (!activeSinceNudge && Date.now() - nudgedAt < this.stallThresholdMs) {
|
|
21286
|
+
log(`watchdog:stall:skip member=${member.name} team=${member.team_id} reason=recently-nudged`);
|
|
21287
|
+
continue;
|
|
21288
|
+
}
|
|
21289
|
+
}
|
|
21290
|
+
this.db.run("UPDATE team_member SET last_nudged_at = ? WHERE team_id = ? AND name = ?", [Date.now(), member.team_id, member.name]);
|
|
21291
|
+
const stallModel = getMemberModel(this.db, member.team_id, member.name);
|
|
21059
21292
|
this.client.session.promptAsync({
|
|
21060
21293
|
sessionID: member.session_id,
|
|
21061
|
-
parts: [{ type: "text", text: "[System]: You appear stalled — no progress detected. Report your current status to the lead via team_message, or wrap up your work." }]
|
|
21062
|
-
|
|
21063
|
-
|
|
21064
|
-
|
|
21065
|
-
|
|
21066
|
-
|
|
21067
|
-
content: `Teammate "${member.name}" appears stalled (${reason}). Consider checking on them via team_message or shutting them down.`
|
|
21294
|
+
parts: [{ type: "text", text: "[System]: You appear stalled — no progress detected. Report your current status to the lead via team_message, or wrap up your work." }],
|
|
21295
|
+
...stallModel ? { model: stallModel } : {}
|
|
21296
|
+
}).then(() => {
|
|
21297
|
+
this.progressTracker.markReported(member.session_id);
|
|
21298
|
+
}).catch((err) => {
|
|
21299
|
+
log(`watchdog:stall:nudge-failed member=${member.name} team=${member.team_id} session=${member.session_id} err=${err instanceof Error ? err.message : String(err)}`);
|
|
21068
21300
|
});
|
|
21301
|
+
notifyLead(this.client, this.db, member.team_id, `Teammate "${member.name}" appears stalled (${reason}). Consider checking on them via team_message or shutting them down.`);
|
|
21069
21302
|
try {
|
|
21070
21303
|
await this.client.tui.showToast({
|
|
21071
21304
|
title: "Team",
|
|
@@ -21088,17 +21321,20 @@ class Watchdog {
|
|
|
21088
21321
|
continue;
|
|
21089
21322
|
if (!this.progressTracker.isChatty(member.session_id, this.peerMessageLimit, this.peerMessageWindowMs))
|
|
21090
21323
|
continue;
|
|
21324
|
+
if (hasReportedCompletion(this.db, member.team_id, member.name)) {
|
|
21325
|
+
log(`watchdog:chatty:skip member=${member.name} team=${member.team_id} reason=already-reported-completion`);
|
|
21326
|
+
continue;
|
|
21327
|
+
}
|
|
21091
21328
|
this.progressTracker.markChattyReported(member.session_id);
|
|
21329
|
+
const chattyModel = getMemberModel(this.db, member.team_id, member.name);
|
|
21092
21330
|
this.client.session.promptAsync({
|
|
21093
21331
|
sessionID: member.session_id,
|
|
21094
|
-
parts: [{ type: "text", text: "[System]: You've sent several messages to teammates. Focus on completing your task and send your results to the lead via team_message." }]
|
|
21095
|
-
|
|
21096
|
-
|
|
21097
|
-
|
|
21098
|
-
from: "system",
|
|
21099
|
-
to: "lead",
|
|
21100
|
-
content: `Agent "${member.name}" is sending many peer messages and may be over-coordinating. Consider checking on them.`
|
|
21332
|
+
parts: [{ type: "text", text: "[System]: You've sent several messages to teammates. Focus on completing your task and send your results to the lead via team_message." }],
|
|
21333
|
+
...chattyModel ? { model: chattyModel } : {}
|
|
21334
|
+
}).catch((err) => {
|
|
21335
|
+
log(`watchdog:chatty:nudge-failed member=${member.name} team=${member.team_id} session=${member.session_id} err=${err instanceof Error ? err.message : String(err)}`);
|
|
21101
21336
|
});
|
|
21337
|
+
notifyLead(this.client, this.db, member.team_id, `Agent "${member.name}" is sending many peer messages and may be over-coordinating. Consider checking on them.`);
|
|
21102
21338
|
log(`watchdog:chatty member=${member.name} limit=${this.peerMessageLimit}`);
|
|
21103
21339
|
}
|
|
21104
21340
|
}
|
|
@@ -21126,6 +21362,10 @@ class Watchdog {
|
|
|
21126
21362
|
}
|
|
21127
21363
|
}
|
|
21128
21364
|
this.db.run("UPDATE team_member SET status = 'error', execution_status = 'timed_out', time_updated = ? WHERE team_id = ? AND name = ?", [Date.now(), member.team_id, member.name]);
|
|
21365
|
+
const released = releaseMemberTasks(this.db, member.team_id, member.name);
|
|
21366
|
+
if (released > 0)
|
|
21367
|
+
log(`watchdog:tasks:released name=${member.name} count=${released}`);
|
|
21368
|
+
notifyLead(this.client, this.db, member.team_id, `Teammate "${member.name}" timed out after exceeding the busy time limit and was aborted. Their in-progress work has been released. Review their session, then re-spawn or reassign the task if needed.`);
|
|
21129
21369
|
try {
|
|
21130
21370
|
await this.client.session.abort({ sessionID: member.session_id });
|
|
21131
21371
|
} catch {}
|
|
@@ -21176,9 +21416,15 @@ var plugin = async (input) => {
|
|
|
21176
21416
|
const rawClient = new OpencodeClient({ client: pluginTransport });
|
|
21177
21417
|
initLog(rawClient);
|
|
21178
21418
|
const client3 = wrapThrowingClient(rawClient);
|
|
21179
|
-
const deps = { db, registry: registry2, tracker, purgeApprovals, client: client3, directory: input.directory, config: config2 };
|
|
21419
|
+
const deps = { db, registry: registry2, tracker, purgeApprovals, client: client3, directory: input.directory, config: config2, progressTracker };
|
|
21180
21420
|
if (!isWorktreeInstance(input.directory)) {
|
|
21181
21421
|
log("init:recovery:start (main instance)");
|
|
21422
|
+
recoverOrphanedTeams(db, client3, input.directory, registry2).then((result) => {
|
|
21423
|
+
if (result.archived > 0)
|
|
21424
|
+
log(`init:recovery:orphaned-teams-archived=${result.archived}`);
|
|
21425
|
+
}).catch((err) => {
|
|
21426
|
+
log(`init:recover-orphaned-teams:failed err=${err instanceof Error ? err.message : String(err)}`);
|
|
21427
|
+
});
|
|
21182
21428
|
const recovery = await recoverStaleMembers(db, client3, input.directory);
|
|
21183
21429
|
if (recovery.interrupted > 0) {
|
|
21184
21430
|
log(`init:recovery:interrupted=${recovery.interrupted}`);
|
|
@@ -21229,7 +21475,8 @@ var plugin = async (input) => {
|
|
|
21229
21475
|
if (event.type === "session.status") {
|
|
21230
21476
|
const { sessionID, status } = event.properties;
|
|
21231
21477
|
const statusType = status.type;
|
|
21232
|
-
const
|
|
21478
|
+
const retryPayload = statusType === "retry" ? status : undefined;
|
|
21479
|
+
const transition = handleSessionStatusEvent(db, registry2, sessionID, statusType, retryPayload);
|
|
21233
21480
|
if (transition) {
|
|
21234
21481
|
if (transition.to === "shutdown") {
|
|
21235
21482
|
notifyTeamEvent(client3, "shutdown", { memberName: transition.memberName });
|
|
@@ -21245,12 +21492,7 @@ var plugin = async (input) => {
|
|
|
21245
21492
|
nudgedMembers.add(fastIdleKey);
|
|
21246
21493
|
const modelInfo = memberInfo.model ? ` (model: ${memberInfo.model})` : "";
|
|
21247
21494
|
log(`fast-idle: ${transition.memberName} went idle ${Math.round(spawnAge / 1000)}s after spawn with 0 messages${modelInfo}`);
|
|
21248
|
-
|
|
21249
|
-
teamId: transition.teamId,
|
|
21250
|
-
from: "system",
|
|
21251
|
-
to: "lead",
|
|
21252
|
-
content: `Warning: Teammate "${transition.memberName}" went idle immediately after spawning with no output${modelInfo}. This usually means the model failed to start (authentication error, invalid model, or provider issue). Check your API key and model configuration, then retry the spawn.`
|
|
21253
|
-
});
|
|
21495
|
+
notifyLead(client3, db, transition.teamId, `Warning: Teammate "${transition.memberName}" went idle immediately after spawning with no output${modelInfo}. This usually means the model failed to start (authentication error, invalid model, or provider issue). Check your API key and model configuration, then retry the spawn.`);
|
|
21254
21496
|
client3.tui.showToast({
|
|
21255
21497
|
title: "Team",
|
|
21256
21498
|
message: `${transition.memberName} failed to produce output${modelInfo}`,
|
|
@@ -21264,13 +21506,19 @@ var plugin = async (input) => {
|
|
|
21264
21506
|
if (!nudgedMembers.has(nudgeKey) && shouldNudgeIdleMember(db, transition.teamId, transition.memberName) && !hasReportedCompletion(db, transition.teamId, transition.memberName)) {
|
|
21265
21507
|
nudgedMembers.add(nudgeKey);
|
|
21266
21508
|
log(`nudge:idle-without-report name=${transition.memberName}`);
|
|
21509
|
+
const nudgeModel = getMemberModel(db, transition.teamId, transition.memberName);
|
|
21267
21510
|
client3.session.promptAsync({
|
|
21268
21511
|
sessionID,
|
|
21269
|
-
parts: [{ type: "text", text: "[System]: You completed your work but did not report results. Send your findings to the lead via team_message now." }]
|
|
21270
|
-
|
|
21512
|
+
parts: [{ type: "text", text: "[System]: You completed your work but did not report results. Send your findings to the lead via team_message now." }],
|
|
21513
|
+
...nudgeModel ? { model: nudgeModel } : {}
|
|
21514
|
+
}).catch((err) => {
|
|
21515
|
+
log(`nudge:idle-without-report:failed name=${transition.memberName} team=${transition.teamId} err=${err instanceof Error ? err.message : String(err)}`);
|
|
21516
|
+
});
|
|
21271
21517
|
}
|
|
21272
21518
|
} else if (transition.to === "error") {
|
|
21273
21519
|
notifyTeamEvent(client3, "error", { memberName: transition.memberName });
|
|
21520
|
+
} else if (transition.to === "busy") {
|
|
21521
|
+
progressTracker.recordBusyStart(sessionID);
|
|
21274
21522
|
} else if (transition.to === "retry") {
|
|
21275
21523
|
try {
|
|
21276
21524
|
await client3.tui.showToast({
|
|
@@ -21323,9 +21571,11 @@ var plugin = async (input) => {
|
|
|
21323
21571
|
const peerMsgs = db.query("SELECT COUNT(*) as c FROM team_message WHERE team_id = ? AND to_name = ? AND delivered = 0 AND time_created < ?").get(member.team_id, member.name, staleThreshold);
|
|
21324
21572
|
if (peerMsgs.c > 0) {
|
|
21325
21573
|
log(`wake-peer: ${member.name} has ${peerMsgs.c} pending peer messages`);
|
|
21574
|
+
const peerModel = getMemberModel(db, member.team_id, member.name);
|
|
21326
21575
|
client3.session.promptAsync({
|
|
21327
21576
|
sessionID,
|
|
21328
|
-
parts: [{ type: "text", text: `[System: ${peerMsgs.c} new message(s) from teammates]` }]
|
|
21577
|
+
parts: [{ type: "text", text: `[System: ${peerMsgs.c} new message(s) from teammates]` }],
|
|
21578
|
+
...peerModel ? { model: peerModel } : {}
|
|
21329
21579
|
}).catch((err) => {
|
|
21330
21580
|
log(`wake-peer:failed err=${err instanceof Error ? err.message : String(err)}`);
|
|
21331
21581
|
});
|
|
@@ -21341,7 +21591,7 @@ var plugin = async (input) => {
|
|
|
21341
21591
|
}
|
|
21342
21592
|
if (event.type === "session.error") {
|
|
21343
21593
|
const props = event.properties;
|
|
21344
|
-
handleSessionErrorEvent(db, registry2, props.sessionID, props.error);
|
|
21594
|
+
handleSessionErrorEvent(db, registry2, client3, props.sessionID, props.error);
|
|
21345
21595
|
}
|
|
21346
21596
|
if (event.type === "message.part.updated") {
|
|
21347
21597
|
const part = event.properties.part;
|
|
@@ -21436,19 +21686,21 @@ var plugin = async (input) => {
|
|
|
21436
21686
|
}
|
|
21437
21687
|
}),
|
|
21438
21688
|
team_message: tool({
|
|
21439
|
-
description: "Send a message to a specific teammate or to the lead. Use 'lead' to message the team lead.",
|
|
21689
|
+
description: "Send a message to a specific teammate or to the lead. Use 'lead' to message the team lead. " + "Lead only: pass 'model' (provider/model) to update a teammate's model in-place — it applies on their next turn. " + "When updating a model, 'text' is optional; omit it to change the model without sending a message.",
|
|
21440
21690
|
args: {
|
|
21441
21691
|
to: tool.schema.string().describe("Recipient name ('lead' or teammate name)"),
|
|
21442
|
-
text: tool.schema.string().describe("Message content (max 10KB)"),
|
|
21692
|
+
text: tool.schema.string().optional().describe("Message content (max 10KB). Optional only when 'model' is provided."),
|
|
21443
21693
|
approve: tool.schema.boolean().optional().describe("Approve a teammate's plan (only when recipient has plan_approval='pending')"),
|
|
21444
|
-
reject: tool.schema.string().optional().describe("Reject a teammate's plan with reason (only when recipient has plan_approval='pending')")
|
|
21694
|
+
reject: tool.schema.string().optional().describe("Reject a teammate's plan with reason (only when recipient has plan_approval='pending')"),
|
|
21695
|
+
force: tool.schema.boolean().optional().describe("Lead only. Re-activate a teammate who already reported task completion (normally their session won't be woken again). Use for legitimate follow-on work — e.g. the next round of a multi-round debate — not for courtesy replies."),
|
|
21696
|
+
model: tool.schema.string().optional().describe("Lead only: update the recipient teammate's model in-place, in 'provider/model' format (e.g. 'anthropic/claude-sonnet').")
|
|
21445
21697
|
},
|
|
21446
21698
|
async execute(args, ctx) {
|
|
21447
21699
|
const result = await executeTeamMessage(deps, args, ctx.sessionID);
|
|
21448
21700
|
progressTracker.recordMessage(ctx.sessionID);
|
|
21449
21701
|
if (args.to !== "lead")
|
|
21450
21702
|
progressTracker.recordPeerMessage(ctx.sessionID);
|
|
21451
|
-
ctx.metadata({ title: `Message → ${args.to}` });
|
|
21703
|
+
ctx.metadata({ title: args.model ? `Set ${args.to} model → ${args.model}` : `Message → ${args.to}` });
|
|
21452
21704
|
return result;
|
|
21453
21705
|
}
|
|
21454
21706
|
}),
|
|
@@ -21460,6 +21712,7 @@ var plugin = async (input) => {
|
|
|
21460
21712
|
async execute(args, ctx) {
|
|
21461
21713
|
const result = await executeTeamBroadcast(deps, args, ctx.sessionID);
|
|
21462
21714
|
progressTracker.recordMessage(ctx.sessionID);
|
|
21715
|
+
progressTracker.recordPeerMessage(ctx.sessionID);
|
|
21463
21716
|
ctx.metadata({ title: "Broadcast to team" });
|
|
21464
21717
|
return result;
|
|
21465
21718
|
}
|