@hueyexe/opencode-ensemble 0.16.1 → 0.18.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/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,47 @@ 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. Synthetic: renders as a persistent
120
+ // 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) => {
126
+ log(`notifyLead:wake:failed team=${teamId} err=${err instanceof Error ? err.message : String(err)}`)
127
+ })
128
+
129
+ return id
130
+ }
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)
package/src/recovery.ts CHANGED
@@ -2,18 +2,104 @@ import type { Database } from "./db"
2
2
  import type { PluginClient } from "./types"
3
3
  import type { MemberRegistry } from "./state"
4
4
  import { getUndeliveredMessages, markDelivered, hasReportedCompletion } from "./messaging"
5
+ import { releaseMemberTasks } from "./tasks"
6
+ import { getMemberModel } from "./member-model"
5
7
  import { preserveBranch, preservedBranchName, teamResourceSegment } from "./tools/merge-helper"
6
8
  import { log } from "./log"
7
9
  import { runCommand } from "./process"
8
10
 
11
+ /**
12
+ * Whether a session still exists in OpenCode. `client.session.get` throws for a
13
+ * deleted/unknown session rather than returning empty data -- see dashboard.ts's
14
+ * existing best-effort usage of the same call.
15
+ *
16
+ * Bounded by a timeout: a `session.get` call made from within this plugin's own
17
+ * init sequence, before the server has finished bootstrapping, can hang the
18
+ * request indefinitely rather than resolve or reject -- the same class of
19
+ * self-referential-HTTP-call deadlock team-spawn.ts's own `withTimeout` exists
20
+ * to guard against for `worktree.create`/`session.create` ("makes HTTP calls
21
+ * back to the server, which deadlocks because the server is still handling
22
+ * session.create"). On timeout, treat liveness as UNKNOWN (return true, i.e.
23
+ * "assume alive, don't archive") rather than assuming dead -- a false negative
24
+ * here just means the orphan isn't reconciled this pass; a false positive means
25
+ * archiving a team that might still be genuinely active.
26
+ */
27
+ export async function isSessionAlive(client: PluginClient, sessionId: string, timeoutMs = 5000): Promise<boolean> {
28
+ try {
29
+ await Promise.race([
30
+ client.session.get({ sessionID: sessionId }),
31
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error("isSessionAlive timed out")), timeoutMs)),
32
+ ])
33
+ return true
34
+ } catch (err) {
35
+ if (err instanceof Error && err.message === "isSessionAlive timed out") {
36
+ log(`recovery:team:liveness-check-timeout session=${sessionId}`)
37
+ return true
38
+ }
39
+ return false
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Reconcile 'active' teams whose lead session no longer exists in OpenCode --
45
+ * e.g. the user deleted the session via OpenCode's own session UI/API, not via
46
+ * team_cleanup. Ensemble's own bookkeeping has no way to learn about that on its
47
+ * own: nothing tells it the session is gone, so the team row stays 'active'
48
+ * forever, blocking team_create ("already exists") and team_cleanup's purge path
49
+ * ("cannot purge active team" -- purge only operates on archived teams) for that
50
+ * name indefinitely. A team with no live lead session is unrecoverable through
51
+ * any legitimate path (there is no "reassign lead" tool), so archiving it directly
52
+ * is safe regardless of member state -- the existing recoverOrphanedWorktrees/
53
+ * recoverOrphanedBranches passes then naturally sweep up its resources on the same
54
+ * recovery cycle, since those already scope to archived teams with no active
55
+ * members.
56
+ *
57
+ * Scoped by cwd the same way recoverStaleMembers is, to avoid reconciling other
58
+ * projects' teams when multiple projects share one DB.
59
+ */
60
+ export async function recoverOrphanedTeams(
61
+ db: Database,
62
+ client: PluginClient,
63
+ cwd?: string,
64
+ registry?: MemberRegistry,
65
+ ): Promise<{ archived: number }> {
66
+ const active = db.query(
67
+ `SELECT id, lead_session_id FROM team
68
+ WHERE status = 'active' AND (? IS NULL OR project_id = ? OR project_id = 'default')`
69
+ ).all(cwd ?? null, cwd ?? null) as Array<{ id: string; lead_session_id: string }>
70
+
71
+ let archived = 0
72
+ for (const team of active) {
73
+ if (await isSessionAlive(client, team.lead_session_id)) continue
74
+
75
+ db.run("UPDATE team SET status = 'archived', time_updated = ? WHERE id = ?", [Date.now(), team.id])
76
+ registry?.unregisterTeam(team.id)
77
+ archived++
78
+ log(`recovery:team:orphaned team_id=${team.id} lead_session=${team.lead_session_id}`)
79
+ }
80
+
81
+ return { archived }
82
+ }
83
+
9
84
  /**
10
85
  * Scan for team members stuck in 'busy' status (stale from a crash)
11
86
  * and mark them as 'error' with execution_status 'idle'.
12
87
  * Preserves worktree branches before aborting orphaned sessions.
13
88
  * Only processes members in active teams.
14
89
  * Returns the count of interrupted members.
90
+ *
91
+ * `abortTimeoutMs` bounds each `client.session.abort()` call -- the same
92
+ * self-referential-HTTP-call-back-to-this-server shape as
93
+ * `isSessionAlive()`'s `client.session.get()` (see that function's doc
94
+ * comment for the full deadlock mechanism, confirmed live 2026-08-17). This
95
+ * function is awaited synchronously in plugin init (unlike
96
+ * `recoverOrphanedTeams`, which is fire-and-forget) because
97
+ * `rehydrateRegistry` depends on it completing first, so a bounded timeout on
98
+ * the network call -- not restructuring the await -- is the fix here: the
99
+ * DB-only work (marking members 'error') always completes and is reflected in
100
+ * the returned count regardless of whether any individual abort call settles.
15
101
  */
16
- export async function recoverStaleMembers(db: Database, client?: PluginClient, cwd?: string): Promise<{ interrupted: number }> {
102
+ export async function recoverStaleMembers(db: Database, client?: PluginClient, cwd?: string, abortTimeoutMs = 5000): Promise<{ interrupted: number }> {
17
103
  // Find stale members with branch info so we can preserve before aborting
18
104
  const stale = db.query(
19
105
  `SELECT tm.session_id, tm.worktree_branch, tm.name, tm.team_id, t.name as team_name, p.name as project_name
@@ -31,6 +117,13 @@ export async function recoverStaleMembers(db: Database, client?: PluginClient, c
31
117
  [Date.now(), cwd ?? null, cwd ?? null]
32
118
  )
33
119
 
120
+ // Release each stale member's in_progress tasks back to the pool so their
121
+ // unfinished work is reclaimable after a crash (issue #27).
122
+ for (const member of stale) {
123
+ const released = releaseMemberTasks(db, member.team_id, member.name)
124
+ if (released > 0) log(`recovery:tasks:released name=${member.name} count=${released}`)
125
+ }
126
+
34
127
  // Preserve branches then abort orphaned sessions
35
128
  if (client) {
36
129
  for (const member of stale) {
@@ -45,8 +138,16 @@ export async function recoverStaleMembers(db: Database, client?: PluginClient, c
45
138
  }
46
139
  }
47
140
  try {
48
- await client.session.abort({ sessionID: member.session_id })
49
- } catch { /* best effort */ }
141
+ await Promise.race([
142
+ client.session.abort({ sessionID: member.session_id }),
143
+ new Promise<never>((_, reject) => setTimeout(() => reject(new Error("session.abort timed out")), abortTimeoutMs)),
144
+ ])
145
+ } catch (err) {
146
+ if (err instanceof Error && err.message === "session.abort timed out") {
147
+ log(`recovery:stale-members:abort-timeout session=${member.session_id}`)
148
+ }
149
+ /* best effort */
150
+ }
50
151
  }
51
152
  }
52
153
 
@@ -133,9 +234,11 @@ export async function recoverUndeliveredMessages(
133
234
  }
134
235
 
135
236
  try {
237
+ const recipientModel = getMemberModel(db, team.id, msg.to_name)
136
238
  await client.session.promptAsync({
137
239
  sessionID: recipientSessionId,
138
240
  parts: [{ type: "text", text: `[Recovered team message from ${msg.from_name}]: ${msg.content}` }],
241
+ ...(recipientModel ? { model: recipientModel } : {}),
139
242
  })
140
243
  markDelivered(db, msg.id)
141
244
  redelivered++
package/src/rpc.ts ADDED
@@ -0,0 +1,99 @@
1
+ import { Rpc } from "@opencode/plugin/rpc"
2
+
3
+ /**
4
+ * RPC contract between the server plugin and terminal companions.
5
+ * Published as `./rpc` so companions import the shape without the
6
+ * implementation. Companion behavior (toasts, navigation) is enhancement
7
+ * only — every client works fully without it.
8
+ */
9
+ export const EnsembleRpc = Rpc.define({
10
+ id: "ensemble",
11
+ methods: {
12
+ summary: {
13
+ input: {
14
+ type: "object",
15
+ properties: { team: { type: "string" } },
16
+ required: ["team"],
17
+ additionalProperties: false,
18
+ },
19
+ output: {
20
+ type: "object",
21
+ properties: { text: { type: "string" } },
22
+ required: ["text"],
23
+ additionalProperties: false,
24
+ },
25
+ },
26
+ teamContext: {
27
+ input: {
28
+ type: "object",
29
+ properties: { sessionID: { type: "string" } },
30
+ required: ["sessionID"],
31
+ additionalProperties: false,
32
+ },
33
+ output: {
34
+ type: "object",
35
+ properties: {
36
+ team: { type: ["string", "null"] },
37
+ members: {
38
+ type: "array",
39
+ items: {
40
+ type: "object",
41
+ properties: { name: { type: "string" }, status: { type: "string" } },
42
+ required: ["name", "status"],
43
+ additionalProperties: false,
44
+ },
45
+ },
46
+ tasks: {
47
+ type: "object",
48
+ properties: {
49
+ pending: { type: "number" },
50
+ done: { type: "number" },
51
+ },
52
+ required: ["pending", "done"],
53
+ additionalProperties: false,
54
+ },
55
+ },
56
+ required: ["team", "members", "tasks"],
57
+ additionalProperties: false,
58
+ },
59
+ },
60
+ },
61
+ events: {
62
+ member: {
63
+ schema: {
64
+ type: "object",
65
+ properties: {
66
+ memberName: { type: "string" },
67
+ teamId: { type: "string" },
68
+ from: { type: "string" },
69
+ to: { type: "string" },
70
+ },
71
+ required: ["memberName", "teamId", "from", "to"],
72
+ additionalProperties: false,
73
+ },
74
+ },
75
+ notice: {
76
+ schema: {
77
+ type: "object",
78
+ properties: {
79
+ title: { type: "string" },
80
+ message: { type: "string" },
81
+ variant: { type: "string" },
82
+ },
83
+ required: ["message"],
84
+ additionalProperties: false,
85
+ },
86
+ },
87
+ view: {
88
+ schema: {
89
+ type: "object",
90
+ properties: {
91
+ sessionID: { type: "string" },
92
+ memberName: { type: "string" },
93
+ },
94
+ required: ["sessionID"],
95
+ additionalProperties: false,
96
+ },
97
+ },
98
+ },
99
+ })
package/src/schema.ts CHANGED
@@ -186,6 +186,24 @@ export const MIGRATIONS: string[] = [
186
186
  CREATE INDEX IF NOT EXISTS team_message_undelivered_idx ON team_message(team_id, delivered) WHERE delivered = 0;
187
187
  CREATE INDEX IF NOT EXISTS team_message_unread_idx ON team_message(team_id, read) WHERE read = 0;
188
188
  PRAGMA foreign_keys=ON;`,
189
+ // Migration 9: Add last_nudged_at to team_member — additive-only display-staleness
190
+ // signal for the watchdog's soft stall-nudge path. Deliberately NOT a new status
191
+ // enum value (see checkStalled() in watchdog.ts for the rationale): a 6th CHECK
192
+ // constraint literal would require a table-rebuild migration and touch every
193
+ // consumer that switches on the 5 known status strings. This column is additive —
194
+ // existing consumers that don't know about it simply don't render it.
195
+ `ALTER TABLE team_member ADD COLUMN last_nudged_at INTEGER;`,
196
+ // Migration 10: Add retry_* columns to team_member — additive-only provider-retry
197
+ // display signal (see hooks.ts's "retry" branch). Same non-negotiable as Migration
198
+ // 9: no 6th status/execution_status literal, no table rebuild. retry_until is a
199
+ // TTL, not a stored enum — "currently retrying" is derived at read time
200
+ // (retry_until > Date.now()), so there is no explicit clear-write anywhere in this
201
+ // fix; it simply becomes stale and gets superseded by real activity or by time
202
+ // elapsing.
203
+ `ALTER TABLE team_member ADD COLUMN retry_until INTEGER;
204
+ ALTER TABLE team_member ADD COLUMN retry_attempt INTEGER;
205
+ ALTER TABLE team_member ADD COLUMN retry_provider TEXT;
206
+ ALTER TABLE team_member ADD COLUMN retry_message TEXT;`,
189
207
  ]
190
208
 
191
209
  /**
package/src/tasks.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { Database } from "./db"
2
+
3
+ /**
4
+ * Release all in-progress tasks assigned to a departing member back to the
5
+ * shared pool. Sets status to 'pending' and clears the assignee so another
6
+ * teammate can claim the work. Completed, cancelled, and blocked tasks are
7
+ * left untouched. Returns the number of tasks released.
8
+ *
9
+ * Call this whenever a member reaches a terminal state (shutdown, timeout,
10
+ * crash recovery, or spawn rollback) so their unfinished work is not stranded
11
+ * as permanently in_progress with a dead assignee (issue #27).
12
+ */
13
+ export function releaseMemberTasks(db: Database, teamId: string, memberName: string): number {
14
+ const result = db.run(
15
+ "UPDATE team_task SET status = 'pending', assignee = NULL, time_updated = ? WHERE team_id = ? AND assignee = ? AND status = 'in_progress'",
16
+ [Date.now(), teamId, memberName],
17
+ )
18
+ return result.changes
19
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ToolDeps } from "../types"
2
2
  import { generateId, generateProjectName, validateProjectName, validateTeamName } from "../util"
3
3
  import { findTeamBySession } from "../types"
4
+ import { isSessionAlive } from "../recovery"
4
5
 
5
6
  /**
6
7
  * Execute the team_create tool. Creates a new team with the caller as lead.
@@ -19,8 +20,20 @@ export async function executeTeamCreate(
19
20
 
20
21
  // Check if team name already exists
21
22
  const projectId = deps.directory
22
- const existing = deps.db.query("SELECT id FROM team WHERE name = ? AND project_id = ? AND status = 'active'").get(args.name, projectId)
23
- if (existing) throw new Error(`Team "${args.name}" already exists`)
23
+ const existing = deps.db.query("SELECT id, lead_session_id FROM team WHERE name = ? AND project_id = ? AND status = 'active'")
24
+ .get(args.name, projectId) as { id: string; lead_session_id: string } | undefined
25
+ if (existing) {
26
+ // The existing team's lead session may have been deleted externally (via
27
+ // OpenCode's own session UI/API, not team_cleanup) rather than genuinely
28
+ // still being in use. Reconcile on demand rather than block this call on a
29
+ // team nobody can ever be lead of again -- don't wait for the next plugin
30
+ // restart's periodic recoverOrphanedTeams pass to notice.
31
+ if (await isSessionAlive(deps.client, existing.lead_session_id)) {
32
+ throw new Error(`Team "${args.name}" already exists`)
33
+ }
34
+ deps.db.run("UPDATE team SET status = 'archived', time_updated = ? WHERE id = ?", [Date.now(), existing.id])
35
+ deps.registry.unregisterTeam(existing.id)
36
+ }
24
37
 
25
38
  // Check if session already leads a team
26
39
  const lead = findTeamBySession(deps.db, deps.registry, sessionId)
@@ -1,5 +1,6 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { requireLead } from "./shared"
2
+ import { requireLead, checkWorktreeDirty, countBranchCommits } from "./shared"
3
+ import type { IsDirtyFn, CommitCountFn } from "./shared"
3
4
  import { mergeBranch, deleteBranch, getOverlappingFiles } from "./merge-helper"
4
5
  import type { MergeBranchFn, DeleteBranchFn, OverlapCheckFn } from "./merge-helper"
5
6
  import { log } from "../log"
@@ -15,11 +16,13 @@ export async function executeTeamMerge(
15
16
  merge: MergeBranchFn = mergeBranch,
16
17
  delBranch: DeleteBranchFn = deleteBranch,
17
18
  overlapCheck: OverlapCheckFn = getOverlappingFiles,
19
+ commitCount: CommitCountFn = countBranchCommits,
20
+ isDirty: IsDirtyFn = checkWorktreeDirty,
18
21
  ): Promise<string> {
19
22
  const teamInfo = requireLead(deps, sessionId)
20
23
 
21
- const member = deps.db.query("SELECT status, worktree_branch FROM team_member WHERE team_id = ? AND name = ?")
22
- .get(teamInfo.teamId, args.member) as { status: string; worktree_branch: string | null } | null
24
+ const member = deps.db.query("SELECT status, worktree_branch, worktree_dir FROM team_member WHERE team_id = ? AND name = ?")
25
+ .get(teamInfo.teamId, args.member) as { status: string; worktree_branch: string | null; worktree_dir: string | null } | null
23
26
  if (!member) throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`)
24
27
 
25
28
  if (member.status !== "shutdown" && member.status !== "error") {
@@ -33,6 +36,30 @@ export async function executeTeamMerge(
33
36
  const branch = member.worktree_branch
34
37
  log(`merge:start member=${args.member} branch=${branch}`)
35
38
 
39
+ // A branch with zero new commits produces a trivially "successful" no-op squash merge --
40
+ // there is genuinely nothing to apply. Reporting that as "Merged ... changes" is
41
+ // misleading: if the teammate's worktree still has uncommitted work, that work was never
42
+ // captured on the branch at all and is about to be permanently lost the moment the
43
+ // worktree is removed (e.g. by team_cleanup). Say so plainly instead of a false success,
44
+ // and don't tear down the branch/worktree while it may be the only copy of the work.
45
+ // A commit-count check failure returns -1 (unknown) -- never treat that as "nothing to
46
+ // merge"; fall through to the real merge attempt so a check failure can't silently skip
47
+ // real work.
48
+ const commits = await commitCount(branch, deps.directory)
49
+ if (commits === 0) {
50
+ const dirty = member.worktree_dir ? await isDirty(member.worktree_dir).catch(() => false) : false
51
+ if (dirty) {
52
+ return [
53
+ `Nothing to merge for "${args.member}" — their branch (${branch}) has no commits.`,
54
+ `Their worktree still has uncommitted changes that were never captured on the branch.`,
55
+ `This work will be permanently lost if the worktree is removed (e.g. via team_cleanup).`,
56
+ member.worktree_dir ? `Worktree: ${member.worktree_dir}` : ``,
57
+ `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.`,
58
+ ].filter(Boolean).join("\n")
59
+ }
60
+ return `Nothing to merge for "${args.member}" — their branch (${branch}) has no commits and no uncommitted changes. They made no changes.`
61
+ }
62
+
36
63
  // Block merge if lead has local changes to files the agent also modified
37
64
  try {
38
65
  const overlap = await overlapCheck(branch, deps.directory)
@@ -2,21 +2,62 @@ import type { ToolDeps } from "../types"
2
2
  import { resolveRecipientSession } from "../types"
3
3
  import { requireTeamMember } from "./shared"
4
4
  import { sendMessage, markDelivered, hasReportedCompletion } from "../messaging"
5
+ import { parseModelId, getMemberModel } from "../member-model"
5
6
  import { log } from "../log"
6
7
 
7
8
  /**
8
9
  * Execute the team_message tool. Sends a direct message to a teammate or lead.
9
10
  * Optionally approves or rejects a teammate's plan (lead only).
11
+ * Optionally forces delivery to a teammate who already reported completion (lead only) —
12
+ * see the `force` guard below.
13
+ * Optionally updates a teammate's model in-place (lead only) via the `model`
14
+ * param — see #26. When `model` is set, `text` is optional: if omitted, the
15
+ * model is updated without delivering a message.
10
16
  */
11
17
  export async function executeTeamMessage(
12
18
  deps: ToolDeps,
13
- args: { to: string; text: string; approve?: boolean; reject?: string },
19
+ args: { to: string; text?: string; approve?: boolean; reject?: string; force?: boolean; model?: string },
14
20
  sessionId: string,
15
21
  ): Promise<string> {
16
22
  const teamInfo = requireTeamMember(deps, sessionId)
17
23
 
24
+ if (args.force && teamInfo.role !== "lead") {
25
+ throw new Error("Only the lead can force-deliver a message to a completed teammate.")
26
+ }
27
+
18
28
  const senderName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
19
29
 
30
+ // In-place model update (lead only). Handled before recipient session
31
+ // resolution so it works even when the member is idle/not in the registry,
32
+ // and rejects terminal members explicitly. See #26.
33
+ if (args.model !== undefined) {
34
+ if (teamInfo.role !== "lead") throw new Error("Only the lead can update a teammate's model.")
35
+ const parsed = parseModelId(args.model)
36
+ if (!parsed) throw new Error(`Invalid model "${args.model}" — expected "provider/model" format (e.g. "anthropic/claude-sonnet").`)
37
+ const member = deps.db.query("SELECT status FROM team_member WHERE team_id = ? AND name = ?")
38
+ .get(teamInfo.teamId, args.to) as { status: string } | null
39
+ if (!member) throw new Error(`Teammate "${args.to}" not found in team "${teamInfo.teamName}".`)
40
+ if (member.status === "shutdown" || member.status === "error") {
41
+ throw new Error(`Teammate "${args.to}" is shut down — spawn a new teammate instead of updating the model.`)
42
+ }
43
+ deps.db.run(
44
+ "UPDATE team_member SET model = ?, time_updated = ? WHERE team_id = ? AND name = ?",
45
+ [args.model, Date.now(), teamInfo.teamId, args.to],
46
+ )
47
+ log(`team_message:model-update to=${args.to} model=${args.model}`)
48
+
49
+ // If there's no accompanying message, we're done — the new model applies
50
+ // on the teammate's next natural delivery.
51
+ if (!args.text) {
52
+ return `Updated ${args.to}'s model to ${args.model}. It applies on their next turn.`
53
+ }
54
+ }
55
+
56
+ if (args.text === undefined) {
57
+ throw new Error("team_message requires either 'text' or 'model'.")
58
+ }
59
+ const text = args.text
60
+
20
61
  const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to)
21
62
 
22
63
  // If recipient not found, store the message for later delivery (they may not be spawned yet)
@@ -29,7 +70,7 @@ export async function executeTeamMessage(
29
70
  teamId: teamInfo.teamId,
30
71
  from: senderName,
31
72
  to: args.to,
32
- content: args.text,
73
+ content: text,
33
74
  })
34
75
  log(`team_message:queued from=${senderName} to=${args.to} (recipient not yet spawned)`)
35
76
  return `Message queued for ${args.to} — they haven't been spawned yet. It will be delivered when they join the team.`
@@ -37,7 +78,7 @@ export async function executeTeamMessage(
37
78
  if (!recipientSessionId) throw new Error(`Recipient "${args.to}" not found in team "${teamInfo.teamName}"`)
38
79
 
39
80
  // Handle plan approval/rejection
40
- let messageText = args.text
81
+ let messageText = text
41
82
  if (args.approve || args.reject) {
42
83
  if (args.approve && args.reject) {
43
84
  throw new Error("Cannot both approve and reject a plan.")
@@ -84,22 +125,30 @@ export async function executeTeamMessage(
84
125
  deps.client.session.promptAsync({
85
126
  sessionID: recipientSessionId,
86
127
  parts: [{ type: "text", text: `[System: New team message from ${senderName}]` }],
128
+ synthetic: true,
87
129
  }).catch((err) => {
88
130
  log(`team_message:wake-lead:failed from=${senderName} err=${err instanceof Error ? err.message : String(err)}`)
89
131
  })
90
132
  return `Message sent to ${args.to}.`
91
133
  }
92
134
 
93
- // Guard: skip promptAsync delivery to teammates who have already reported completion (issue #3)
94
- if (hasReportedCompletion(deps.db, teamInfo.teamId, args.to)) {
95
- return `Message stored for ${args.to} (teammate has completed their task — message will not wake them).`
135
+ // Guard: skip promptAsync delivery to teammates who have already reported completion (issue #3),
136
+ // unless the lead explicitly forces it. Forcing wakes the session; the natural busy transition
137
+ // (handleSessionStatusEvent) resets the completion flag when the teammate next goes idle, the
138
+ // same reset that already happens for a normal re-activation — this just gives the lead a way
139
+ // to trigger it deliberately instead of it being unreachable once the guard is set.
140
+ if (hasReportedCompletion(deps.db, teamInfo.teamId, args.to) && !args.force) {
141
+ return `Message stored for ${args.to} (teammate has completed their task — message will not wake them). Pass force:true to re-activate them.`
96
142
  }
97
143
 
98
144
  // For member-to-member messages, fire-and-forget delivery is safe.
145
+ // Deliver on the recipient's configured model, if any (#26).
99
146
  const deliveryText = `[Team message from ${senderName}]: ${messageText}`
147
+ const recipientModel = getMemberModel(deps.db, teamInfo.teamId, args.to)
100
148
  deps.client.session.promptAsync({
101
149
  sessionID: recipientSessionId,
102
150
  parts: [{ type: "text", text: deliveryText }],
151
+ ...(recipientModel ? { model: recipientModel } : {}),
103
152
  }).then(() => {
104
153
  markDelivered(deps.db, msgId)
105
154
  }).catch((err) => {
@@ -3,6 +3,7 @@ import { requireLead, checkWorktreeDirty, countBranchCommits } from "./shared"
3
3
  import type { IsDirtyFn, CommitCountFn } from "./shared"
4
4
  import { getTeamResourceParts, preserveBranch, preservedBranchName } from "./merge-helper"
5
5
  import type { PreserveBranchFn } from "./merge-helper"
6
+ import { releaseMemberTasks } from "../tasks"
6
7
  import { log } from "../log"
7
8
 
8
9
  /**
@@ -127,6 +128,11 @@ async function preserveAndAbort(
127
128
  "UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?",
128
129
  [Date.now(), teamId, memberName],
129
130
  )
131
+
132
+ // Release any tasks this member was working — they are gone now, so their
133
+ // in_progress work must return to the pool for another teammate (issue #27).
134
+ const released = releaseMemberTasks(deps.db, teamId, memberName)
135
+ if (released > 0) log(`shutdown:tasks:released name=${memberName} count=${released}`)
130
136
  }
131
137
 
132
138
  /** Build a status line describing the teammate's work: commit count, dirty state, next step. */