@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/README.md +25 -2
- package/dist/index.js +301 -63
- package/package.json +3 -3
- package/src/index.ts +53 -3
- package/src/recovery.ts +37 -0
- package/src/schema.ts +6 -0
- package/src/system-prompt.ts +111 -0
- package/src/tools/team-cleanup.ts +22 -5
- package/src/tools/team-message.ts +36 -5
- package/src/tools/team-shutdown.ts +68 -36
- package/src/tools/team-spawn.ts +103 -15
- package/src/tools/team-status.ts +5 -3
- package/src/types.ts +8 -0
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
|
-
|
|
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:
|
|
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 &&
|
|
33
|
-
const truncated =
|
|
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}]: ${
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
31
|
-
|
|
32
|
-
await deps.
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
//
|
|
46
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
//
|
|
64
|
+
// promptAsync failed — best effort
|
|
60
65
|
}
|
|
61
66
|
|
|
62
|
-
|
|
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
|
}
|