@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.
@@ -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
- // Create child session via SDK
26
- const createResult = await deps.client.session.create({
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
- const childSessionId = createResult.data?.id
30
- if (!childSessionId) throw new Error("Failed to create teammate session")
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 per AGENTS.md Teammate Context Message Design
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 abort the orphaned session
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
- return `Teammate "${args.name}" spawned (agent: ${args.agent}). They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
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
  }
@@ -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
- lines.push(` ${m.name} [${statusIcon}] agent: ${m.agent} session: ${m.session_id}`)
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
  /**