@hueyexe/opencode-ensemble 0.3.1 → 0.5.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/index.ts CHANGED
@@ -3,10 +3,12 @@ import { tool } from "@opencode-ai/plugin"
3
3
  import path from "path"
4
4
  import { mkdirSync } from "node:fs"
5
5
  import { createDb, getDbPath } from "./db"
6
- import { recoverStaleMembers, recoverUndeliveredMessages } from "./recovery"
6
+ import { recoverStaleMembers, recoverUndeliveredMessages, recoverOrphanedWorktrees } from "./recovery"
7
7
  import { MemberRegistry, DescendantTracker } from "./state"
8
8
  import { handleSessionStatusEvent, handleSessionCreatedEvent, checkToolIsolation } from "./hooks"
9
9
  import { notifyTeamEvent, notifyWorkingProgress } from "./notify"
10
+ import { buildLeadSystemPrompt, buildTeammateSystemPrompt, buildTeamCompactionContext } from "./system-prompt"
11
+ import { findTeamBySession } from "./types"
10
12
  import { executeTeamCreate } from "./tools/team-create"
11
13
  import { executeTeamSpawn } from "./tools/team-spawn"
12
14
  import { executeTeamMessage } from "./tools/team-message"
@@ -47,7 +49,7 @@ const plugin: Plugin = async (input) => {
47
49
 
48
50
  // Build shared tool dependencies
49
51
  const client = input.client as unknown as PluginClient
50
- const deps: ToolDeps = { db, registry, tracker, client }
52
+ const deps: ToolDeps = { db, registry, tracker, client, directory: input.directory }
51
53
 
52
54
  // Run recovery on init — mark stale busy members as error + abort orphaned sessions
53
55
  const recovery = await recoverStaleMembers(db, client)
@@ -66,6 +68,11 @@ const plugin: Plugin = async (input) => {
66
68
  // Best effort — don't block plugin init
67
69
  })
68
70
 
71
+ // Clean up orphaned worktrees from crashed teams
72
+ recoverOrphanedWorktrees(db, client).catch(() => {
73
+ // Best effort — don't block plugin init
74
+ })
75
+
69
76
  // Initialize rate limiter
70
77
  // OPENCODE_ENSEMBLE_RATE_LIMIT=0 disables it entirely
71
78
  const rateLimitEnv = process.env.OPENCODE_ENSEMBLE_RATE_LIMIT
@@ -147,6 +154,42 @@ const plugin: Plugin = async (input) => {
147
154
  }
148
155
  },
149
156
 
157
+ // System prompt injection — keeps lead aware of team state, reminds teammates of role
158
+ "experimental.chat.system.transform": async (input, output) => {
159
+ if (!input.sessionID) return
160
+ const teamInfo = findTeamBySession(db, registry, input.sessionID)
161
+ if (!teamInfo) return
162
+ const prompt = teamInfo.role === "lead"
163
+ ? buildLeadSystemPrompt(db, teamInfo.teamId)
164
+ : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName!)
165
+ output.system.push(prompt)
166
+ },
167
+
168
+ // Compaction safety — preserves team context when sessions get long
169
+ "experimental.session.compacting": async (input, output) => {
170
+ const teamInfo = findTeamBySession(db, registry, input.sessionID)
171
+ if (!teamInfo) return
172
+ const context = buildTeamCompactionContext(db, teamInfo.teamId, teamInfo.role, teamInfo.memberName)
173
+ output.context.push(context)
174
+ },
175
+
176
+ // Team-aware shell environment for scripts and hooks
177
+ "shell.env": async (input, output) => {
178
+ if (!input.sessionID) return
179
+ const teamInfo = findTeamBySession(db, registry, input.sessionID)
180
+ if (!teamInfo) return
181
+ output.env.ENSEMBLE_TEAM = teamInfo.teamName
182
+ output.env.ENSEMBLE_ROLE = teamInfo.role
183
+ if (teamInfo.memberName) {
184
+ output.env.ENSEMBLE_MEMBER = teamInfo.memberName
185
+ const member = db.query("SELECT worktree_branch FROM team_member WHERE team_id = ? AND name = ?")
186
+ .get(teamInfo.teamId, teamInfo.memberName) as { worktree_branch: string | null } | null
187
+ if (member?.worktree_branch) {
188
+ output.env.ENSEMBLE_BRANCH = member.worktree_branch
189
+ }
190
+ }
191
+ },
192
+
150
193
  // Register all team tools
