@hueyexe/opencode-ensemble 0.18.0 → 0.21.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/src/messaging.ts CHANGED
@@ -62,11 +62,13 @@ export function broadcastMessage(db: Database, input: BroadcastMessageInput): st
62
62
  }
63
63
 
64
64
  /**
65
- * Get all undelivered messages for a team.
65
+ * Get all undelivered messages for a team. Messages already read via
66
+ * team_results are excluded — re-injecting or re-sending them would duplicate
67
+ * content the recipient has already seen.
66
68
  */
67
69
  export function getUndeliveredMessages(db: Database, teamId: string): MessageRow[] {
68
70
  return db.query(
69
- "SELECT * FROM team_message WHERE team_id = ? AND delivered = 0 ORDER BY time_created ASC"
71
+ "SELECT * FROM team_message WHERE team_id = ? AND read = 0 AND delivered = 0 ORDER BY time_created ASC"
70
72
  ).all(teamId) as MessageRow[]
71
73
  }
72
74
 
package/src/notify.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Database } from "./db"
2
2
  import type { PluginClient } from "./types"
3
3
  import { sendMessage } from "./messaging"
4
+ import { wakeLeadIfPending } from "./wake"
4
5
  import { log } from "./log"
5
6
 
6
7
  type TeamEventType = "spawn" | "message" | "completed" | "error" | "shutdown"
