@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/tools/team-spawn.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import type { ToolDeps } from "../types"
|
|
2
2
|
import { generateId, validateMemberName } from "../util"
|
|
3
3
|
import { findTeamBySession } from "../types"
|
|
4
|
+
import path from "path"
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Execute the team_spawn tool. Creates a child session and starts a teammate.
|
|
8
|
+
* By default, each teammate gets their own git worktree for file isolation.
|
|
9
|
+
* Pass worktree: false for read-only agents that don't need isolation.
|
|
10
|
+
* Pass plan_approval: true to require the teammate to send a plan before writing.
|
|
7
11
|
*/
|
|
8
12
|
export async function executeTeamSpawn(
|
|
9
13
|
deps: ToolDeps,
|
|
10
|
-
args: { name: string; agent: string; prompt: string; model?: string; claim_task?: string },
|
|
14
|
+
args: { name: string; agent: string; prompt: string; model?: string; claim_task?: string; worktree?: boolean; plan_approval?: boolean },
|
|
11
15
|
sessionId: string,
|
|
12
16
|
): Promise<string> {
|
|
13
17
|
const nameError = validateMemberName(args.name)
|
|
@@ -22,28 +26,95 @@ export async function executeTeamSpawn(
|
|
|
22
26
|
.get(teamInfo.teamId, args.name)
|
|
23
27
|
if (existing) throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`)
|
|
24
28
|
|
|
25
|
-
|
|
26
|
-
const
|
|
29
|
+
const useWorktree = args.worktree !== false
|
|
30
|
+
const usePlanApproval = args.plan_approval === true
|
|
31
|
+
|
|
32
|
+
// Create worktree if enabled
|
|
33
|
+
let worktreeDir: string | null = null
|
|
34
|
+
let worktreeBranch: string | null = null
|
|
35
|
+
|
|
36
|
+
if (useWorktree) {
|
|
37
|
+
const worktreeName = `ensemble-${teamInfo.teamName}-${args.name}`
|
|
38
|
+
try {
|
|
39
|
+
const result = await deps.client.worktree.create({ name: worktreeName })
|
|
40
|
+
if (result.data) {
|
|
41
|
+
worktreeDir = result.data.directory
|
|
42
|
+
worktreeBranch = result.data.branch
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Worktree creation failed — fall back to shared directory with a warning
|
|
46
|
+
try {
|
|
47
|
+
await deps.client.tui.showToast({
|
|
48
|
+
title: "Team",
|
|
49
|
+
message: `Worktree creation failed for ${args.name}, using shared directory`,
|
|
50
|
+
variant: "warning",
|
|
51
|
+
duration: 4000,
|
|
52
|
+
})
|
|
53
|
+
} catch { /* TUI may not be available */ }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Create child session — use worktree directory if available
|
|
58
|
+
const createOpts: Record<string, unknown> = {
|
|
27
59
|
body: { parentID: sessionId, title: `${args.name} (@${args.agent} teammate)` },
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
|
|
60
|
+
}
|
|
61
|
+
if (worktreeDir) {
|
|
62
|
+
createOpts.directory = worktreeDir
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
let childSessionId: string | undefined
|
|
66
|
+
try {
|
|
67
|
+
const createResult = await deps.client.session.create(createOpts as Parameters<typeof deps.client.session.create>[0])
|
|
68
|
+
childSessionId = createResult.data?.id
|
|
69
|
+
} catch (err) {
|
|
70
|
+
// Rollback worktree if session creation failed
|
|
71
|
+
if (worktreeDir) {
|
|
72
|
+
try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
|
|
73
|
+
}
|
|
74
|
+
throw new Error(`Failed to create session for teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!childSessionId) {
|
|
78
|
+
if (worktreeDir) {
|
|
79
|
+
try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
|
|
80
|
+
}
|
|
81
|
+
throw new Error("Failed to create teammate session")
|
|
82
|
+
}
|
|
31
83
|
|
|
32
84
|
// Register in DB
|
|
85
|
+
const planApproval = usePlanApproval ? "pending" : "none"
|
|
33
86
|
const now = Date.now()
|
|
34
87
|
deps.db.run(
|
|
35
|
-
`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, time_created, time_updated)
|
|
36
|
-
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?)`,
|
|
37
|
-
[teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, now, now]
|
|
88
|
+
`INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, plan_approval, time_created, time_updated)
|
|
89
|
+
VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?)`,
|
|
90
|
+
[teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, planApproval, now, now]
|
|
38
91
|
)
|
|
39
92
|
|
|
40
93
|
// Register in memory
|
|
41
94
|
deps.registry.register(teamInfo.teamId, args.name, childSessionId)
|
|
42
95
|
|
|
43
|
-
// Build teammate context message
|
|
96
|
+
// Build teammate context message
|
|
44
97
|
const context = [
|
|
45
98
|
`You are "${args.name}", a teammate in team "${teamInfo.teamName}".`,
|
|
46
99
|
`Your agent type is "${args.agent}".`,
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
if (worktreeBranch) {
|
|
103
|
+
context.push(`You are working on branch "${worktreeBranch}" in your own worktree. Your changes are isolated from other teammates.`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Plan approval mode — teammate must send plan before writing
|
|
107
|
+
if (usePlanApproval) {
|
|
108
|
+
context.push(
|
|
109
|
+
"",
|
|
110
|
+
"IMPORTANT: You are in PLAN MODE.",
|
|
111
|
+
"Read and explore the codebase, then send your implementation plan to the lead via team_message.",
|
|
112
|
+
"Do NOT write or modify any files until the lead approves your plan.",
|
|
113
|
+
"Wait for the lead's approval message before proceeding with implementation.",
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
context.push(
|
|
47
118
|
"",
|
|
48
119
|
"Tools available to you:",
|
|
49
120
|
"- team_message: send a message to the lead or another teammate",
|
|
@@ -61,10 +132,24 @@ export async function executeTeamSpawn(
|
|
|
61
132
|
"If you are blocked, send ONE message to the lead describing the specific blocker.",
|
|
62
133
|
"",
|
|
63
134
|
"Your plain text output is NOT visible to the team. You MUST use team_message to communicate.",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
// Load project AGENTS.md if it exists
|
|
138
|
+
try {
|
|
139
|
+
const agentsPath = path.join(deps.directory, "AGENTS.md")
|
|
140
|
+
const file = Bun.file(agentsPath)
|
|
141
|
+
if (await file.exists()) {
|
|
142
|
+
const content = await file.text()
|
|
143
|
+
const truncated = content.length > 2000 ? content.slice(0, 2000) + "\n...(truncated)" : content
|
|
144
|
+
context.push("", "Project guidelines (from AGENTS.md):", truncated)
|
|
145
|
+
}
|
|
146
|
+
} catch { /* file may not exist or be unreadable */ }
|
|
147
|
+
|
|
148
|
+
context.push(
|
|
64
149
|
"",
|
|
65
150
|
"Your task:",
|
|
66
151
|
args.prompt,
|
|
67
|
-
|
|
152
|
+
)
|
|
68
153
|
|
|
69
154
|
if (args.claim_task) {
|
|
70
155
|
context.push("", `You have been assigned task ${args.claim_task}. Mark it complete when done.`)
|
|
@@ -73,20 +158,23 @@ export async function executeTeamSpawn(
|
|
|
73
158
|
const contextStr = context.join("\n")
|
|
74
159
|
|
|
75
160
|
// Fire-and-forget: send prompt to teammate session
|
|
76
|
-
// OQ-1: confirmed — promptAsync queues when session is busy (verified in live testing)
|
|
77
|
-
// OQ-10: confirmed — fresh session accepts promptAsync without session.init() (verified in live testing)
|
|
78
161
|
try {
|
|
79
162
|
await deps.client.session.promptAsync({
|
|
80
163
|
path: { id: childSessionId },
|
|
81
164
|
body: { parts: [{ type: "text", text: contextStr }] },
|
|
82
165
|
})
|
|
83
166
|
} catch (err) {
|
|
84
|
-
// Rollback: clean up DB, registry, and
|
|
167
|
+
// Rollback: clean up DB, registry, session, and worktree
|
|
85
168
|
deps.db.run("DELETE FROM team_member WHERE team_id = ? AND name = ?", [teamInfo.teamId, args.name])
|
|
86
169
|
deps.registry.unregister(childSessionId)
|
|
87
170
|
try { await deps.client.session.abort({ path: { id: childSessionId } }) } catch { /* best effort */ }
|
|
171
|
+
if (worktreeDir) {
|
|
172
|
+
try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
|
|
173
|
+
}
|
|
88
174
|
throw new Error(`Failed to send initial prompt to teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`)
|
|
89
175
|
}
|
|
90
176
|
|
|
91
|
-
|
|
177
|
+
const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : ""
|
|
178
|
+
const planInfo = usePlanApproval ? " [plan mode — will send plan for approval]" : ""
|
|
179
|
+
return `Teammate "${args.name}" spawned (agent: ${args.agent})${branchInfo}${planInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
|
|
92
180
|
}
|
package/src/tools/team-status.ts
CHANGED
|
@@ -24,9 +24,9 @@ export async function executeTeamStatus(
|
|
|
24
24
|
lastCallTime.set(teamInfo.teamId, now)
|
|
25
25
|
|
|
26
26
|
const members = deps.db.query(
|
|
27
|
-
"SELECT name, session_id, agent, status, execution_status FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
|
|
27
|
+
"SELECT name, session_id, agent, status, execution_status, worktree_branch, plan_approval FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
|
|
28
28
|
).all(teamInfo.teamId) as Array<{
|
|
29
|
-
name: string; session_id: string; agent: string; status: string; execution_status: string
|
|
29
|
+
name: string; session_id: string; agent: string; status: string; execution_status: string; worktree_branch: string | null; plan_approval: string
|
|
30
30
|
}>
|
|
31
31
|
|
|
32
32
|
const tasks = deps.db.query(
|
|
@@ -43,7 +43,9 @@ export async function executeTeamStatus(
|
|
|
43
43
|
lines.push("Members:")
|
|
44
44
|
for (const m of members) {
|
|
45
45
|
const statusIcon = m.status === "busy" ? "working" : m.status === "ready" ? "idle" : m.status
|
|
46
|
-
|
|
46
|
+
const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : ""
|
|
47
|
+
const plan = m.plan_approval !== "none" ? `, plan: ${m.plan_approval}` : ""
|
|
48
|
+
lines.push(` ${m.name} [${statusIcon}${plan}] agent: ${m.agent}${branch} session: ${m.session_id}`)
|
|
47
49
|
}
|
|
48
50
|
}
|
|
49
51
|
|
package/src/types.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface ToolDeps {
|
|
|
11
11
|
tracker: DescendantTracker
|
|
12
12
|
/** The OpenCode SDK client — used for session.create, promptAsync, abort, etc. */
|
|
13
13
|
client: PluginClient
|
|
14
|
+
/** The project root directory — used for reading AGENTS.md and other project files. */
|
|
15
|
+
directory: string
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
/**
|
|
@@ -37,6 +39,12 @@ export interface PluginClient {
|
|
|
37
39
|
}): Promise<unknown>
|
|
38
40
|
selectSession(options: { sessionID?: string }): Promise<unknown>
|
|
39
41
|
}
|
|
42
|
+
worktree: {
|
|
43
|
+
create(options: { name?: string; startCommand?: string }): Promise<{ data?: { name: string; branch: string; directory: string } }>
|
|
44
|
+
remove(options: { worktreeRemoveInput?: { directory: string } }): Promise<unknown>
|
|
45
|
+
list(): Promise<{ data?: Array<{ name: string; branch: string; directory: string }> }>
|
|
46
|
+
reset(options: { worktreeResetInput?: { directory: string } }): Promise<unknown>
|
|
47
|
+
}
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
/**
|