151
194
  tool: {
152
195
  team_create: tool({
@@ -163,6 +206,7 @@ const plugin: Plugin = async (input) => {
163
206
 
164
207
  team_spawn: tool({
165
208
  description: "Spawn a new teammate that works in parallel. The teammate starts immediately with the given prompt. " +
209
+ "Each teammate gets their own git worktree for file isolation. " +
166
210
  "Teammates work asynchronously and will message you when done. Do not poll for their status.",
167
211
  args: {
168
212
  name: tool.schema.string().describe("Teammate name (lowercase alphanumeric with hyphens)"),
@@ -170,6 +214,8 @@ const plugin: Plugin = async (input) => {
170
214
  prompt: tool.schema.string().describe("Task instructions for the teammate"),
171
215
  model: tool.schema.string().optional().describe("Model in provider/model format (optional, uses default)"),
172
216
  claim_task: tool.schema.string().optional().describe("Task ID to auto-claim for this teammate (optional)"),
217
+ worktree: tool.schema.boolean().default(true).describe("Create a git worktree for file isolation (default: true, set false for read-only agents)"),
218
+ plan_approval: tool.schema.boolean().default(false).describe("Require teammate to send a plan for approval before writing files (default: false)"),
173
219
  },
174
220
  async execute(args, ctx) {
175
221
  const result = await executeTeamSpawn(deps, args, ctx.sessionID)
@@ -183,6 +229,8 @@ const plugin: Plugin = async (input) => {
183
229
  args: {
184
230
  to: tool.schema.string().describe("Recipient name ('lead' or teammate name)"),
185
231
  text: tool.schema.string().describe("Message content (max 10KB)"),
232
+ approve: tool.schema.boolean().optional().describe("Approve a teammate's plan (only when recipient has plan_approval='pending')"),
233
+ reject: tool.schema.string().optional().describe("Reject a teammate's plan with reason (only when recipient has plan_approval='pending')"),
186
234
  },
187
235
  async execute(args, ctx) {
188
236
  const result = await executeTeamMessage(deps, args, ctx.sessionID)
@@ -267,9 +315,11 @@ const plugin: Plugin = async (input) => {
267
315
  }),
268
316
 
269
317
  team_shutdown: tool({
270
- description: "Request a teammate to shut down. The teammate finishes current work then stops.",
318
+ description: "Request a teammate to shut down. The teammate finishes current work then stops. " +
319
+ "Pass force: true to abort immediately without waiting.",
271
320
  args: {
272
321
  member: tool.schema.string().describe("Teammate name to shut down"),
322
+ force: tool.schema.boolean().default(false).describe("Force immediate abort without waiting for current work to finish"),
273
323
  },
274
324
  async execute(args, ctx) {
275
325
  const result = await executeTeamShutdown(deps, args, ctx.sessionID)
package/src/recovery.ts CHANGED
@@ -37,6 +37,43 @@ export async function recoverStaleMembers(db: Database, client?: PluginClient):
37
37
  return { interrupted: result.changes }
38
38
  }
39
39
 
40
+ /**
41
+ * Clean up orphaned worktrees from archived teams or members that no longer exist.
42
+ * Compares worktrees on disk (via client.worktree.list) against active team members.
43
+ */
44
+ export async function recoverOrphanedWorktrees(db: Database, client: PluginClient): Promise<{ removed: number }> {
45
+ let removed = 0
46
+
47
+ try {
48
+ const worktrees = await client.worktree.list()
49
+ if (!worktrees.data) return { removed: 0 }
50
+
51
+ // Get all active worktree directories from the DB
52
+ const activeWorktrees = new Set(
53
+ (db.query(
54
+ `SELECT tm.worktree_dir FROM team_member tm
55
+ JOIN team t ON tm.team_id = t.id
56
+ WHERE tm.worktree_dir IS NOT NULL AND t.status = 'active'`
57
+ ).all() as Array<{ worktree_dir: string }>).map(r => r.worktree_dir)
58
+ )
59
+
60
+ for (const wt of worktrees.data) {
61
+ // Only clean up worktrees created by ensemble (name starts with "ensemble-")
62
+ if (!wt.name.startsWith("ensemble-")) continue
63
+ if (activeWorktrees.has(wt.directory)) continue
64
+
65
+ try {
66
+ await client.worktree.remove({ worktreeRemoveInput: { directory: wt.directory } })
67
+ removed++
68
+ } catch { /* best effort */ }
69
+ }
70
+ } catch {
71
+ // worktree.list may not be available — silently ignore
72
+ }
73
+
74
+ return { removed }
75
+ }
76
+
40
77
  /**
41
78
  * Redeliver undelivered messages (delivered=0) via promptAsync.
42
79
  * Resolves recipient session IDs from the member registry or team lead.
package/src/schema.ts CHANGED
@@ -70,6 +70,12 @@ export const MIGRATIONS: string[] = [
70
70
  // Migration 2: Add read column to team_message for team_results tracking
71
71
  `ALTER TABLE team_message ADD COLUMN read INTEGER NOT NULL DEFAULT 0;
72
72
  CREATE INDEX IF NOT EXISTS team_message_unread_idx ON team_message(team_id, read) WHERE read = 0;`,
73
+ // Migration 3: Add worktree columns to team_member for git worktree isolation
74
+ `ALTER TABLE team_member ADD COLUMN worktree_dir TEXT;
75
+ ALTER TABLE team_member ADD COLUMN worktree_branch TEXT;`,
76
+ // Migration 4: Add plan_approval column to team_member for plan-before-build workflow
77
+ `ALTER TABLE team_member ADD COLUMN plan_approval TEXT NOT NULL DEFAULT 'none'
78
+ CHECK(plan_approval IN ('none', 'pending', 'approved', 'rejected'));`,
73
79
  ]
74
80
 
75
81
  /**
@@ -0,0 +1,111 @@
1
+ import type { Database } from "bun:sqlite"
2
+
3
+ /** Status display mapping from DB status to human-readable label. */
4
+ const STATUS_DISPLAY: Record<string, string> = {
5
+ busy: "working",
6
+ ready: "idle",
7
+ shutdown_requested: "shutting down",
8
+ shutdown: "shut down",
9
+ error: "error",
10
+ }
11
+
12
+ /**
13
+ * Build the system prompt injected into the lead's session.
14
+ * Includes team name, member statuses, task counts, and anti-polling guidance.
15
+ */
16
+ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
17
+ const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId) as { name: string } | null
18
+ if (!team) return ""
19
+
20
+ const members = db.query("SELECT name, status FROM team_member WHERE team_id = ?").all(teamId) as Array<{ name: string; status: string }>
21
+
22
+ const taskCounts = db.query(
23
+ "SELECT status, COUNT(*) as count FROM team_task WHERE team_id = ? GROUP BY status",
24
+ ).all(teamId) as Array<{ status: string; count: number }>
25
+
26
+ const countMap: Record<string, number> = {}
27
+ for (const row of taskCounts) {
28
+ countMap[row.status] = row.count
29
+ }
30
+
31
+ const completed = countMap["completed"] ?? 0
32
+ const inProgress = countMap["in_progress"] ?? 0
33
+ const pending = countMap["pending"] ?? 0
34
+
35
+ const memberList = members
36
+ .map((m) => `${m.name} [${STATUS_DISPLAY[m.status] ?? m.status}]`)
37
+ .join(", ")
38
+
39
+ const teammateLine = members.length > 0
40
+ ? `Teammates: ${memberList}`
41
+ : "Teammates: none"
42
+
43
+ return [
44
+ `You are leading team "${team.name}" with ${members.length} active teammates.`,
45
+ teammateLine,
46
+ `Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`,
47
+ "",
48
+ "Teammates work asynchronously and message you when done.",
49
+ "Do NOT poll team_status or team_tasks_list repeatedly — wait for messages.",
50
+ "After spawning teammates, tell the user what you've set up and wait.",
51
+ "When all teammates finish, summarize results and suggest next steps.",
52
+ ].join("\n")
53
+ }
54
+
55
+ /**
56
+ * Build the system prompt injected into a teammate's session.
57
+ * Short role reminder with name, team name, and communication tool.
58
+ */
59
+ export function buildTeammateSystemPrompt(db: Database, teamId: string, memberName: string): string {
60
+ const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId) as { name: string } | null
61
+ if (!team) return ""
62
+
63
+ return `You are "${memberName}", a teammate in team "${team.name}". Use team_message to communicate.`
64
+ }
65
+
66
+ /**
67
+ * Build a concise context string for compaction.
68
+ * Includes team name, member statuses, task progress, and role statement.
69
+ */
70
+ export function buildTeamCompactionContext(
71
+ db: Database,
72
+ teamId: string,
73
+ role: "lead" | "member",
74
+ memberName?: string,
75
+ ): string {
76
+ const team = db.query("SELECT name FROM team WHERE id = ?").get(teamId) as { name: string } | null
77
+ if (!team) return ""
78
+
79
+ const members = db.query("SELECT name, status FROM team_member WHERE team_id = ?").all(teamId) as Array<{ name: string; status: string }>
80
+
81
+ const taskCounts = db.query(
82
+ "SELECT status, COUNT(*) as count FROM team_task WHERE team_id = ? GROUP BY status",
83
+ ).all(teamId) as Array<{ status: string; count: number }>
84
+
85
+ const countMap: Record<string, number> = {}
86
+ for (const row of taskCounts) {
87
+ countMap[row.status] = row.count
88
+ }
89
+
90
+ const completed = countMap["completed"] ?? 0
91
+ const inProgress = countMap["in_progress"] ?? 0
92
+ const pending = countMap["pending"] ?? 0
93
+
94
+ const roleLine = role === "lead"
95
+ ? `[Team Context] You are the lead of team "${team.name}".`
96
+ : `[Team Context] You are a teammate named "${memberName}" in team "${team.name}".`
97
+
98
+ const memberList = members
99
+ .map((m) => `${m.name} (${STATUS_DISPLAY[m.status] ?? m.status})`)
100
+ .join(", ")
101
+
102
+ const membersLine = members.length > 0
103
+ ? `Members: ${memberList}`
104
+ : "Members: none"
105
+
106
+ return [
107
+ roleLine,
108
+ membersLine,
109
+ `Tasks: ${completed} completed, ${inProgress} in progress, ${pending} pending`,
110
+ ].join("\n")
111
+ }
@@ -3,6 +3,7 @@ import { findTeamBySession } from "../types"
3
3
 
4
4
  /**
5
5
  * Execute the team_cleanup tool. Archives the team and cleans up resources.
6
+ * Removes worktrees and lists branches for the lead to merge.
6
7
  * If force=true, aborts all active member sessions first.
7
8
  */
8
9
  export async function executeTeamCleanup(
@@ -14,8 +15,8 @@ export async function executeTeamCleanup(
14
15
  if (!teamInfo) throw new Error("This session is not in a team.")
15
16
  if (teamInfo.role !== "lead") throw new Error("Only the team lead can clean up the team.")
16
17
 
17
- const members = deps.db.query("SELECT name, session_id, status FROM team_member WHERE team_id = ?")
18
- .all(teamInfo.teamId) as Array<{ name: string; session_id: string; status: string }>
18
+ const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch FROM team_member WHERE team_id = ?")
19
+ .all(teamInfo.teamId) as Array<{ name: string; session_id: string; status: string; worktree_dir: string | null; worktree_branch: string | null }>
19
20
 
20
21
  const active = members.filter(m => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error")
21
22
 
@@ -29,9 +30,20 @@ export async function executeTeamCleanup(
29
30
  for (const member of active) {
30
31
  try {
31
32
  await deps.client.session.abort({ path: { id: member.session_id } })
32
- } catch {
33
- // Best effort
34
- }
33
+ } catch { /* best effort */ }
34
+ }
35
+ }
36
+
37
+ // Remove worktrees and collect branches for merging
38
+ const branches: string[] = []
39
+ for (const member of members) {
40
+ if (member.worktree_dir) {
41
+ try {
42
+ await deps.client.worktree.remove({ worktreeRemoveInput: { directory: member.worktree_dir } })
43
+ } catch { /* best effort — worktree may already be gone */ }
44
+ }
45
+ if (member.worktree_branch) {
46
+ branches.push(member.worktree_branch)
35
47
  }
36
48
  }
37
49
 
@@ -41,5 +53,10 @@ export async function executeTeamCleanup(
41
53
  // Clean up registry
42
54
  deps.registry.unregisterTeam(teamInfo.teamId)
43
55
 
56
+ if (branches.length > 0) {
57
+ const mergeCommands = branches.map(b => ` git merge ${b}`).join("\n")
58
+ return `Team "${teamInfo.teamName}" cleaned up. Worktrees removed.\n\nMerge teammate branches:\n${mergeCommands}`
59
+ }
60
+
44
61
  return `Team "${teamInfo.teamName}" cleaned up.`
45
62
  }
@@ -4,10 +4,11 @@ import { sendMessage, markDelivered } from "../messaging"
4
4
 
5
5
  /**
6
6
  * Execute the team_message tool. Sends a direct message to a teammate or lead.
7
+ * Optionally approves or rejects a teammate's plan (lead only).
7
8
  */
8
9
  export async function executeTeamMessage(
9
10
  deps: ToolDeps,
10
- args: { to: string; text: string },
11
+ args: { to: string; text: string; approve?: boolean; reject?: string },
11
12
  sessionId: string,
12
13
  ): Promise<string> {
13
14
  const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
@@ -18,22 +19,52 @@ export async function executeTeamMessage(
18
19
  const recipientSessionId = resolveRecipientSession(deps.db, deps.registry, teamInfo.teamId, args.to)
19
20
  if (!recipientSessionId) throw new Error(`Recipient "${args.to}" not found in team "${teamInfo.teamName}"`)
20
21
 
22
+ // Handle plan approval/rejection
23
+ let messageText = args.text
24
+ if (args.approve || args.reject) {
25
+ if (args.approve && args.reject) {
26
+ throw new Error("Cannot both approve and reject a plan.")
27
+ }
28
+ if (teamInfo.role !== "lead") {
29
+ throw new Error("Only the lead can approve or reject plans.")
30
+ }
31
+ const recipient = deps.db.query(
32
+ "SELECT plan_approval FROM team_member WHERE team_id = ? AND name = ?"
33
+ ).get(teamInfo.teamId, args.to) as { plan_approval: string } | null
34
+ if (!recipient || recipient.plan_approval !== "pending") {
35
+ throw new Error(`Recipient "${args.to}" is not in plan approval mode (plan_approval is not pending).`)
36
+ }
37
+ if (args.approve) {
38
+ deps.db.run(
39
+ "UPDATE team_member SET plan_approval = 'approved', time_updated = ? WHERE team_id = ? AND name = ?",
40
+ [Date.now(), teamInfo.teamId, args.to]
41
+ )
42
+ messageText = `[Plan Approved] ${args.text}`
43
+ } else {
44
+ deps.db.run(
45
+ "UPDATE team_member SET plan_approval = 'rejected', time_updated = ? WHERE team_id = ? AND name = ?",
46
+ [Date.now(), teamInfo.teamId, args.to]
47
+ )
48
+ messageText = `[Plan Rejected: ${args.reject}] ${args.text}`
49
+ }
50
+ }
51
+
21
52
  const msgId = sendMessage(deps.db, {
22
53
  teamId: teamInfo.teamId,
23
54
  from: senderName,
24
55
  to: args.to,
25
- content: args.text,
56
+ content: messageText,
26
57
  })
27
58
 
28
59
  // Deliver via promptAsync — truncate lead-bound messages over 500 chars
29
60
  const isToLead = args.to === "lead"
30
61
  const MAX_LEAD_MSG = 500
31
62
  let deliveryText: string
32
- if (isToLead && args.text.length > MAX_LEAD_MSG) {
33
- const truncated = args.text.slice(0, MAX_LEAD_MSG)
63
+ if (isToLead && messageText.length > MAX_LEAD_MSG) {
64
+ const truncated = messageText.slice(0, MAX_LEAD_MSG)
34
65
  deliveryText = `[Team message from ${senderName}]: ${truncated}... (use team_results to read full message)`
35
66
  } else {
36
- deliveryText = `[Team message from ${senderName}]: ${args.text}`
67
+ deliveryText = `[Team message from ${senderName}]: ${messageText}`
37
68
  }
38
69
 
39
70
  await deps.client.session.promptAsync({
@@ -3,61 +3,93 @@ import { findTeamBySession } from "../types"
3
3
 
4
4
  /**
5
5
  * Execute the team_shutdown tool. Requests a teammate to shut down.
6
- * Sets status to shutdown_requested, calls abort, then polls session status.
7
- * If the session is already idle after abort, transitions directly to shutdown.
8
- * Otherwise the event hook handles the transition when the session goes idle.
6
+ *
7
+ * Graceful negotiation flow:
8
+ * - If member is already shutdown_requested, treat as force (second call).
9
+ * - If member is idle or force=true, abort immediately and set status='shutdown'.
10
+ * - If member is busy and force=false, send a shutdown message via promptAsync
11
+ * and set status='shutdown_requested'. The member finishes work and reports back.
9
12
  */
10
13
  export async function executeTeamShutdown(
11
14
  deps: ToolDeps,
12
- args: { member: string },
15
+ args: { member: string; force?: boolean },
13
16
  sessionId: string,
14
17
  ): Promise<string> {
15
18
  const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
16
19
  if (!teamInfo) throw new Error("This session is not in a team.")
17
20
  if (teamInfo.role !== "lead") throw new Error("Only the team lead can shut down teammates.")
18
21
 
19
- const member = deps.db.query("SELECT session_id, status FROM team_member WHERE team_id = ? AND name = ?")
20
- .get(teamInfo.teamId, args.member) as { session_id: string; status: string } | null
22
+ const member = deps.db.query("SELECT session_id, status, worktree_branch FROM team_member WHERE team_id = ? AND name = ?")
23
+ .get(teamInfo.teamId, args.member) as { session_id: string; status: string; worktree_branch: string | null } | null
21
24
  if (!member) throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`)
22
25
  if (member.status === "shutdown") throw new Error(`Teammate "${args.member}" is already shut down`)
23
26
 
24
- // Set to shutdown_requested
25
- deps.db.run(
26
- "UPDATE team_member SET status = 'shutdown_requested', time_updated = ? WHERE team_id = ? AND name = ?",
27
- [Date.now(), teamInfo.teamId, args.member]
28
- )
27
+ const force = args.force ?? false
29
28
 
30
- // Call abort on the member's session (best effort)
31
- try {
32
- await deps.client.session.abort({ path: { id: member.session_id } })
33
- } catch {
34
- // Abort failed — session may already be gone. Fire a warning toast.
35
- try {
36
- await deps.client.tui.showToast({
37
- title: "Team",
38
- message: `Failed to abort ${args.member} session — will rely on event hook`,
39
- variant: "warning",
40
- duration: 4000,
41
- })
42
- } catch { /* TUI may not be available */ }
29
+ // Second call on an already-requested member → force abort
30
+ if (member.status === "shutdown_requested") {
31
+ await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id)
32
+ const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : ""
33
+ return `Force shut down "${args.member}".${branchInfo}`
43
34
  }
44
35
 
45
- // Fallback: poll session status after abort. If already idle, transition
46
- // directly to shutdown. This handles the case where abort() on an
47
- // already-idle session doesn't fire a session.status event.
36
+ // Determine if member is idle or busy
37
+ let isIdle = false
48
38
  try {
49
39
  const statuses = await deps.client.session.status()
50
40
  const sessionStatus = statuses.data?.[member.session_id]
51
- if (!sessionStatus || sessionStatus.type === "idle") {
52
- deps.db.run(
53
- "UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?",
54
- [Date.now(), teamInfo.teamId, args.member]
55
- )
56
- return `Teammate "${args.member}" has been shut down.`
57
- }
41
+ isIdle = !sessionStatus || sessionStatus.type === "idle"
42
+ } catch {
43
+ // Status poll failed — assume busy, fall through to graceful path
44
+ }
45
+
46
+ if (isIdle || force) {
47
+ await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id)
48
+ const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : ""
49
+ return `Teammate "${args.member}" has been shut down.${branchInfo}`
50
+ }
51
+
52
+ // Busy + not force → graceful: send shutdown message, set shutdown_requested
53
+ try {
54
+ await deps.client.session.promptAsync({
55
+ path: { id: member.session_id },
56
+ body: {
57
+ parts: [{
58
+ type: "text",
59
+ text: `[Shutdown requested]: The lead has requested you shut down. Finish your current task, send your final findings to the lead via team_message, then stop.`,
60
+ }],
61
+ },
62
+ })
58
63
  } catch {
59
- // Status poll failed — fall through to eventual consistency
64
+ // promptAsync failed — best effort
60
65
  }
61
66
 
62
- return `Shutdown requested for ${args.member}. Will complete when current work finishes.`
67
+ deps.db.run(
68
+ "UPDATE team_member SET status = 'shutdown_requested', time_updated = ? WHERE team_id = ? AND name = ?",
69
+ [Date.now(), teamInfo.teamId, args.member],
70
+ )
71
+
72
+ return `Shutdown requested for ${args.member}. They will finish current work and shut down. Call team_shutdown with force: true to abort immediately.`
73
+ }
74
+
75
+ /**
76
+ * Abort a member's session and set their status to shutdown.
77
+ * Best-effort: if abort fails, we still mark them as shutdown.
78
+ */
79
+ async function abortAndShutdown(
80
+ deps: ToolDeps,
81
+ teamId: string,
82
+ memberName: string,
83
+ sessionId: string,
84
+ ): Promise<void> {
85
+ try {
86
+ await deps.client.session.abort({ path: { id: sessionId } })
87
+ } catch {
88
+ // Abort failed — session may already be gone
89
+ }
90
+
91
+ deps.db.run(
92
+ "UPDATE team_member SET status = 'shutdown', execution_status = 'idle', time_updated = ? WHERE team_id = ? AND name = ?",
93
+ [Date.now(), teamId, memberName],
94
+ )
63
95
  }