@hueyexe/opencode-ensemble 0.16.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.16.1",
3
+ "version": "0.17.0",
4
4
  "description": "Agent teams for OpenCode — parallel agents with peer-to-peer communication, shared tasks, and coordinated execution",
5
5
  "module": "src/index.ts",
6
6
  "main": "dist/index.js",
@@ -81,7 +81,7 @@ function rAgents(t){
81
81
  '<div class="flex items-center gap-2">'+
82
82
  '<span class="w-[8px] h-[8px] rounded-full '+s.d+(m.status==='busy'?' pulse':'')+' shrink-0"></span>'+
83
83
  '<span class="font-mono font-semibold text-[14px] truncate">'+E(m.name)+'</span>'+
84
- '<span class="text-[10px] px-1.5 py-[1px] rounded '+s.t+' bg-base-800/80 shrink-0">'+s.l+'</span>'+
84
+ '<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>'+
85
85
  spark+
86
86
  '<span class="text-[10px] text-txt-500 ml-auto shrink-0">'+E(m.agent)+'</span>'+
87
87
  (m.model?chip(E(m.model),'muted'):'')+
@@ -90,6 +90,7 @@ function rAgents(t){
90
90
  (mp?'<div class="mt-1 text-[12px] text-txt-400 truncate">Latest: '+E(mp)+'</div>':'')+
91
91
  '<div class="mt-2 flex items-center gap-1.5 flex-wrap">'+
92
92
  chip('status '+d,'muted')+chip('msg '+mi,'muted')+chip(E(m.executionStatus||m.status),m.status==='busy'?'blue':m.status==='error'?'red':'muted')+
93
+ (m.isRetrying?chip('retrying'+(m.retryAttempt!=null?' (attempt '+m.retryAttempt+')':''),'amber'):'')+
93
94
  (m.worktreeBranch?chip(E(m.worktreeBranch),'muted'):'')+
94
95
  '</div></button>';
95
96
  }).join('');
