@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 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 recoverStaleMembers(db, client3, cwd) {
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 client3.session.abort({ sessionID: member.session_id });
17835
- } catch {}
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
- sendMessage(db, {
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
- isTimeStalled(sessionId, thresholdMs) {
18697
+ recordBusyStart(sessionId) {
18698
+ this.busySince.set(sessionId, Date.now());
18699
+ }
18700
+ lastActivityAt(sessionId) {
18602
18701
  const records = this.steps.get(sessionId);
18603
- if (!records || records.length === 0)
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 = info.time ? new Date(info.time).getTime() : Date.now();
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
- throw new Error(`Team "${args.name}" already exists`);
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 = args.agent === "plan" || args.agent === "explore";
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=${args.agent} worktree=${useWorktree}`);
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} (@${args.agent} teammate)`,
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, args.agent, memberCount, deps.config);
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, args.agent, resolvedModel ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]);
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 "${args.agent}".`
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 (args.claim_task) {
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: args.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
- sendMessage(deps.db, {
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: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`;
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: args.text
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 = args.text;
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 task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?").get(args.task_id, teamInfo.teamId);
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
- deps.db.run("UPDATE team_task SET status = 'completed', time_updated = ? WHERE id = ?", [now, args.task_id]);
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
- if (last !== undefined && now - last < RATE_LIMIT_MS) {
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
- const members = deps.db.query("SELECT name, session_id, agent, status, execution_status, worktree_branch, worktree_dir, plan_approval, time_updated FROM team_member WHERE team_id = ? ORDER BY time_created ASC").all(teamInfo.teamId);
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.progressTracker.markReported(member.session_id);
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
- }).catch(() => {});
21063
- sendMessage(this.db, {
21064
- teamId: member.team_id,
21065
- from: "system",
21066
- to: "lead",
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
- }).catch(() => {});
21096
- sendMessage(this.db, {
21097
- teamId: member.team_id,
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 transition = handleSessionStatusEvent(db, registry2, sessionID, statusType);
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
- sendMessage(db, {
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
- }).catch(() => {});
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
  }