@hueyexe/opencode-ensemble 0.6.4 → 0.7.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.
@@ -75,6 +75,10 @@ export function buildLeadSystemPrompt(db: Database, teamId: string): string {
75
75
  "Do NOT poll team_status or team_tasks_list repeatedly — wait for messages.",
76
76
  "After spawning all teammates, tell the user what you've set up and wait.",
77
77
  "When all teammates finish, summarize results and suggest next steps.",
78
+ "",
79
+ "Before calling team_cleanup, verify teammates have committed their work.",
80
+ "team_shutdown will warn you if a teammate has uncommitted changes.",
81
+ "team_cleanup will block if any worktree has uncommitted changes — merge or commit first.",
78
82
  )
79
83
 
80
84
  return lines.join("\n")
@@ -0,0 +1,39 @@
1
+ import type { ToolDeps } from "../types"
2
+ import { findTeamBySession } from "../types"
3
+
4
+ /** Function type for dirty worktree check — injectable for testing. */
5
+ export type IsDirtyFn = (dir: string) => Promise<boolean>
6
+
7
+ /** Check if a worktree directory has uncommitted changes via git status. */
8
+ export async function checkWorktreeDirty(dir: string): Promise<boolean> {
9
+ try {
10
+ const proc = Bun.spawn(["git", "-C", dir, "status", "--porcelain"], { stdout: "pipe", stderr: "pipe" })
11
+ const output = await new Response(proc.stdout).text()
12
+ const exitCode = await proc.exited
13
+ if (exitCode !== 0) return false // git failed — assume clean
14
+ return output.trim().length > 0
15
+ } catch {
16
+ return false // can't check — assume clean
17
+ }
18
+ }
19
+
20
+ /** Validate that the session belongs to the team lead. Throws if not. */
21
+ export function requireLead(
22
+ deps: Pick<ToolDeps, "db" | "registry">,
23
+ sessionId: string,
24
+ ): { teamId: string; teamName: string } {
25
+ const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
26
+ if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
27
+ if (teamInfo.role !== "lead") throw new Error("Only the team lead can use this tool.")
28
+ return { teamId: teamInfo.teamId, teamName: teamInfo.teamName }
29
+ }
30
+
31
+ /** Validate that the session belongs to any team member (lead or teammate). Throws if not. */
32
+ export function requireTeamMember(
33
+ deps: Pick<ToolDeps, "db" | "registry">,
34
+ sessionId: string,
35
+ ): { teamId: string; teamName: string; role: "lead" | "member"; memberName?: string } {
36
+ const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
37
+ if (!teamInfo) throw new Error("This session is not in a team.")
38
+ return teamInfo
39
+ }
@@ -1,5 +1,5 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { findTeamBySession } from "../types"
2
+ import { requireTeamMember } from "./shared"
3
3
  import { broadcastMessage, markDelivered } from "../messaging"
4
4
  import { log } from "../log"
5
5
 