@@ -90,16 +91,14 @@ export async function notifyWorkingProgress(
90
91
  * so the message is delivered on the lead's next turn.
91
92
  *
92
93
  * 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.
94
+ * stalls, spawn failures). The wake is forced past the busy check and the
95
+ * coalescing cooldown so an errored/aborted teammate is not stranded while
96
+ * the lead is working; the wake itself is still guarded against
97
+ * archived/purged teams (#48, #57).
98
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.
99
+ * The message is persisted first; the wake is fire-and-forget (never awaited,
100
+ * per the plugin's promptAsync invariant). If the wake fails, the message is
101
+ * still in the DB and the passive backstop / recovery paths remain available.
103
102
  *
104
103
  * Returns the persisted message id.
105
104
  */
@@ -111,18 +110,10 @@ export function notifyLead(
111
110
  ): string {
112
111
  const id = sendMessage(db, { teamId, from: "system", to: "lead", content })
113
112
 
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
113
  // Fire-and-forget wake — the system prompt transform delivers the actual
119
114
  // content on the lead's next turn. Synthetic: renders as a persistent
120
115
  // system entry, not a fake user turn.
121
- client.session.promptAsync({
122
- sessionID: team.lead_session_id,
123
- parts: [{ type: "text", text: "[System: New team message from system]" }],
124
- synthetic: true,
125
- }).catch((err) => {
116
+ wakeLeadIfPending(client, db, teamId, { force: true }).catch((err) => {
126
117
  log(`notifyLead:wake:failed team=${teamId} err=${err instanceof Error ? err.message : String(err)}`)
127
118
  })
128
119
 
package/src/recovery.ts CHANGED
@@ -192,20 +192,25 @@ export async function recoverOrphanedWorktrees(db: Database, client: PluginClien
192
192
  }
193
193
 
194
194
  /**
195
- * Redeliver undelivered messages (delivered=0) via promptAsync.
195
+ * Schedule redelivery of undelivered messages (delivered=0) via promptAsync.
196
196
  * Resolves recipient session IDs from the member registry or team lead.
197
- * Continues on partial failure — logs but doesn't abort.
197
+ *
198
+ * Delivery is fire-and-forget: promptAsync is never awaited (AGENTS.md
199
+ * invariant), so a slow or broken transport cannot block recovery/init.
200
+ * Messages are marked delivered only after promptAsync resolves; failures
201
+ * leave them undelivered for the next recovery pass. The returned count is
202
+ * the number of deliveries scheduled, not confirmed.
198
203
  */
199
204
  export async function recoverUndeliveredMessages(
200
205
  db: Database,
201
206
  client: PluginClient,
202
207
  registry: MemberRegistry,
203
- ): Promise<{ redelivered: number }> {
208
+ ): Promise<{ scheduled: number }> {
204
209
  // Get all active teams
205
210
  const teams = db.query("SELECT id, lead_session_id FROM team WHERE status = 'active'")
206
211
  .all() as Array<{ id: string; lead_session_id: string }>
207
212
 
208
- let redelivered = 0
213
+ let scheduled = 0
209
214
 
210
215
  for (const team of teams) {
211
216
  const messages = getUndeliveredMessages(db, team.id)
@@ -233,22 +238,24 @@ export async function recoverUndeliveredMessages(
233
238
  continue
234
239
  }
235
240
 
236
- try {
237
- const recipientModel = getMemberModel(db, team.id, msg.to_name)
238
- await client.session.promptAsync({
239
- sessionID: recipientSessionId,
240
- parts: [{ type: "text", text: `[Recovered team message from ${msg.from_name}]: ${msg.content}` }],
241
- ...(recipientModel ? { model: recipientModel } : {}),
242
- })
241
+ const recipientModel = getMemberModel(db, team.id, msg.to_name)
242
+ // Fire-and-forget, NOT awaited: recovery must not block on transport.
243
+ // Mark delivered only on success; failures stay undelivered for the
244
+ // next recovery pass.
245
+ client.session.promptAsync({
246
+ sessionID: recipientSessionId,
247
+ parts: [{ type: "text", text: `[Recovered team message from ${msg.from_name}]: ${msg.content}` }],
248
+ ...(recipientModel ? { model: recipientModel } : {}),
249
+ }).then(() => {
243
250
  markDelivered(db, msg.id)
244
- redelivered++
245
- } catch {
246
- // Continue on failure — message stays undelivered for next recovery
247
- }
251
+ }).catch((err) => {
252
+ log(`recovery:redeliver:failed team=${team.id} msg=${msg.id} err=${err instanceof Error ? err.message : String(err)}`)
253
+ })
254
+ scheduled++
248
255
  }
249
256
  }
250
257
 
251
- return { redelivered }
258
+ return { scheduled }
252
259
  }
253
260
 
254
261
  /**
package/src/schema.ts CHANGED
@@ -204,6 +204,10 @@ export const MIGRATIONS: string[] = [
204
204
  ALTER TABLE team_member ADD COLUMN retry_attempt INTEGER;
205
205
  ALTER TABLE team_member ADD COLUMN retry_provider TEXT;
206
206
  ALTER TABLE team_member ADD COLUMN retry_message TEXT;`,
207
+ // Migration 11: Persist the last lead-wake time on the team. Worktree
208
+ // teammate instances share the SQLite file but not module state, so an
209
+ // in-memory cooldown cannot coalesce wakes across instances (issue #57).
210
+ `ALTER TABLE team ADD COLUMN last_wake_at INTEGER NOT NULL DEFAULT 0;`,
207
211
  ]
208
212
 
209
213
  /**
@@ -49,54 +49,72 @@ export function buildLeadSystemPrompt(db: Database, teamId: string, config?: Req
49
49
  : "Teammates: none"
50
50
 
51
51
  const pendingMessages = db.query(
52
- "SELECT id, from_name, content FROM team_message WHERE team_id = ? AND to_name = 'lead' AND delivered = 0 ORDER BY time_created ASC"
52
+ "SELECT id, from_name, content FROM team_message WHERE team_id = ? AND to_name = 'lead' AND read = 0 AND delivered = 0 ORDER BY time_created ASC"
53
53
  ).all(teamId) as Array<{ id: string; from_name: string; content: string }>
54
54
 
55
- const lines = [
56
- `You are leading team "${team.name}" with ${members.length} active teammates.`,
57
- teammateLine,
58
- `Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`,
59
- ]
55
+ const teamStateMode = config?.systemPromptTeamState ?? "full"
56
+ const lines: string[] = []
57
+
58
+ if (teamStateMode === "off") {
59
+ // Stable role line only. Everything else in this block would churn on
60
+ // status/task/message changes and invalidate the provider prefix cache.
61
+ lines.push(`You are leading team "${team.name}".`)
62
+ } else {
63
+ lines.push(
64
+ `You are leading team "${team.name}" with ${members.length} active teammates.`,
65
+ teammateLine,
66
+ `Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`,
67
+ )
60
68
 
61
- // Inline active and recently completed tasks
62
- const activeTasks = db.query(
63
- "SELECT content, assignee FROM team_task WHERE team_id = ? AND status = 'in_progress' ORDER BY time_updated DESC LIMIT 5"
64
- ).all(teamId) as Array<{ content: string; assignee: string | null }>
69
+ // Inline active and recently completed tasks
70
+ const activeTasks = db.query(
71
+ "SELECT content, assignee FROM team_task WHERE team_id = ? AND status = 'in_progress' ORDER BY time_updated DESC LIMIT 5"
72
+ ).all(teamId) as Array<{ content: string; assignee: string | null }>
65
73
 
66
- const recentCompleted = db.query(
67
- "SELECT content, assignee FROM team_task WHERE team_id = ? AND status = 'completed' ORDER BY time_updated DESC LIMIT 3"
68
- ).all(teamId) as Array<{ content: string; assignee: string | null }>
74
+ const recentCompleted = db.query(
75
+ "SELECT content, assignee FROM team_task WHERE team_id = ? AND status = 'completed' ORDER BY time_updated DESC LIMIT 3"
76
+ ).all(teamId) as Array<{ content: string; assignee: string | null }>
69
77
 
70
- if (activeTasks.length > 0) {
71
- lines.push("Active tasks:")
72
- for (const t of activeTasks) {
73
- lines.push(` [in_progress] ${truncate(t.content, 120)}${t.assignee ? ` → ${t.assignee}` : ""}`)
78
+ if (activeTasks.length > 0) {
79
+ lines.push("Active tasks:")
80
+ for (const t of activeTasks) {
81
+ lines.push(` [in_progress] ${truncate(t.content, 120)}${t.assignee ? ` → ${t.assignee}` : ""}`)
82
+ }
74
83
  }
75
- }
76
- if (recentCompleted.length > 0) {
77
- lines.push("Recently completed:")
78
- for (const t of recentCompleted) {
79
- lines.push(` [completed] ${truncate(t.content, 120)}${t.assignee ? ` → ${t.assignee}` : ""}`)
84
+ if (recentCompleted.length > 0) {
85
+ lines.push("Recently completed:")
86
+ for (const t of recentCompleted) {
87
+ lines.push(` [completed] ${truncate(t.content, 120)}${t.assignee ? ` → ${t.assignee}` : ""}`)
88
+ }
80
89
  }
81
90
  }
82
91
 
83
92
  if (pendingMessages.length > 0) {
84
- lines.push("", "--- Team Messages ---")
85
- const MAX_MSG = 500
86
- for (const msg of pendingMessages) {
87
- const parsed = parseTaskResult(msg.content)
88
- if (parsed) {
89
- // Truncate details for system prompt — full content available via team_results
90
- const truncatedResult = { ...parsed, details: truncate(parsed.details, 500) }
91
- lines.push(formatTaskResult(msg.from_name, truncatedResult))
92
- } else if (msg.content.length > MAX_MSG) {
93
- lines.push(`[From ${msg.from_name}]: ${msg.content.slice(0, MAX_MSG)}... (use team_results to read full message)`)
94
- } else {
95
- lines.push(`[From ${msg.from_name}]: ${msg.content}`)
93
+ if (teamStateMode === "full") {
94
+ lines.push("", "--- Team Messages ---")
95
+ const MAX_MSG = 500
96
+ for (const msg of pendingMessages) {
97
+ const parsed = parseTaskResult(msg.content)
98
+ if (parsed) {
99
+ // Truncate details for system prompt — full content available via team_results
100
+ const truncatedResult = { ...parsed, details: truncate(parsed.details, 500) }
101
+ lines.push(formatTaskResult(msg.from_name, truncatedResult))
102
+ } else if (msg.content.length > MAX_MSG) {
103
+ lines.push(`[From ${msg.from_name}]: ${msg.content.slice(0, MAX_MSG)}... (use team_results to read full message)`)
104
+ } else {
105
+ lines.push(`[From ${msg.from_name}]: ${msg.content}`)
106
+ }
96
107
  }
97
- markDelivered(db, msg.id)
108
+ lines.push("--- End Messages ---")
109
+ } else {
110
+ // state-only and off both surface the count (not the payload) so a
111
+ // message arriving mid-turn cannot be silently consumed by the transform.
112
+ lines.push("", `Unread messages: ${pendingMessages.length} — use team_results to read them.`)
98
113
  }
99
- lines.push("--- End Messages ---")
114
+ // Mark delivered in every mode so the wake path does not re-fire for
115
+ // content the lead has already been told about. team_results still
116
+ // returns these until they are read.
117
+ for (const msg of pendingMessages) markDelivered(db, msg.id)
100
118
  }
101
119
 
102
120
  // Model selection guidance (only when promptForModels is enabled)
@@ -140,6 +158,7 @@ export function buildLeadSystemPrompt(db: Database, teamId: string, config?: Req
140
158
  "",
141
159
  "Teammates work asynchronously and message you when done.",
142
160
  "Do NOT poll team_status or team_tasks_list repeatedly — wait for messages.",
161
+ "When woken about new team messages, call team_results to read them.",
143
162
  "After spawning all teammates, tell the user what you've set up and wait.",
144
163
  "When all teammates finish, summarize results and suggest next steps.",
145
164
  "",
@@ -166,34 +185,44 @@ export function buildLeadSystemPrompt(db: Database, teamId: string, config?: Req
166
185
  * Build the system prompt injected into a teammate's session.
167
186
  * Includes role reminder and delivers any pending peer messages.
168
187
  */
169
- export function buildTeammateSystemPrompt(db: Database, teamId: string, memberName: string): string {
188
+ export function buildTeammateSystemPrompt(
189
+ db: Database,
190
+ teamId: string,
191
+ memberName: string,
192
+ config?: Required<EnsembleConfig>,
193
+ ): string {
170
194
  const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId) as { name: string } | null
171
195
  if (!team) return ""
172
196
 
197
+ const teamStateMode = config?.systemPromptTeamState ?? "full"
173
198
  const lines = [
174
199
  `You are "${memberName}", a teammate in team "${team.name}". Use team_message to communicate. You MUST send your results to the lead via team_message before stopping.`,
175
200
  ]
176
201
 
177
202
  // Deliver pending peer messages addressed to this teammate
178
203
  const pendingMessages = db.query(
179
- "SELECT id, from_name, content FROM team_message WHERE team_id = ? AND to_name = ? AND delivered = 0 ORDER BY time_created ASC"
204
+ "SELECT id, from_name, content FROM team_message WHERE team_id = ? AND to_name = ? AND read = 0 AND delivered = 0 ORDER BY time_created ASC"
180
205
  ).all(teamId, memberName) as Array<{ id: string; from_name: string; content: string }>
181
206
 
182
207
  if (pendingMessages.length > 0) {
183
- lines.push("", "--- Messages for you ---")
184
- const MAX_MSG = 500
185
- for (const msg of pendingMessages) {
186
- const parsed = parseTaskResult(msg.content)
187
- if (parsed) {
188
- lines.push(formatTaskResult(msg.from_name, { ...parsed, details: truncate(parsed.details, MAX_MSG) }))
189
- } else if (msg.content.length > MAX_MSG) {
190
- lines.push(`[From ${msg.from_name}]: ${msg.content.slice(0, MAX_MSG)}... (truncated)`)
191
- } else {
192
- lines.push(`[From ${msg.from_name}]: ${msg.content}`)
208
+ if (teamStateMode === "full") {
209
+ lines.push("", "--- Messages for you ---")
210
+ const MAX_MSG = 500
211
+ for (const msg of pendingMessages) {
212
+ const parsed = parseTaskResult(msg.content)
213
+ if (parsed) {
214
+ lines.push(formatTaskResult(msg.from_name, { ...parsed, details: truncate(parsed.details, MAX_MSG) }))
215
+ } else if (msg.content.length > MAX_MSG) {
216
+ lines.push(`[From ${msg.from_name}]: ${msg.content.slice(0, MAX_MSG)}... (truncated)`)
217
+ } else {
218
+ lines.push(`[From ${msg.from_name}]: ${msg.content}`)
219
+ }
193
220
  }
194
- markDelivered(db, msg.id)
221
+ lines.push("--- End Messages ---")
222
+ } else {
223
+ lines.push("", `Unread messages: ${pendingMessages.length} — use team_results to read them.`)
195
224
  }
196
- lines.push("--- End Messages ---")
225
+ for (const msg of pendingMessages) markDelivered(db, msg.id)
197
226
  }
198
227
 
199
228
  return lines.join("\n")
@@ -0,0 +1,116 @@
1
+ /** A task node as needed for dependency graph inspection. */
2
+ export interface TaskGraphNode {
3
+ id: string
4
+ status: string
5
+ dependsOn: string[]
6
+ }
7
+
8
+ /** Result of inspecting a team's task dependency graph. */
9
+ export interface TaskGraphInfo {
10
+ /** Longest-prerequisite-path depth per task (roots are 1). */
11
+ depthByTask: Map<string, number>
12
+ /** Tasks that are part of a dependency cycle. */
13
+ cyclic: string[]
14
+ /** Dependency IDs that do not resolve to a task in the graph. */
15
+ missing: string[]
16
+ }
17
+
18
+ /**
19
+ * Parse a `depends_on` column into a deduplicated list of task IDs.
20
+ * Tolerates malformed/legacy values instead of throwing, so one bad row
21
+ * cannot break task completion or graph rendering.
22
+ */
23
+ export function parseTaskDependencies(value: string | null): string[] {
24
+ if (!value) return []
25
+ try {
26
+ const parsed: unknown = JSON.parse(value)
27
+ if (Array.isArray(parsed)) {
28
+ return [...new Set(parsed.filter((item): item is string => typeof item === "string" && item.length > 0))]
29
+ }
30
+ if (typeof parsed === "string" && parsed.length > 0) return [parsed]
31
+ } catch {
32
+ // Legacy/hand-edited plain string.
33
+ return [value]
34
+ }
35
+ return []
36
+ }
37
+
38
+ /** Options for graph inspection. */
39
+ export interface TaskGraphOptions {
40
+ /**
41
+ * Ignore edges whose prerequisite is already completed/cancelled. Use this
42
+ * for depth-limit enforcement so finished work does not accumulate into a
43
+ * lifetime chain that blocks follow-up tasks.
44
+ */
45
+ activeOnly?: boolean
46
+ }
47
+
48
+ /**
49
+ * Inspect a task graph. Depth is the longest prerequisite path, with roots at
50
+ * 1 (a four-task chain has depth 4). Cyclic/cycle-affected tasks are reported
51
+ * and omitted from depthByTask so callers can reject additions that would
52
+ * build on corrupt data.
53
+ */
54
+ export function inspectTaskGraph(tasks: TaskGraphNode[], options: TaskGraphOptions = {}): TaskGraphInfo {
55
+ const byId = new Map(tasks.map(task => [task.id, task]))
56
+ const deps = new Map<string, string[]>()
57
+ const children = new Map<string, string[]>()
58
+ const indegree = new Map<string, number>()
59
+ const missing = new Set<string>()
60
+
61
+ tasks.forEach(task => {
62
+ children.set(task.id, [])
63
+ indegree.set(task.id, 0)
64
+ })
65
+
66
+ tasks.forEach(task => {
67
+ const valid: string[] = []
68
+ for (const depId of task.dependsOn) {
69
+ const dependency = byId.get(depId)
70
+ if (!dependency) {
71
+ missing.add(depId)
72
+ continue
73
+ }
74
+ if (options.activeOnly && (dependency.status === "completed" || dependency.status === "cancelled")) continue
75
+ if (valid.includes(depId)) continue
76
+ valid.push(depId)
77
+ children.get(depId)?.push(task.id)
78
+ }
79
+ deps.set(task.id, valid)
80
+ indegree.set(task.id, valid.length)
81
+ })
82
+
83
+ const queue = tasks.filter(task => indegree.get(task.id) === 0).map(task => task.id)
84
+ const depthByTask = new Map<string, number>()
85
+ let head = 0
86
+ while (head < queue.length) {
87
+ const id = queue[head]
88
+ head++
89
+ if (!id) continue
90
+ const task = byId.get(id)
91
+ const parents = deps.get(id) ?? []
92
+ const resolved = options.activeOnly && (task?.status === "completed" || task?.status === "cancelled")
93
+ depthByTask.set(
94
+ id,
95
+ resolved
96
+ ? 0
97
+ : parents.length === 0
98
+ ? 1
99
+ : Math.max(...parents.map(parent => depthByTask.get(parent) ?? 1)) + 1,
100
+ )
101
+ for (const child of children.get(id) ?? []) {
102
+ const next = (indegree.get(child) ?? 1) - 1
103
+ indegree.set(child, next)
104
+ if (next === 0) queue.push(child)
105
+ }
106
+ }
107
+
108
+ const cyclic = tasks.filter(task => !depthByTask.has(task.id)).map(task => task.id)
109
+ return { depthByTask, cyclic, missing: [...missing] }
110
+ }
111
+
112
+ /** Depth a new task would have if it depended on `dependencyIds`. */
113
+ export function depthForDependencies(dependencyIds: string[], graph: TaskGraphInfo): number {
114
+ if (dependencyIds.length === 0) return 1
115
+ return Math.max(...dependencyIds.map(id => graph.depthByTask.get(id) ?? 1)) + 1
116
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Weak-model tool-argument fumble detection and error hints (issue #39).
3
+ *
4
+ * Some models trained on other harness conventions call OpenCode's built-in
5
+ * tools with aliased parameter names (e.g. `cmd` instead of the `shell`
6
+ * tool's `command`). OpenCode V2 rejects the call with a generic validation
7
+ * error that the model may fail to act on. This module detects that exact
8
+ * dead-call shape and produces an explicit hint appended to the error so
9
+ * the model can self-correct. Arguments are never rewritten — the model
10
+ * stays in the correction loop.
11
+ *
12
+ * V2 only: on V1, argument validation happens at the model-stream parse
13
+ * level, so tool hooks never see the failed call (see issue #39 triage).
14
+ */
15
+
16
+ /**
17
+ * Canonical parameter plus the aliases weak models emit for it. Only the
18
+ * confirmed failure mode is listed; extend per-tool as reports come in.
19
+ */
20
+ const ARG_ALIASES: Readonly<Record<string, { param: string; aliases: readonly string[] }>> = {
21
+ shell: { param: "command", aliases: ["cmd", "bash", "script"] },
22
+ }
23
+
24
+ /**
25
+ * Return the alias a weak model used instead of the tool's canonical
26
+ * parameter, or null when the input is not a recognized fumble. A fumble is
27
+ * only recognized when the canonical parameter is absent and a known alias
28
+ * is present as a non-empty string — i.e. only on calls that are already
29
+ * guaranteed to fail validation.
30
+ */
31
+ export function detectArgFumble(tool: string, input: unknown): string | null {
32
+ const entry = ARG_ALIASES[tool]
33
+ if (!entry) return null
34
+ if (input === null || typeof input !== "object" || Array.isArray(input)) return null
35
+ const args = input as Record<string, unknown>
36
+ if (entry.param in args) return null
37
+ for (const alias of entry.aliases) {
38
+ const value = args[alias]
39
+ if (typeof value === "string" && value !== "") return alias
40
+ }
41
+ return null
42
+ }
43
+
44
+ /**
45
+ * Build the hint text for a fumbled tool call, or null when the input is
46
+ * not a recognized fumble.
47
+ */
48
+ export function buildArgHint(tool: string, input: unknown): string | null {
49
+ const entry = ARG_ALIASES[tool]
50
+ if (!entry) return null
51
+ const alias = detectArgFumble(tool, input)
52
+ if (!alias) return null
53
+ return (
54
+ `Hint: the "${tool}" tool has no "${alias}" parameter. The correct parameter ` +
55
+ `name is "${entry.param}" — call the tool again with the same arguments, ` +
56
+ `moving the "${alias}" value into "${entry.param}".`
57
+ )
58
+ }
59
+
60
+ /**
61
+ * Append the fumble hint to a failed tool call's error message in place.
62
+ * Returns true when the message was modified. Never throws — a malformed
63
+ * error or input leaves the original message untouched.
64
+ */
65
+ export function enrichToolError(tool: string, error: { message?: unknown }, input: unknown): boolean {
66
+ if (typeof error.message !== "string") return false
67
+ const hint = buildArgHint(tool, input)
68
+ if (!hint) return false
69
+ error.message = `${error.message}\n\n${hint}`
70
+ return true
71
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ToolDeps } from "../types"
2
2
  import { requireTeamMember } from "./shared"
3
3
  import { broadcastMessage, markDelivered, hasReportedCompletion } from "../messaging"
4
+ import { getSessionSelection } from "../member-model"
4
5
  import { log } from "../log"
5
6
 
6
7
  /**
@@ -24,11 +25,19 @@ export async function executeTeamBroadcast(
24
25
  // Collect all recipient session IDs (excluding sender)
25
26
  const recipients: Array<{ name: string; sessionId: string }> = []
26
27
 
27
- // Add lead if sender is not lead
28
+ // Add lead if sender is not lead. Resolve from the DB fresh: the team may
29
+ // have been archived since requireTeamMember() ran.
30
+ let leadSelection: Awaited<ReturnType<typeof getSessionSelection>> = {}
28
31
  if (teamInfo.role !== "lead") {
29
- const leadSession = deps.db.query("SELECT lead_session_id FROM team WHERE id = ?")
32
+ const leadSession = deps.db.query("SELECT lead_session_id FROM team WHERE id = ? AND status = 'active'")
30
33
  .get(teamInfo.teamId) as { lead_session_id: string } | null
31
- if (leadSession) recipients.push({ name: "lead", sessionId: leadSession.lead_session_id })
34
+ if (leadSession?.lead_session_id) {
35
+ recipients.push({ name: "lead", sessionId: leadSession.lead_session_id })
36
+ // V1 broadcasts are normal user turns; preserve the lead's agent/model (#37).
37
+ if (!deps.client.agent) {
38
+ leadSelection = await getSessionSelection(deps.client, leadSession.lead_session_id).catch(() => ({}))
39
+ }
40
+ }
32
41
  }
33
42
 
34
43
  // Add all members except sender
@@ -48,9 +57,18 @@ export async function executeTeamBroadcast(
48
57
  skipped++
49
58
  continue
50
59
  }
60
+ if (recipient.name === "lead") {
61
+ const active = deps.db.query("SELECT 1 FROM team WHERE id = ? AND status = 'active'").get(teamInfo.teamId)
62
+ if (!active) {
63
+ skipped++
64
+ continue
65
+ }
66
+ }
51
67
  deps.client.session.promptAsync({
52
68
  sessionID: recipient.sessionId,
53
69
  parts: [{ type: "text", text: `[Team broadcast from ${senderName}]: ${args.text}` }],
70
+ ...(recipient.name === "lead" && leadSelection.agent ? { agent: leadSelection.agent } : {}),
71
+ ...(recipient.name === "lead" && leadSelection.model ? { model: leadSelection.model } : {}),
54
72
  }).then(() => {
55
73
  delivered++
56
74
  if (delivered === 1) markDelivered(deps.db, msgId)
@@ -3,6 +3,7 @@ import { resolveRecipientSession } from "../types"
3
3
  import { requireTeamMember } from "./shared"
4
4
  import { sendMessage, markDelivered, hasReportedCompletion } from "../messaging"
5
5
  import { parseModelId, getMemberModel } from "../member-model"
6
+ import { wakeLeadIfPending } from "../wake"
6
7
  import { log } from "../log"
7
8
 
8
9
  /**
@@ -122,11 +123,9 @@ export async function executeTeamMessage(
122
123
  // because session.idle events are scoped per-instance.
123
124
  if (isToLead) {
124
125
  log(`team_message:wake-lead from=${senderName} recipientSession=${recipientSessionId}`)
125
- deps.client.session.promptAsync({
126
- sessionID: recipientSessionId,
127
- parts: [{ type: "text", text: `[System: New team message from ${senderName}]` }],
128
- synthetic: true,
129
- }).catch((err) => {
126
+ // Coalesced, guarded wake: skips when the team is gone or the lead has
127
+ // already drained the inbox, and never queues behind a busy lead (#48, #57).
128
+ wakeLeadIfPending(deps.client, deps.db, teamInfo.teamId).catch((err) => {
130
129
  log(`team_message:wake-lead:failed from=${senderName} err=${err instanceof Error ? err.message : String(err)}`)
131
130
  })
132
131
  return `Message sent to ${args.to}.`
@@ -11,8 +11,13 @@ interface UnreadMessageRow {
11
11
  }
12
12
 
13
13
  /**
14
- * Retrieve unread messages from the team message store.
14
+ * Retrieve unread messages from the caller's inbox.
15
15
  * Optionally filter by sender name. Marks returned messages as read.
16
+ *
17
+ * The query is recipient-scoped: a teammate calling team_results must not
18
+ * drain messages addressed to the lead (or to another teammate), or those
19
+ * messages would disappear from the recipient's system prompt and wake path.
20
+ * Broadcast rows (to_name IS NULL) are visible to every team member.
16
21
  */
17
22
  export async function executeTeamResults(
18
23
  deps: ToolDeps,
@@ -20,18 +25,23 @@ export async function executeTeamResults(
20
25
  sessionId: string,
21
26
  ): Promise<string> {
22
27
  const team = requireTeamMember(deps, sessionId)
28
+ const inbox = team.role === "lead" ? "lead" : (team.memberName ?? "")
23
29
 
24
30
  const rows = args.from
25
31
  ? (deps.db
26
32
  .query(
27
- "SELECT id, from_name, content, time_created FROM team_message WHERE team_id = ? AND read = 0 AND from_name = ? ORDER BY time_created ASC",
33
+ `SELECT id, from_name, content, time_created FROM team_message
34
+ WHERE team_id = ? AND read = 0 AND (to_name = ? OR to_name IS NULL) AND from_name = ?
35
+ ORDER BY time_created ASC`,
28
36
  )
29
- .all(team.teamId, args.from) as UnreadMessageRow[])
37
+ .all(team.teamId, inbox, args.from) as UnreadMessageRow[])
30
38
  : (deps.db
31
39
  .query(
32
- "SELECT id, from_name, content, time_created FROM team_message WHERE team_id = ? AND read = 0 ORDER BY time_created ASC",
40
+ `SELECT id, from_name, content, time_created FROM team_message
41
+ WHERE team_id = ? AND read = 0 AND (to_name = ? OR to_name IS NULL)
42
+ ORDER BY time_created ASC`,
33
43
  )
34
- .all(team.teamId) as UnreadMessageRow[])
44
+ .all(team.teamId, inbox) as UnreadMessageRow[])
35
45
 
36
46
  if (rows.length === 0) return "No unread messages."
37
47