@@ -113,6 +114,8 @@ function openDrawer(name){
113
114
  meta.push(chip(E(m.executionStatus||m.status),m.status==='busy'?'blue':m.status==='error'?'red':'muted'));
114
115
  if(m.planApproval&&m.planApproval!=='none')meta.push(chip(E(m.planApproval),m.planApproval==='approved'?'green':m.planApproval==='rejected'?'red':'amber'));
115
116
  meta.push(chip('spawned '+relT(m.timeCreated),'muted'));
117
+ if(m.lastNudgedAt)meta.push(chip('nudged '+relT(m.lastNudgedAt),'amber'));
118
+ if(m.isRetrying)meta.push(chip('retrying'+(m.retryAttempt!=null?' (attempt '+m.retryAttempt+')':'')+(m.retryMessage?': '+E(m.retryMessage):''),'amber'));
116
119
  if(m.worktreeBranch)meta.push(chip(E(m.worktreeBranch),'muted'));
117
120
  h+='<div class="flex flex-wrap gap-1.5 mb-4 pb-4 border-b border-base-800/50">'+meta.join('')+'</div>';
118
121
  // Prompt
@@ -180,7 +183,7 @@ function rDrawerActivityUpdate(){
180
183
  el.innerHTML='<div class="text-txt-500 text-[12px]">No activity recorded</div>';
181
184
  return;
182
185
  }
183
- var html=drawerActivity.map(function(a){
186
+ var html=drawerActivity.slice().reverse().map(function(a){
184
187
  var icon='\\u25CF',color='text-txt-500',label=a.type;
185
188
  if(a.type==='tool_call'){icon='\\u25B8';color='text-blue-400';label=a.tool||'tool'}
186
189
  if(a.type==='tool_result'){icon='\\u25B8';color=a.error?'text-red-400':'text-emerald-400';label=a.tool||'tool'}
package/src/dashboard.ts CHANGED
@@ -16,6 +16,7 @@ interface TeamRow {
16
16
  id: string
17
17
  name: string
18
18
  project_id: string
19
+ lead_session_id: string
19
20
  status: string
20
21
  lead_agent: string | null
21
22
  time_created: number
@@ -43,6 +44,11 @@ interface MemberRow {
43
44
  plan_approval: string
44
45
  time_created: number
45
46
  time_updated: number
47
+ last_nudged_at: number | null
48
+ retry_until: number | null
49
+ retry_attempt: number | null
50
+ retry_provider: string | null
51
+ retry_message: string | null
46
52
  }
47
53
 
48
54
  interface TaskRow {
@@ -81,10 +87,31 @@ function parseDependsOn(value: string | null): string[] {
81
87
  return []
82
88
  }
83
89
 
84
- function buildState(db: Database): { projects: unknown[]; teams: unknown[] } {
90
+ /**
91
+ * Bare-integer version of the `/api/state` response shape. Bump on any
92
+ * breaking change to the payload (field removed/renamed/retyped, not
93
+ * additive fields). Consumers outside this repo compare this field with
94
+ * strict equality — no semver ranges. Currently consumed externally by the
95
+ * OpenCode sidebar TUI plugin that renders Team status from this endpoint.
96
+ */
97
+ export const ENSEMBLE_STATE_VERSION = 1
98
+
99
+ /**
100
+ * `/api/state` response shape. Exported and versioned because at least one
101
+ * consumer outside this repo (an OpenCode sidebar TUI plugin) polls this
102
+ * endpoint and depends on its shape. Treat additive-only changes as safe;
103
+ * bump {@link ENSEMBLE_STATE_VERSION} for anything else.
104
+ */
105
+ export interface EnsembleDashboardState {
106
+ version: number
107
+ projects: unknown[]
108
+ teams: unknown[]
109
+ }
110
+
111
+ function buildState(db: Database): EnsembleDashboardState {
85
112
  const projects = db.query("SELECT id, name, path, status, time_created, time_updated FROM project ORDER BY time_updated DESC").all() as ProjectRow[]
86
- const teams = db.query("SELECT id, name, project_id, status, lead_agent, time_created, time_updated FROM team ORDER BY time_created DESC").all() as TeamRow[]
87
- 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 = ?")
113
+ 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() as TeamRow[]
114
+ 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 = ?")
88
115
  const taskStmt = db.query("SELECT id, content, status, priority, assignee, depends_on, time_created, time_updated FROM team_task WHERE team_id = ?")
89
116
  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")
90
117
 
@@ -101,11 +128,21 @@ function buildState(db: Database): { projects: unknown[]; teams: unknown[] } {
101
128
  planApproval: m.plan_approval,
102
129
  timeCreated: m.time_created,
103
130
  timeUpdated: m.time_updated,
131
+ lastNudgedAt: m.last_nudged_at,
132
+ // Fix 4: derived, read-time TTL boolean (Fix 3) — never a stored enum.
133
+ // Additive fields; existing consumers that don't know about them simply
134
+ // don't render them.
135
+ isRetrying: m.retry_until !== null && m.retry_until > Date.now(),
136
+ retryUntil: m.retry_until,
137
+ retryAttempt: m.retry_attempt,
138
+ retryProvider: m.retry_provider,
139
+ retryMessage: m.retry_message,
104
140
  }))
105
141
  return {
106
142
  id: t.id,
107
143
  name: t.name,
108
144
  projectId: t.project_id,
145
+ leadSessionId: t.lead_session_id,
109
146
  status: t.status,
110
147
  leadAgent: t.lead_agent,
111
148
  timeCreated: t.time_created,
@@ -140,6 +177,7 @@ function buildState(db: Database): { projects: unknown[]; teams: unknown[] } {
140
177
  })
141
178
 
142
179
  return {
180
+ version: ENSEMBLE_STATE_VERSION,
143
181
  projects: projects.flatMap(project => {
144
182
  const projectTeams = teamsByProject.get(project.id) ?? []
145
183
  if (projectTeams.length === 0) return []
@@ -183,11 +221,30 @@ function sendJson(res: ServerResponse, data: unknown): void {
183
221
  res.end(JSON.stringify(data))
184
222
  }
185
223
 
224
+ /**
225
+ * Resolve a message's creation timestamp (Unix ms) from an SDK message's
226
+ * `info.time`. The SDK shape is an object `{ created: number }`; older/other
227
+ * shapes (a numeric epoch or an ISO string) are tolerated. Falls back to
228
+ * `Date.now()` when the value is missing or unparseable — never returns NaN.
229
+ */
230
+ export function parseMessageTime(time: unknown): number {
231
+ if (typeof time === "object" && time !== null) {
232
+ const created = (time as { created?: unknown }).created
233
+ if (typeof created === "number" && Number.isFinite(created)) return created
234
+ }
235
+ if (typeof time === "number" && Number.isFinite(time)) return time
236
+ if (typeof time === "string") {
237
+ const ms = new Date(time).getTime()
238
+ if (!Number.isNaN(ms)) return ms
239
+ }
240
+ return Date.now()
241
+ }
242
+
186
243
  /** Parse SDK message parts into ActivityEntry format for the fallback path. */
187
244
  export function parseMessageParts(parts: unknown[], msgInfo: unknown): ActivityEntry[] {
188
245
  const entries: ActivityEntry[] = []
189
- const info = (msgInfo ?? {}) as { time?: string; role?: string; tokens?: { input?: number; output?: number } }
190
- const timestamp = info.time ? new Date(info.time).getTime() : Date.now()
246
+ const info = (msgInfo ?? {}) as { time?: unknown; role?: string; tokens?: { input?: number; output?: number } }
247
+ const timestamp = parseMessageTime(info.time)
191
248
 
192
249
  for (const part of parts) {
193
250
  if (typeof part !== "object" || part === null) continue
package/src/hooks.ts CHANGED
@@ -1,10 +1,38 @@
1
1
  import type { Database } from "./db"
2
2
  import type { MemberRegistry, DescendantTracker } from "./state"
3
- import { sendMessage } from "./messaging"
3
+ import type { PluginClient } from "./types"
4
+ import { notifyLead } from "./notify"
5
+ import { releaseMemberTasks } from "./tasks"
4
6
  import { findTeamBySession } from "./types"
5
7
 
6
8
  const TEAM_TOOL_PREFIX = "team_"
7
9
 
10
+ /**
11
+ * Retry-specific payload carried by a `session.status` event when
12
+ * `status === "retry"`. Mirrors the SDK's `SessionStatus` retry variant.
13
+ * `action` is optional and free-form (not rate-limit-specific) — see Fix 2
14
+ * in the retry-observability spec.
15
+ */
16
+ export interface RetryStatusPayload {
17
+ attempt: number
18
+ message: string
19
+ action?: {
20
+ reason: string
21
+ provider: string
22
+ title: string
23
+ message: string
24
+ label: string
25
+ link?: string
26
+ }
27
+ /**
28
+ * Absolute epoch-ms timestamp of the next retry attempt — NOT a relative
29
+ * delta. Confirmed against the opencode server/TUI source (session.retry.scheduled
30
+ * emits `{attempt, at, error}` mapped straight onto `next`, and the built-in
31
+ * TUI computes remaining seconds as `Math.round((next - Date.now()) / 1000)`).
32
+ */
33
+ next: number
34
+ }
35
+
8
36
  /** Result of a session status event — tells the caller what transition happened. */
9
37
  export interface StatusTransition {
10
38
  memberName: string
@@ -18,12 +46,17 @@ export interface StatusTransition {
18
46
  * in SQLite based on the new session status.
19
47
  * Ignores events for unknown sessions or archived teams.
20
48
  * Returns the transition if one occurred, for toast notifications.
49
+ *
50
+ * `retryPayload` is only consulted when `status === "retry"` — it persists
51
+ * the four additive retry_* columns (Fix 2) without changing `status`/
52
+ * `execution_status` at all (Fix 1's non-goal: no FSM change).
21
53
  */
22
54
  export function handleSessionStatusEvent(
23
55
  db: Database,
24
56
  registry: MemberRegistry,
25
57
  sessionId: string,
26
58
  status: "idle" | "busy" | "retry",
59
+ retryPayload?: RetryStatusPayload,
27
60
  ): StatusTransition | undefined {
28
61
  const entry = registry.getBySession(sessionId)
29
62
  if (!entry) return undefined
@@ -43,6 +76,11 @@ export function handleSessionStatusEvent(
43
76
  "UPDATE team_member SET status = ?, execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?",
44
77
  [newStatus, Date.now(), entry.teamId, entry.memberName]
45
78
  )
79
+ // A gracefully shut-down member has actually stopped now — release any
80
+ // tasks they left in_progress back to the pool so nothing is stranded (issue #27).
81
+ if (newStatus === "shutdown") {
82
+ releaseMemberTasks(db, entry.teamId, entry.memberName)
83
+ }
46
84
  // Mark teammate as having reported if they sent at least one message to lead (issue #3).
47
85
  // Set on busy→ready transition so Q&A messages during work don't prematurely block delivery.
48
86
  if (member.status === "busy" && newStatus === "ready") {
@@ -67,12 +105,46 @@ export function handleSessionStatusEvent(
67
105
  )
68
106
  return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: "busy" }
69
107
  }
108
+ // Freshly-spawned member: inserted as busy/starting before the session's
109
+ // first busy event. Advance execution_status to running and refresh
110
+ // time_updated so the dashboard shows accurate progress instead of a
111
+ // frozen "starting" state (do NOT reset reported_to_lead — status is
112
+ // already busy, so this is not a re-activation).
113
+ if (member.status === "busy" && member.execution_status === "starting") {
114
+ db.run(
115
+ "UPDATE team_member SET execution_status = 'running', time_updated = ? WHERE team_id = ? AND name = ?",
116
+ [Date.now(), entry.teamId, entry.memberName]
117
+ )
118
+ return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: "busy" }
119
+ }
70
120
  // Session went busy while shutdown was requested — signal for re-abort
71
121
  if (member.status === "shutdown_requested") {
72
122
  return { memberName: entry.memberName, teamId: entry.teamId, from: "shutdown_requested", to: "busy_while_shutdown" }
73
123
  }
74
124
  } else if (status === "retry") {
75
- // Session is being rate-limited — signal for toast but don't change state
125
+ // Session is being rate-limited — signal for toast but don't change state.
126
+ // Persist the retry_* columns so the signal survives past the toast (Fix 2)
127
+ // — status/execution_status are untouched, and there is no "provider
128
+ // throttled" language baked in here: the SDK's retry status is generic,
129
+ // so we surface whatever action.message/status.message actually says.
130
+ if (retryPayload) {
131
+ // Coerce before binding: older server versions may emit a partial retry
132
+ // payload, and an undefined bind throws synchronously inside this event
133
+ // handler, discarding the event's remaining processing.
134
+ const next = typeof retryPayload.next === "number" && Number.isFinite(retryPayload.next) ? retryPayload.next : null
135
+ const attempt = typeof retryPayload.attempt === "number" && Number.isFinite(retryPayload.attempt) ? retryPayload.attempt : null
136
+ db.run(
137
+ "UPDATE team_member SET retry_until = ?, retry_attempt = ?, retry_provider = ?, retry_message = ? WHERE team_id = ? AND name = ?",
138
+ [
139
+ next,
140
+ attempt,
141
+ retryPayload.action?.provider ?? null,
142
+ retryPayload.action?.message ?? retryPayload.message ?? null,
143
+ entry.teamId,
144
+ entry.memberName,
145
+ ]
146
+ )
147
+ }
76
148
  return { memberName: entry.memberName, teamId: entry.teamId, from: member.status, to: "retry" }
77
149
  }
78
150
  return undefined
@@ -169,6 +241,11 @@ export interface SessionErrorPayload {
169
241
  * Handle a session.error event. Surfaces tool/model failures from a teammate
170
242
  * as a system message to the lead, so otherwise-silent failures are visible.
171
243
  *
244
+ * The lead is actively woken via notifyLead so the message is delivered even
245
+ * when the errored teammate was the last busy member (the "all done" gate in
246
+ * the passive wake path would otherwise strand the message). The teammate
247
+ * itself is NOT auto-resumed — recovery is left to the lead's judgement.
248
+ *
172
249
  * Ignored when:
173
250
  * - sessionID is undefined
174
251
  * - the session is not a registered teammate (leads are not in the registry)
@@ -176,6 +253,7 @@ export interface SessionErrorPayload {
176
253
  export function handleSessionErrorEvent(
177
254
  db: Database,
178
255
  registry: MemberRegistry,
256
+ client: PluginClient,
179
257
  sessionId: string | undefined,
180
258
  error: SessionErrorPayload | undefined,
181
259
  ): void {
@@ -187,10 +265,10 @@ export function handleSessionErrorEvent(
187
265
  if (!teamInfo || teamInfo.role !== "member" || !teamInfo.memberName) return
188
266
 
189
267
  const errMsg = error?.data?.message ?? error?.name ?? "unknown error"
190
- sendMessage(db, {
191
- teamId: teamInfo.teamId,
192
- from: "system",
193
- to: "lead",
194
- content: `Teammate "${teamInfo.memberName}" had a session error: ${errMsg}. Check their session for details. They may be stuck and need investigation or shutdown.`,
195
- })
268
+ notifyLead(
269
+ client,
270
+ db,
271
+ teamInfo.teamId,
272
+ `Teammate "${teamInfo.memberName}" had a session error: ${errMsg}. Check their session for details. They may be stuck and need investigation or shutdown.`,
273
+ )
196
274
  }
package/src/index.ts CHANGED
@@ -5,12 +5,13 @@ import path from "node:path"
5
5
  import { mkdirSync } from "node:fs"
6
6
  import { createDb, getDbPath } from "./db"
7
7
  import { wrapThrowingClient } from "./client"
8
- import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktrees, recoverOrphanedBranches, rehydrateRegistry } from "./recovery"
8
+ import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktrees, recoverOrphanedBranches, recoverOrphanedTeams, rehydrateRegistry } from "./recovery"
9
9
  import { MemberRegistry, DescendantTracker, PendingPurgeApprovals } from "./state"
10
10
  import { isWorktreeInstance } from "./util"
11
11
  import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation, shouldNudgeIdleMember, handleSessionErrorEvent } from "./hooks"
12
- import { notifyTeamEvent, notifyWorkingProgress } from "./notify"
13
- import { sendMessage, hasReportedCompletion } from "./messaging"
12
+ import { notifyTeamEvent, notifyWorkingProgress, notifyLead } from "./notify"
13
+ import { hasReportedCompletion } from "./messaging"
14
+ import { getMemberModel } from "./member-model"
14
15
  import { buildLeadSystemPrompt, buildTeammateSystemPrompt, buildTeamCompactionContext } from "./system-prompt"
15
16
  import { log, initLog } from "./log"
16
17
  import { findTeamBySession } from "./types"
@@ -72,13 +73,35 @@ const plugin: Plugin = async (input) => {
72
73
  const rawClient = new OpencodeClient({ client: pluginTransport })
73
74
  initLog(rawClient)
74
75
  const client = wrapThrowingClient(rawClient)
75
- const deps: ToolDeps = { db, registry, tracker, purgeApprovals, client, directory: input.directory, config }
76
+ const deps: ToolDeps = { db, registry, tracker, purgeApprovals, client, directory: input.directory, config, progressTracker }
76
77
 
77
78
  // Recovery only runs for the main project instance — NOT for teammate worktree instances.
78
79
  // Worktree instances are created during session.create. Running recovery there makes HTTP
79
80
  // calls back to the server, which deadlocks because the server is still handling session.create.
80
81
  if (!isWorktreeInstance(input.directory)) {
81
82
  log("init:recovery:start (main instance)")
83
+
84
+ // Reconciles teams whose lead session was deleted externally (not via
85
+ // team_cleanup) -- otherwise they stay 'active' forever, blocking
86
+ // team_create/team_cleanup for that name across restarts. See
87
+ // recoverOrphanedTeams' own doc comment for the full mechanism.
88
+ //
89
+ // Fire-and-forget, NOT awaited: isSessionAlive() calls client.session.get(),
90
+ // an HTTP call back to this same server. Awaiting it synchronously here --
91
+ // before the server has finished bootstrapping -- reproduced a real,
92
+ // confirmed deadlock (server never responds to ANY request, including
93
+ // unrelated ones like /config) when a stale team from a prior run exists
94
+ // for this project. Matches the existing pattern for the other three
95
+ // non-critical recovery passes below, and the documented reason
96
+ // recoverStaleMembers is skipped entirely for worktree instances two lines
97
+ // up ("makes HTTP calls back to the server, which deadlocks"). isSessionAlive
98
+ // itself also carries a bounded timeout as defense in depth.
99
+ recoverOrphanedTeams(db, client, input.directory, registry).then((result) => {
100
+ if (result.archived > 0) log(`init:recovery:orphaned-teams-archived=${result.archived}`)
101
+ }).catch((err) => {
102
+ log(`init:recover-orphaned-teams:failed err=${err instanceof Error ? err.message : String(err)}`)
103
+ })
104
+
82
105
  const recovery = await recoverStaleMembers(db, client, input.directory)
83
106
  if (recovery.interrupted > 0) {
84
107
  log(`init:recovery:interrupted=${recovery.interrupted}`)
@@ -142,7 +165,10 @@ const plugin: Plugin = async (input) => {
142
165
  if (event.type === "session.status") {
143
166
  const { sessionID, status } = event.properties
144
167
  const statusType = status.type as "idle" | "busy" | "retry"
145
- const transition = handleSessionStatusEvent(db, registry, sessionID, statusType)
168
+ const retryPayload = statusType === "retry"
169
+ ? (status as { attempt: number; message: string; action?: { reason: string; provider: string; title: string; message: string; label: string; link?: string }; next: number })
170
+ : undefined
171
+ const transition = handleSessionStatusEvent(db, registry, sessionID, statusType, retryPayload)
146
172
 
147
173
  // Fire toast notifications for meaningful transitions
148
174
  if (transition) {
@@ -167,12 +193,12 @@ const plugin: Plugin = async (input) => {
167
193
  nudgedMembers.add(fastIdleKey)
168
194
  const modelInfo = memberInfo.model ? ` (model: ${memberInfo.model})` : ""
169
195
  log(`fast-idle: ${transition.memberName} went idle ${Math.round(spawnAge / 1000)}s after spawn with 0 messages${modelInfo}`)
170
- sendMessage(db, {
171
- teamId: transition.teamId,
172
- from: "system",
173
- to: "lead",
174
- 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.`,
175
- })
196
+ notifyLead(
197
+ client,
198
+ db,
199
+ transition.teamId,
200
+ `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.`,
201
+ )
176
202
  client.tui.showToast({
177
203
  title: "Team",
178
204
  message: `${transition.memberName} failed to produce output${modelInfo}`,
@@ -189,13 +215,23 @@ const plugin: Plugin = async (input) => {
189
215
  if (!nudgedMembers.has(nudgeKey) && shouldNudgeIdleMember(db, transition.teamId, transition.memberName) && !hasReportedCompletion(db, transition.teamId, transition.memberName)) {
190
216
  nudgedMembers.add(nudgeKey)
191
217
  log(`nudge:idle-without-report name=${transition.memberName}`)
218
+ const nudgeModel = getMemberModel(db, transition.teamId, transition.memberName)
192
219
  client.session.promptAsync({
193
220
  sessionID,
194
221
  parts: [{ type: "text", text: "[System]: You completed your work but did not report results. Send your findings to the lead via team_message now." }],
195
- }).catch(() => { /* best effort */ })
222
+ ...(nudgeModel ? { model: nudgeModel } : {}),
223
+ }).catch((err) => {
224
+ log(`nudge:idle-without-report:failed name=${transition.memberName} team=${transition.teamId} err=${err instanceof Error ? err.message : String(err)}`)
225
+ })
196
226
  }
197
227
  } else if (transition.to === "error") {
198
228
  notifyTeamEvent(client, "error", { memberName: transition.memberName })
229
+ } else if (transition.to === "busy") {
230
+ // Fresh busy period (ready/error -> busy) — give checkStalled a baseline
231
+ // that doesn't depend on a step-finish event having landed yet. Without
232
+ // this, a member whose first action is one long-running tool call is
233
+ // never detected as stalled until that call itself returns.
234
+ progressTracker.recordBusyStart(sessionID)
199
235
  } else if (transition.to === "retry") {
200
236
  // Teammate is being rate-limited — notify user
201
237
  try {
@@ -270,9 +306,11 @@ const plugin: Plugin = async (input) => {
270
306
  ).get(member.team_id, member.name, staleThreshold) as { c: number }
271
307
  if (peerMsgs.c > 0) {
272
308
  log(`wake-peer: ${member.name} has ${peerMsgs.c} pending peer messages`)
309
+ const peerModel = getMemberModel(db, member.team_id, member.name)
273
310
  client.session.promptAsync({
274
311
  sessionID,
275
312
  parts: [{ type: "text", text: `[System: ${peerMsgs.c} new message(s) from teammates]` }],
313
+ ...(peerModel ? { model: peerModel } : {}),
276
314
  }).catch((err) => {
277
315
  log(`wake-peer:failed err=${err instanceof Error ? err.message : String(err)}`)
278
316
  })
@@ -294,7 +332,7 @@ const plugin: Plugin = async (input) => {
294
332
  // teammate just appears stuck.
295
333
  if (event.type === "session.error") {
296
334
  const props = event.properties as { sessionID?: string; error?: { name?: string; data?: { message?: string } } }
297
- handleSessionErrorEvent(db, registry, props.sessionID, props.error)
335
+ handleSessionErrorEvent(db, registry, client, props.sessionID, props.error)
298
336
  }
299
337
 
300
338
  // Track per-step output tokens for stall detection
@@ -415,12 +453,16 @@ const plugin: Plugin = async (input) => {
415
453
  }),
416
454
 
417
455
  team_message: tool({
418
- description: "Send a message to a specific teammate or to the lead. Use 'lead' to message the team lead.",
456
+ description: "Send a message to a specific teammate or to the lead. Use 'lead' to message the team lead. " +
457
+ "Lead only: pass 'model' (provider/model) to update a teammate's model in-place — it applies on their next turn. " +
458
+ "When updating a model, 'text' is optional; omit it to change the model without sending a message.",
419
459
  args: {
420
460
  to: tool.schema.string().describe("Recipient name ('lead' or teammate name)"),
421
- text: tool.schema.string().describe("Message content (max 10KB)"),
461
+ text: tool.schema.string().optional().describe("Message content (max 10KB). Optional only when 'model' is provided."),
422
462
  approve: tool.schema.boolean().optional().describe("Approve a teammate's plan (only when recipient has plan_approval='pending')"),
423
463
  reject: tool.schema.string().optional().describe("Reject a teammate's plan with reason (only when recipient has plan_approval='pending')"),
464
+ 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."),
465
+ 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')."),
424
466
  },
425
467
  async execute(args, ctx) {
426
468
  const result = await executeTeamMessage(deps, args, ctx.sessionID)
@@ -428,7 +470,7 @@ const plugin: Plugin = async (input) => {
428
470
  progressTracker.recordMessage(ctx.sessionID)
429
471
  // Track peer messages for chatty detection
430
472
  if (args.to !== "lead") progressTracker.recordPeerMessage(ctx.sessionID)
431
- ctx.metadata({ title: `Message → ${args.to}` })
473
+ ctx.metadata({ title: args.model ? `Set ${args.to} model → ${args.model}` : `Message → ${args.to}` })
432
474
  return result
433
475
  },
434
476
  }),
@@ -440,8 +482,13 @@ const plugin: Plugin = async (input) => {
440
482
  },
441
483
  async execute(args, ctx) {
442
484
  const result = await executeTeamBroadcast(deps, args, ctx.sessionID)
443
- // Track broadcast activity for stall detection
485
+ // Track broadcast activity for stall detection AND chatty detection --
486
+ // a broadcast reaches every teammate at once, arguably the most
487
+ // "chatty" action possible, and was previously invisible to
488
+ // checkChatty()'s peer-message rate limit (recordPeerMessage was never
489
+ // called here, only recordMessage).
444
490
  progressTracker.recordMessage(ctx.sessionID)
491
+ progressTracker.recordPeerMessage(ctx.sessionID)
445
492
  ctx.metadata({ title: "Broadcast to team" })
446
493
  return result
447
494
  },
@@ -0,0 +1,30 @@
1
+ import type { Database } from "./db"
2
+
3
+ /** A parsed provider/model pair, as accepted by session.promptAsync. */
4
+ export interface ParsedModel {
5
+ providerID: string
6
+ modelID: string
7
+ }
8
+
9
+ /**
10
+ * Parse a "provider/model" string into { providerID, modelID } for the SDK.
11
+ * Returns undefined if the string is not in "provider/model" form (empty
12
+ * provider or empty model). The model portion may itself contain slashes.
13
+ */
14
+ export function parseModelId(model: string): ParsedModel | undefined {
15
+ const slash = model.indexOf("/")
16
+ if (slash <= 0 || slash === model.length - 1) return undefined
17
+ return { providerID: model.slice(0, slash), modelID: model.slice(slash + 1) }
18
+ }
19
+
20
+ /**
21
+ * Read a team member's stored model and parse it for session.promptAsync.
22
+ * Returns undefined when the member is unknown, has no model set, or the
23
+ * stored value is malformed. Never throws — safe to call from delivery paths.
24
+ */
25
+ export function getMemberModel(db: Database, teamId: string, memberName: string): ParsedModel | undefined {
26
+ const row = db.query("SELECT model FROM team_member WHERE team_id = ? AND name = ?")
27
+ .get(teamId, memberName) as { model: string | null } | null
28
+ if (!row?.model) return undefined
29
+ return parseModelId(row.model)
30
+ }
package/src/notify.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { Database } from "./db"
2
2
  import type { PluginClient } from "./types"
3
+ import { sendMessage } from "./messaging"
4
+ import { log } from "./log"
3
5
 
4
6
  type TeamEventType = "spawn" | "message" | "completed" | "error" | "shutdown"
5
7
 
@@ -82,3 +84,45 @@ export async function notifyWorkingProgress(
82
84
  // TUI may not be available — silently ignore
83
85
  }
84
86
  }
87
+
88
+ /**
89
+ * Persist a system-generated message to the lead AND actively wake the lead
90
+ * so the message is delivered on the lead's next turn.
91
+ *
92
+ * This is the delivery path for system events (teammate errors, timeouts,
93
+ * stalls, spawn failures). Unlike the passive wake-lead path in the event
94
+ * hook, this ALWAYS fires a promptAsync regardless of whether teammates are
95
+ * still busy — an errored/aborted teammate is precisely the case where the
96
+ * "all teammates done" gate would otherwise suppress the wake and strand the
97
+ * message in the DB with delivered=0.
98
+ *
99
+ * The message is persisted first; the promptAsync wake is fire-and-forget
100
+ * (never awaited, per the plugin's promptAsync invariant). If the wake fails,
101
+ * the message is still in the DB and the passive backstop / recovery paths
102
+ * remain available.
103
+ *
104
+ * Returns the persisted message id.
105
+ */
106
+ export function notifyLead(
107
+ client: PluginClient,
108
+ db: Database,
109
+ teamId: string,
110
+ content: string,
111
+ ): string {
112
+ const id = sendMessage(db, { teamId, from: "system", to: "lead", content })
113
+
114
+ const team = db.query("SELECT lead_session_id FROM team WHERE id = ?")
115
+ .get(teamId) as { lead_session_id: string } | null
116
+ if (!team?.lead_session_id) return id
117
+
118
+ // Fire-and-forget wake — the system prompt transform delivers the actual
119
+ // content on the lead's next turn.
120
+ client.session.promptAsync({
121
+ sessionID: team.lead_session_id,
122
+ parts: [{ type: "text", text: "[System: New team message from system]" }],
123
+ }).catch((err) => {
124
+ log(`notifyLead:wake:failed team=${teamId} err=${err instanceof Error ? err.message : String(err)}`)
125
+ })
126
+
127
+ return id
128
+ }
package/src/progress.ts CHANGED
@@ -12,6 +12,7 @@ export class ProgressTracker {
12
12
  private steps = new Map<string, StepRecord[]>()
13
13
  private lastMessageAt = new Map<string, number>()
14
14
  private lastTaskAt = new Map<string, number>()
15
+ private busySince = new Map<string, number>()
15
16
  private peerMessages = new Map<string, number[]>()
16
17
  private reported = new Set<string>()
17
18
  private chattyReported = new Set<string>()
@@ -83,15 +84,32 @@ export class ProgressTracker {
83
84
  return recent.every(r => r.outputTokens < threshold)
84
85
  }
85
86
 
86
- /** Time-based stall: no message or task completion within `thresholdMs` of now. */
87
- isTimeStalled(sessionId: string, thresholdMs: number): boolean {
87
+ /**
88
+ * Record that this member transitioned to busy. Gives isTimeStalled a baseline
89
+ * independent of step records, so a member whose first (or only) action is one
90
+ * long-running tool call is still subject to time-based stall detection before
91
+ * any step-finish event has landed.
92
+ */
93
+ recordBusyStart(sessionId: string): void {
94
+ this.busySince.set(sessionId, Date.now())
95
+ }
96
+
97
+ /** Latest activity timestamp across all tracked signals (0 when nothing recorded). */
98
+ lastActivityAt(sessionId: string): number {
88
99
  const records = this.steps.get(sessionId)
89
- if (!records || records.length === 0) return false
100
+ const lastStepAt = records && records.length > 0 ? records[records.length - 1]!.timestamp : 0
101
+ return Math.max(
102
+ this.lastMessageAt.get(sessionId) ?? 0,
103
+ this.lastTaskAt.get(sessionId) ?? 0,
104
+ lastStepAt,
105
+ this.busySince.get(sessionId) ?? 0,
106
+ )
107
+ }
90
108
 
91
- const msgAt = this.lastMessageAt.get(sessionId) ?? 0
92
- const taskAt = this.lastTaskAt.get(sessionId) ?? 0
93
- const lastStep = records[records.length - 1]?.timestamp ?? 0
94
- const baseline = Math.max(msgAt, taskAt, lastStep)
109
+ /** Time-based stall: no message, task completion, step, or busy-transition within `thresholdMs` of now. */
110
+ isTimeStalled(sessionId: string, thresholdMs: number): boolean {
111
+ const baseline = this.lastActivityAt(sessionId)
112
+ if (baseline === 0) return false // no activity or busy signal recorded at all yet
95
113
 
96
114
  return Date.now() - baseline >= thresholdMs
97
115
  }
@@ -116,6 +134,7 @@ export class ProgressTracker {
116
134
  this.steps.delete(sessionId)
117
135
  this.lastMessageAt.delete(sessionId)
118
136
  this.lastTaskAt.delete(sessionId)
137
+ this.busySince.delete(sessionId)
119
138
  this.peerMessages.delete(sessionId)
120
139
  this.reported.delete(sessionId)
121
140
  this.chattyReported.delete(sessionId)