@@ -11,8 +11,7 @@ export async function executeTeamBroadcast(
11
11
  args: { text: string },
12
12
  sessionId: string,
13
13
  ): Promise<string> {
14
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
15
- if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
14
+ const teamInfo = requireTeamMember(deps, sessionId)
16
15
 
17
16
  const senderName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
18
17
 
@@ -1,5 +1,5 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { findTeamBySession } from "../types"
2
+ import { requireTeamMember } from "./shared"
3
3
 
4
4
  /**
5
5
  * Execute the team_claim tool. Atomically claims a pending task.
@@ -10,8 +10,7 @@ export async function executeTeamClaim(
10
10
  args: { task_id: string },
11
11
  sessionId: string,
12
12
  ): Promise<string> {
13
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
14
- if (!teamInfo) throw new Error("This session is not in a team.")
13
+ const teamInfo = requireTeamMember(deps, sessionId)
15
14
 
16
15
  const claimerName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
17
16
 
@@ -1,22 +1,24 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { findTeamBySession } from "../types"
2
+ import { requireLead, checkWorktreeDirty } from "./shared"
3
+ import type { IsDirtyFn } from "./shared"
4
+ import { log } from "../log"
3
5
 
4
6
  /**
5
7
  * Execute the team_cleanup tool. Archives the team and cleans up resources.
6
- * Removes worktrees and lists branches for the lead to merge.
7
- * If force=true, aborts all active member sessions first.
8
+ * Checks for uncommitted changes BEFORE aborting sessions or removing worktrees.
9
+ * If force=true, aborts active sessions but still blocks on dirty worktrees.
10
+ * Pass acknowledge_uncommitted=true to remove dirty worktrees.
8
11
  */
9
12
  export async function executeTeamCleanup(
10
13
  deps: ToolDeps,
11
- args: { force: boolean },
14
+ args: { force: boolean; acknowledge_uncommitted?: boolean },
12
15
  sessionId: string,
16
+ isDirty: IsDirtyFn = checkWorktreeDirty,
13
17
  ): Promise<string> {
14
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
15
- if (!teamInfo) throw new Error("This session is not in a team.")
16
- if (teamInfo.role !== "lead") throw new Error("Only the team lead can clean up the team.")
18
+ const teamInfo = requireLead(deps, sessionId)
17
19
 
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 }>
20
+ const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch, workspace_id FROM team_member WHERE team_id = ?")
21
+ .all(teamInfo.teamId) as Array<{ name: string; session_id: string; status: string; worktree_dir: string | null; worktree_branch: string | null; workspace_id: string | null }>
20
22
 
21
23
  const active = members.filter(m => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error")
22
24
 
@@ -25,7 +27,28 @@ export async function executeTeamCleanup(
25
27
  throw new Error(`Cannot clean up team "${teamInfo.teamName}": ${active.length} member(s) still active: ${names}. Use team_shutdown on each member first, or call team_cleanup with force: true to abort them immediately.`)
26
28
  }
27
29
 
28
- // Force-abort active members
30
+ // Check for uncommitted changes BEFORE aborting sessions — agents can still commit if warned
31
+ if (!args.acknowledge_uncommitted) {
32
+ const dirty: Array<{ name: string; branch: string }> = []
33
+ for (const member of members) {
34
+ if (member.worktree_dir) {
35
+ try {
36
+ if (await isDirty(member.worktree_dir)) {
37
+ dirty.push({ name: member.name, branch: member.worktree_branch ?? "unknown" })
38
+ }
39
+ } catch {
40
+ log(`cleanup:dirty-check:failed name=${member.name}`)
41
+ }
42
+ }
43
+ }
44
+
45
+ if (dirty.length > 0) {
46
+ const warnings = dirty.map(d => ` - ${d.name} (branch: ${d.branch})`).join("\n")
47
+ return `Warning: ${dirty.length} teammate(s) have uncommitted changes in their worktrees:\n${warnings}\n\nCommit or merge their work first, then call team_cleanup with acknowledge_uncommitted: true to proceed.`
48
+ }
49
+ }
50
+
51
+ // Force-abort active members (only reached if worktrees are clean or acknowledged)
29
52
  if (args.force) {
30
53
  for (const member of active) {
31
54
  try {
@@ -34,12 +57,19 @@ export async function executeTeamCleanup(
34
57
  }
35
58
  }
36
59
 
37
- // Remove worktrees and collect branches for merging
60
+ // Remove workspaces, worktrees, and collect branches for merging
38
61
  const branches: string[] = []
39
62
  for (const member of members) {
63
+ if (member.workspace_id) {
64
+ try {
65
+ await deps.client.workspace.remove({ id: member.workspace_id })
66
+ deps.db.run("UPDATE team_member SET workspace_id = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name])
67
+ } catch { /* best effort — workspace may already be gone */ }
68
+ }
40
69
  if (member.worktree_dir) {
41
70
  try {
42
71
  await deps.client.worktree.remove({ worktreeRemoveInput: { directory: member.worktree_dir } })
72
+ deps.db.run("UPDATE team_member SET worktree_dir = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name])
43
73
  } catch { /* best effort — worktree may already be gone */ }
44
74
  }
45
75
  if (member.worktree_branch) {
@@ -1,5 +1,6 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { findTeamBySession, resolveRecipientSession } from "../types"
2
+ import { resolveRecipientSession } from "../types"
3
+ import { requireTeamMember } from "./shared"
3
4
  import { sendMessage, markDelivered } from "../messaging"
4
5
  import { log } from "../log"
5
6
 
@@ -12,8 +13,7 @@ export async function executeTeamMessage(
12
13
  args: { to: string; text: string; approve?: boolean; reject?: string },
13
14
  sessionId: string,
14
15
  ): Promise<string> {
15
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
16
- if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
16
+ const teamInfo = requireTeamMember(deps, sessionId)
17
17
 
18
18
  const senderName = teamInfo.role === "lead" ? "lead" : (teamInfo.memberName ?? "unknown")
19
19
 
@@ -1,5 +1,5 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { findTeamBySession } from "../types"
2
+ import { requireTeamMember } from "./shared"
3
3
 
4
4
  /** Row shape for unread messages query. */
5
5
  interface UnreadMessageRow {
@@ -18,8 +18,7 @@ export async function executeTeamResults(
18
18
  args: { from?: string },
19
19
  sessionId: string,
20
20
  ): Promise<string> {
21
- const team = findTeamBySession(deps.db, deps.registry, sessionId)
22
- if (!team) throw new Error("not in a team")
21
+ const team = requireTeamMember(deps, sessionId)
23
22
 
24
23
  const rows = args.from
25
24
  ? (deps.db
@@ -1,5 +1,6 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { findTeamBySession } from "../types"
2
+ import { requireLead, checkWorktreeDirty } from "./shared"
3
+ import type { IsDirtyFn } from "./shared"
3
4
 
4
5
  /**
5
6
  * Execute the team_shutdown tool. Requests a teammate to shut down.
@@ -9,18 +10,19 @@ import { findTeamBySession } from "../types"
9
10
  * - If member is idle or force=true, abort immediately and set status='shutdown'.
10
11
  * - If member is busy and force=false, send a shutdown message via promptAsync
11
12
  * and set status='shutdown_requested'. The member finishes work and reports back.
13
+ *
14
+ * Warns if the member's worktree has uncommitted changes.
12
15
  */
13
16
  export async function executeTeamShutdown(
14
17
  deps: ToolDeps,
15
18
  args: { member: string; force?: boolean },
16
19
  sessionId: string,
20
+ isDirty: IsDirtyFn = checkWorktreeDirty,
17
21
  ): Promise<string> {
18
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
19
- if (!teamInfo) throw new Error("This session is not in a team.")
20
- if (teamInfo.role !== "lead") throw new Error("Only the team lead can shut down teammates.")
22
+ const teamInfo = requireLead(deps, sessionId)
21
23
 
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
24
+ const member = deps.db.query("SELECT session_id, status, worktree_branch, worktree_dir FROM team_member WHERE team_id = ? AND name = ?")
25
+ .get(teamInfo.teamId, args.member) as { session_id: string; status: string; worktree_branch: string | null; worktree_dir: string | null } | null
24
26
  if (!member) throw new Error(`Teammate "${args.member}" not found in team "${teamInfo.teamName}"`)
25
27
  if (member.status === "shutdown") throw new Error(`Teammate "${args.member}" is already shut down`)
26
28
 
@@ -30,7 +32,8 @@ export async function executeTeamShutdown(
30
32
  if (member.status === "shutdown_requested") {
31
33
  await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id)
32
34
  const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : ""
33
- return `Force shut down "${args.member}".${branchInfo}`
35
+ const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty)
36
+ return `Force shut down "${args.member}".${branchInfo}${dirtyWarning}`
34
37
  }
35
38
 
36
39
  // Determine if member is idle or busy
@@ -46,7 +49,8 @@ export async function executeTeamShutdown(
46
49
  if (isIdle || force) {
47
50
  await abortAndShutdown(deps, teamInfo.teamId, args.member, member.session_id)
48
51
  const branchInfo = member.worktree_branch ? ` Changes on branch: ${member.worktree_branch}` : ""
49
- return `Teammate "${args.member}" has been shut down.${branchInfo}`
52
+ const dirtyWarning = await getDirtyWarning(member.worktree_dir, args.member, isDirty)
53
+ return `Teammate "${args.member}" has been shut down.${branchInfo}${dirtyWarning}`
50
54
  }
51
55
 
52
56
  // Busy + not force → graceful: send shutdown message, set shutdown_requested
@@ -91,3 +95,14 @@ async function abortAndShutdown(
91
95
  [Date.now(), teamId, memberName],
92
96
  )
93
97
  }
98
+
99
+ /** Build a warning suffix if the member's worktree has uncommitted changes. */
100
+ async function getDirtyWarning(worktreeDir: string | null, memberName: string, isDirty: IsDirtyFn): Promise<string> {
101
+ if (!worktreeDir) return ""
102
+ try {
103
+ if (await isDirty(worktreeDir)) {
104
+ return `\n\nWarning: ${memberName} has uncommitted changes in their worktree. Commit or merge before calling team_cleanup.`
105
+ }
106
+ } catch { /* best effort */ }
107
+ return ""
108
+ }
@@ -1,6 +1,6 @@
1
1
  import type { ToolDeps, PermissionRule } from "../types"
2
2
  import { validateMemberName } from "../util"
3
- import { findTeamBySession } from "../types"
3
+ import { requireLead } from "./shared"
4
4
  import { sendMessage } from "../messaging"
5
5
  import { log } from "../log"
6
6
 
@@ -9,6 +9,11 @@ function getSpawnTimeout(): number {
9
9
  return Number(process.env.SPAWN_TIMEOUT_MS) || 120_000
10
10
  }
11
11
 
12
+ /** Returns true if the directory is already inside an OpenCode worktree. */
13
+ function isWorktreeDirectory(dir: string): boolean {
14
+ return dir.includes("/opencode/worktree/")
15
+ }
16
+
12
17
  /** Race a promise against a timeout. Throws if the timeout fires first. Cleans up timer on resolution. */
13
18
  function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
14
19
  let timer: ReturnType<typeof setTimeout>
@@ -34,16 +39,14 @@ export async function executeTeamSpawn(
34
39
  const nameError = validateMemberName(args.name)
35
40
  if (nameError) throw new Error(nameError)
36
41
 
37
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
38
- if (!teamInfo) throw new Error("This session is not in a team. Use team_create first.")
39
- if (teamInfo.role !== "lead") throw new Error("Only the team lead can spawn teammates.")
42
+ const teamInfo = requireLead(deps, sessionId)
40
43
 
41
44
  // Check duplicate name
42
45
  const existing = deps.db.query("SELECT name FROM team_member WHERE team_id = ? AND name = ?")
43
46
  .get(teamInfo.teamId, args.name)
44
47
  if (existing) throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`)
45
48
 
46
- const useWorktree = args.worktree !== false
49
+ const useWorktree = args.worktree !== false && !isWorktreeDirectory(deps.directory)
47
50
  const usePlanApproval = args.plan_approval === true
48
51
 
49
52
  log(`spawn:start name=${args.name} agent=${args.agent} worktree=${useWorktree}`)
@@ -78,20 +81,55 @@ export async function executeTeamSpawn(
78
81
  }
79
82
  }
80
83
 
84
+ // Create workspace from worktree branch — links session to worktree directory.
85
+ // OQ-workspace: assumes workspace.create({ branch }) auto-links to the worktree at that branch.
86
+ let workspaceId: string | null = null
87
+ if (worktreeDir && worktreeBranch) {
88
+ try {
89
+ log(`spawn:workspace:start name=${args.name}`)
90
+ const wsResult = await withTimeout(
91
+ deps.client.workspace.create({ branch: worktreeBranch }),
92
+ getSpawnTimeout(), `workspace.create for "${args.name}"`
93
+ )
94
+ if (wsResult.data) {
95
+ workspaceId = wsResult.data.id
96
+ }
97
+ log(`spawn:workspace:done name=${args.name} id=${workspaceId}`)
98
+ } catch (err) {
99
+ log(`spawn:workspace:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`)
100
+ // Non-fatal — prompt-based CWD instruction is the fallback
101
+ }
102
+ }
103
+
81
104
  // Permission rules on session.create are the hard gate (server-enforced).
82
- // For read-only agents, deny write tools and explicitly allow team tools
83
- // so the agent's built-in restrictive permissions don't block them.
105
+ // For read-only agents, deny write tools and explicitly allow team tools.
106
+ // For all agents with worktrees, allowlist the worktree path for edit/bash.
84
107
  const isReadOnly = args.agent === "plan" || args.agent === "explore"
85
108
  const TEAM_TOOLS = ["team_message", "team_broadcast", "team_tasks_list", "team_tasks_add", "team_tasks_complete", "team_claim"] as const
86
- const permission: PermissionRule[] | undefined = isReadOnly
87
- ? [
88
- { permission: "edit", pattern: "*", action: "deny" },
89
- { permission: "bash", pattern: "*", action: "deny" },
90
- ...TEAM_TOOLS.map(t => ({ permission: t, pattern: "*", action: "allow" as const })),
91
- ]
92
- : undefined
93
-
94
- // Create child session — use worktree directory if available
109
+ const permission: PermissionRule[] = []
110
+
111
+ if (worktreeDir) {
112
+ permission.push(
113
+ { permission: "edit", pattern: `${worktreeDir}/**`, action: "allow" },
114
+ )
115
+ if (!isReadOnly) {
116
+ permission.push({ permission: "bash", pattern: "*", action: "allow" })
117
+ }
118
+ }
119
+
120
+ if (isReadOnly) {
121
+ permission.push(
122
+ { permission: "edit", pattern: "*", action: "deny" },
123
+ { permission: "bash", pattern: "*", action: "deny" },
124
+ )
125
+ }
126
+
127
+ permission.push(
128
+ ...TEAM_TOOLS.map(t => ({ permission: t, pattern: "*", action: "allow" as const })),
129
+ )
130
+
131
+ // Create child session — bind to workspace if available (server-enforced CWD isolation).
132
+ // Falls back to no workspace binding if workspace.create failed.
95
133
  let childSessionId: string | undefined
96
134
  try {
97
135
  log(`spawn:session:start name=${args.name}`)
@@ -99,8 +137,8 @@ export async function executeTeamSpawn(
99
137
  deps.client.session.create({
100
138
  parentID: sessionId,
101
139
  title: `${args.name} (@${args.agent} teammate)`,
102
- ...(permission ? { permission } : {}),
103
- ...(worktreeDir ? { directory: worktreeDir } : {}),
140
+ permission,
141
+ ...(workspaceId ? { workspaceID: workspaceId } : {}),
104
142
  }),
105
143
  getSpawnTimeout(), `session.create for "${args.name}"`
106
144
  )
@@ -108,7 +146,10 @@ export async function executeTeamSpawn(
108
146
  log(`spawn:session:done name=${args.name} sessionId=${childSessionId}`)
109
147
  } catch (err) {
110
148
  log(`spawn:session:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`)
111
- // Rollback worktree if session creation failed
149
+ // Rollback workspace and worktree if session creation failed
150
+ if (workspaceId) {
151
+ try { await deps.client.workspace.remove({ id: workspaceId }) } catch { /* best effort */ }
152
+ }
112
153
  if (worktreeDir) {
113
154
  try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
114
155
  }
@@ -116,6 +157,9 @@ export async function executeTeamSpawn(
116
157
  }
117
158
 
118
159
  if (!childSessionId) {
160
+ if (workspaceId) {
161
+ try { await deps.client.workspace.remove({ id: workspaceId }) } catch { /* best effort */ }
162
+ }
119
163
  if (worktreeDir) {
120
164
  try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
121
165
  }
@@ -126,9 +170,9 @@ export async function executeTeamSpawn(
126
170
  const planApproval = usePlanApproval ? "pending" : "none"
127
171
  const now = Date.now()
128
172
  deps.db.run(
129
- `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)
130
- VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?)`,
131
- [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, planApproval, now, now]
173
+ `INSERT INTO team_member (team_id, name, session_id, agent, status, execution_status, model, prompt, worktree_dir, worktree_branch, workspace_id, plan_approval, time_created, time_updated)
174
+ VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?, ?)`,
175
+ [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]
132
176
  )
133
177
 
134
178
  // Register in memory
@@ -140,8 +184,22 @@ export async function executeTeamSpawn(
140
184
  `Your agent type is "${args.agent}".`,
141
185
  ]
142
186
 
143
- if (worktreeBranch) {
144
- context.push(`You are working on branch "${worktreeBranch}" in your own worktree. Your changes are isolated from other teammates.`)
187
+ if (worktreeBranch && worktreeDir && !workspaceId) {
188
+ // Workspace binding failed — fallback to prompt-based CWD instruction
189
+ context.push(
190
+ `You are working on branch "${worktreeBranch}" in your own worktree at: ${worktreeDir}`,
191
+ `Your changes are isolated from other teammates.`,
192
+ `IMPORTANT: All file operations and shell commands MUST target your worktree directory.`,
193
+ `Before running shell commands, cd to: ${worktreeDir}`,
194
+ )
195
+ } else if (worktreeBranch && worktreeDir) {
196
+ // Workspace binding active — server handles CWD
197
+ context.push(
198
+ `You are working on branch "${worktreeBranch}" in your own isolated worktree.`,
199
+ `Your changes are isolated from other teammates.`,
200
+ )
201
+ } else if (worktreeBranch) {
202
+ context.push(`You are working on branch "${worktreeBranch}". Your changes are isolated from other teammates.`)
145
203
  }
146
204
 
147
205
  // Plan approval mode — teammate must send plan before writing
@@ -155,20 +213,55 @@ export async function executeTeamSpawn(
155
213
  )
156
214
  }
157
215
 
216
+ if (isReadOnly) {
217
+ context.push(
218
+ "", "Tools available to you:",
219
+ "- team_message: send a message to the lead or another teammate",
220
+ "- team_broadcast: send a message to all team members",
221
+ "- team_tasks_list: view the shared team task board",
222
+ )
223
+ } else {
224
+ context.push(
225
+ "", "Tools available to you:",
226
+ "- team_message: send a message to the lead or another teammate",
227
+ "- team_broadcast: send a message to all team members",
228
+ "- team_tasks_list: view the shared team task board",
229
+ "- team_tasks_add: add tasks to the shared board",
230
+ "- team_tasks_complete: mark a task complete on the shared board",
231
+ "- team_claim: claim a pending task from the shared board",
232
+ )
233
+ }
234
+
235
+ context.push("", "When you finish your task:")
236
+ if (!isReadOnly && worktreeBranch) {
237
+ context.push(`1. Commit your changes: git add -A && git commit -m "your summary"`)
238
+ context.push("2. If you claimed a task, mark it complete using team_tasks_complete.")
239
+ context.push(
240
+ "3. Send ONE message to the lead using team_message with this format:",
241
+ )
242
+ } else if (!isReadOnly) {
243
+ context.push("1. If you claimed a task, mark it complete using team_tasks_complete.")
244
+ context.push(
245
+ "2. Send ONE message to the lead using team_message with this format:",
246
+ )
247
+ } else {
248
+ context.push(
249
+ "1. Send ONE message to the lead using team_message with this format:",
250
+ )
251
+ }
158
252
  context.push(
159
- "",
160
- "Tools available to you:",
161
- "- team_message: send a message to the lead or another teammate",
162
- "- team_broadcast: send a message to all team members",
163
- "- team_tasks_list: view the shared team task board",
164
- "- team_tasks_add: add tasks to the shared board",
165
- "- team_tasks_complete: mark a task complete on the shared board",
166
- "- team_claim: claim a pending task from the shared board",
167
- "",
168
- "When you finish your task:",
169
- "1. If you claimed a task, mark it complete using team_tasks_complete.",
170
- "2. Send ONE message to the lead with your findings using team_message.",
171
- "3. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.",
253
+ "<task-result>",
254
+ "<status>completed or failed</status>",
255
+ "<summary>One-line summary of what you did</summary>",
256
+ "<details>Full findings or changes made</details>",
257
+ )
258
+ if (worktreeBranch) {
259
+ context.push(`<branch>${worktreeBranch}</branch>`)
260
+ }
261
+ context.push("</task-result>")
262
+ const lastStep = !isReadOnly && worktreeBranch ? "4" : !isReadOnly ? "3" : "2"
263
+ context.push(
264
+ `${lastStep}. STOP. Do not send follow-up confirmations, status updates, or 'standing by' messages.`,
172
265
  "",
173
266
  "If you are blocked, send ONE message to the lead describing the specific blocker.",
174
267
  "",
@@ -200,6 +293,9 @@ export async function executeTeamSpawn(
200
293
  deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId])
201
294
  deps.registry.unregister(childSessionId)
202
295
  deps.client.session.abort({ sessionID: childSessionId }).catch(() => { /* best effort */ })
296
+ if (workspaceId) {
297
+ deps.client.workspace.remove({ id: workspaceId }).catch(() => { /* best effort */ })
298
+ }
203
299
  if (worktreeDir) {
204
300
  deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => { /* best effort */ })
205
301
  }
@@ -1,5 +1,5 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { findTeamBySession } from "../types"
2
+ import { requireTeamMember } from "./shared"
3
3
 
4
4
  /** Tracks the last time team_status was called per team ID (epoch ms). */
5
5
  export const lastCallTime = new Map<string, number>()
@@ -13,8 +13,7 @@ export async function executeTeamStatus(
13
13
  deps: ToolDeps,
14
14
  sessionId: string,
15
15
  ): Promise<string> {
16
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
17
- if (!teamInfo) throw new Error("This session is not in a team.")
16
+ const teamInfo = requireTeamMember(deps, sessionId)
18
17
 
19
18
  const now = Date.now()
20
19
  const last = lastCallTime.get(teamInfo.teamId)
@@ -24,9 +23,9 @@ export async function executeTeamStatus(
24
23
  lastCallTime.set(teamInfo.teamId, now)
25
24
 
26
25
  const members = deps.db.query(
27
- "SELECT name, session_id, agent, status, execution_status, worktree_branch, plan_approval FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
26
+ "SELECT name, session_id, agent, status, execution_status, worktree_branch, worktree_dir, plan_approval FROM team_member WHERE team_id = ? ORDER BY time_created ASC"
28
27
  ).all(teamInfo.teamId) as Array<{
29
- name: string; session_id: string; agent: string; status: string; execution_status: string; worktree_branch: string | null; plan_approval: string
28
+ name: string; session_id: string; agent: string; status: string; execution_status: string; worktree_branch: string | null; worktree_dir: string | null; plan_approval: string
30
29
  }>
31
30
 
32
31
  const tasks = deps.db.query(
@@ -46,6 +45,9 @@ export async function executeTeamStatus(
46
45
  const branch = m.worktree_branch ? ` branch: ${m.worktree_branch}` : ""
47
46
  const plan = m.plan_approval !== "none" ? `, plan: ${m.plan_approval}` : ""
48
47
  lines.push(` ${m.name} [${statusIcon}${plan}] agent: ${m.agent}${branch} session: ${m.session_id}`)
48
+ if (m.worktree_dir) {
49
+ lines.push(` worktree: ${m.worktree_dir}`)
50
+ }
49
51
  }
50
52
  }
51
53
 
@@ -1,5 +1,5 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { findTeamBySession } from "../types"
2
+ import { requireTeamMember } from "./shared"
3
3
  import { generateId } from "../util"
4
4
 
5
5
  interface TaskInput {
@@ -17,8 +17,7 @@ export async function executeTeamTasksAdd(
17
17
  args: { tasks: TaskInput[] },
18
18
  sessionId: string,
19
19
  ): Promise<string> {
20
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
21
- if (!teamInfo) throw new Error("This session is not in a team.")
20
+ const teamInfo = requireTeamMember(deps, sessionId)
22
21
 
23
22
  const ids: string[] = []
24
23
  const now = Date.now()
@@ -1,5 +1,5 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { findTeamBySession } from "../types"
2
+ import { requireTeamMember } from "./shared"
3
3
 
4
4
  /**
5
5
  * Execute the team_tasks_complete tool. Marks a task as completed
@@ -10,8 +10,7 @@ export async function executeTeamTasksComplete(
10
10
  args: { task_id: string },
11
11
  sessionId: string,
12
12
  ): Promise<string> {
13
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
14
- if (!teamInfo) throw new Error("This session is not in a team.")
13
+ const teamInfo = requireTeamMember(deps, sessionId)
15
14
 
16
15
  const task = deps.db.query("SELECT * FROM team_task WHERE id = ? AND team_id = ?")
17
16
  .get(args.task_id, teamInfo.teamId) as Record<string, unknown> | null
@@ -1,5 +1,5 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { findTeamBySession } from "../types"
2
+ import { requireTeamMember } from "./shared"
3
3
 
4
4
  /**
5
5
  * Execute the team_tasks_list tool. Shows all tasks on the shared board.
@@ -8,8 +8,7 @@ export async function executeTeamTasksList(
8
8
  deps: ToolDeps,
9
9
  sessionId: string,
10
10
  ): Promise<string> {
11
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
12
- if (!teamInfo) throw new Error("This session is not in a team.")
11
+ const teamInfo = requireTeamMember(deps, sessionId)
13
12
 
14
13
  const tasks = deps.db.query(
15
14
  "SELECT * FROM team_task WHERE team_id = ? ORDER BY time_created ASC"
@@ -1,5 +1,5 @@
1
1
  import type { ToolDeps } from "../types"
2
- import { findTeamBySession } from "../types"
2
+ import { requireTeamMember } from "./shared"
3
3
 
4
4
  /**
5
5
  * Execute the team_view tool. Navigates the TUI to a teammate's session
@@ -10,8 +10,7 @@ export async function executeTeamView(
10
10
  args: { member: string },
11
11
  sessionId: string,
12
12
  ): Promise<string> {
13
- const teamInfo = findTeamBySession(deps.db, deps.registry, sessionId)
14
- if (!teamInfo) throw new Error("This session is not in a team.")
13
+ const teamInfo = requireTeamMember(deps, sessionId)
15
14
 
16
15
  const member = deps.db.query("SELECT session_id, status, agent FROM team_member WHERE team_id = ? AND name = ?")
17
16
  .get(teamInfo.teamId, args.member) as { session_id: string; status: string; agent: string } | null
package/src/types.ts CHANGED
@@ -62,6 +62,11 @@ export interface PluginClient {
62
62
  list(): Promise<{ data?: Array<{ name: string; branch: string; directory: string }> }>
63
63
  reset(options: { worktreeResetInput?: { directory: string } }): Promise<unknown>
64
64
  }
65
+ workspace: {
66
+ create(options: { id?: string; type?: string; branch?: string | null; extra?: unknown | null }): Promise<{ data?: { id: string; type: string; branch: string | null; directory: string | null; projectID: string } }>
67
+ remove(options: { id: string }): Promise<unknown>
68
+ list(): Promise<{ data?: Array<{ id: string; type: string; branch: string | null; directory: string | null; projectID: string }> }>
69
+ }
65
70
  }
66
71
 
67
72
  /**