@hueyexe/opencode-ensemble 0.6.4 → 0.6.5

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/dist/index.js CHANGED
@@ -15366,7 +15366,8 @@ var MIGRATIONS = [
15366
15366
  ALTER TABLE team_member ADD COLUMN worktree_branch TEXT;`,
15367
15367
  `ALTER TABLE team_member ADD COLUMN plan_approval TEXT NOT NULL DEFAULT 'none'
15368
15368
  CHECK(plan_approval IN ('none', 'pending', 'approved', 'rejected'));`,
15369
- `ALTER TABLE team ADD COLUMN lead_agent TEXT;`
15369
+ `ALTER TABLE team ADD COLUMN lead_agent TEXT;`,
15370
+ `ALTER TABLE team_member ADD COLUMN workspace_id TEXT;`
15370
15371
  ];
15371
15372
  function applyMigrations(db) {
15372
15373
  const { user_version: current } = db.query("PRAGMA user_version").get();
@@ -15426,6 +15427,11 @@ function wrapThrowingClient(raw) {
15426
15427
  remove: throwing(r.worktree.remove.bind(r.worktree)),
15427
15428
  list: throwing(r.worktree.list.bind(r.worktree)),
15428
15429
  reset: throwing(r.worktree.reset.bind(r.worktree))
15430
+ },
15431
+ workspace: {
15432
+ create: throwing(r.experimental.workspace.create.bind(r.experimental.workspace)),
15433
+ remove: throwing(r.experimental.workspace.remove.bind(r.experimental.workspace)),
15434
+ list: throwing(r.experimental.workspace.list.bind(r.experimental.workspace))
15429
15435
  }
15430
15436
  };
15431
15437
  }
@@ -15906,6 +15912,19 @@ async function executeTeamSpawn(deps, args, sessionId) {
15906
15912
  } catch {}
15907
15913
  }
15908
15914
  }
15915
+ let workspaceId = null;
15916
+ if (worktreeDir && worktreeBranch) {
15917
+ try {
15918
+ log(`spawn:workspace:start name=${args.name}`);
15919
+ const wsResult = await withTimeout(deps.client.workspace.create({ branch: worktreeBranch }), getSpawnTimeout(), `workspace.create for "${args.name}"`);
15920
+ if (wsResult.data) {
15921
+ workspaceId = wsResult.data.id;
15922
+ }
15923
+ log(`spawn:workspace:done name=${args.name} id=${workspaceId}`);
15924
+ } catch (err) {
15925
+ log(`spawn:workspace:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`);
15926
+ }
15927
+ }
15909
15928
  const isReadOnly = args.agent === "plan" || args.agent === "explore";
15910
15929
  const TEAM_TOOLS = ["team_message", "team_broadcast", "team_tasks_list", "team_tasks_add", "team_tasks_complete", "team_claim"];
15911
15930
  const permission = isReadOnly ? [
@@ -15920,12 +15939,17 @@ async function executeTeamSpawn(deps, args, sessionId) {
15920
15939
  parentID: sessionId,
15921
15940
  title: `${args.name} (@${args.agent} teammate)`,
15922
15941
  ...permission ? { permission } : {},
15923
- ...worktreeDir ? { directory: worktreeDir } : {}
15942
+ ...workspaceId ? { workspaceID: workspaceId } : {}
15924
15943
  }), getSpawnTimeout(), `session.create for "${args.name}"`);
15925
15944
  childSessionId = createResult.data?.id;
15926
15945
  log(`spawn:session:done name=${args.name} sessionId=${childSessionId}`);
15927
15946
  } catch (err) {
15928
15947
  log(`spawn:session:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`);
15948
+ if (workspaceId) {
15949
+ try {
15950
+ await deps.client.workspace.remove({ id: workspaceId });
15951
+ } catch {}
15952
+ }
15929
15953
  if (worktreeDir) {
15930
15954
  try {
15931
15955
  await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
@@ -15934,6 +15958,11 @@ async function executeTeamSpawn(deps, args, sessionId) {
15934
15958
  throw new Error(`Failed to create session for teammate "${args.name}": ${err instanceof Error ? err.message : String(err)}`);
15935
15959
  }
15936
15960
  if (!childSessionId) {
15961
+ if (workspaceId) {
15962
+ try {
15963
+ await deps.client.workspace.remove({ id: workspaceId });
15964
+ } catch {}
15965
+ }
15937
15966
  if (worktreeDir) {
15938
15967
  try {
15939
15968
  await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } });
@@ -15943,15 +15972,19 @@ async function executeTeamSpawn(deps, args, sessionId) {
15943
15972
  }
15944
15973
  const planApproval = usePlanApproval ? "pending" : "none";
15945
15974
  const now = Date.now();
15946
- deps.db.run(`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)
15947
- VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, planApproval, now, now]);
15975
+ deps.db.run(`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)
15976
+ VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?, ?)`, [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]);
15948
15977
  deps.registry.register(teamInfo.teamId, args.name, childSessionId);
15949
15978
  const context = [
15950
15979
  `You are "${args.name}", a teammate in team "${teamInfo.teamName}".`,
15951
15980
  `Your agent type is "${args.agent}".`
15952
15981
  ];
15953
- if (worktreeBranch) {
15954
- context.push(`You are working on branch "${worktreeBranch}" in your own worktree. Your changes are isolated from other teammates.`);
15982
+ if (worktreeBranch && worktreeDir && !workspaceId) {
15983
+ context.push(`You are working on branch "${worktreeBranch}" in your own worktree at: ${worktreeDir}`, `Your changes are isolated from other teammates.`, `IMPORTANT: All file operations and shell commands MUST target your worktree directory.`, `Before running shell commands, cd to: ${worktreeDir}`);
15984
+ } else if (worktreeBranch && worktreeDir) {
15985
+ context.push(`You are working on branch "${worktreeBranch}" in your own isolated worktree.`, `Your changes are isolated from other teammates.`);
15986
+ } else if (worktreeBranch) {
15987
+ context.push(`You are working on branch "${worktreeBranch}". Your changes are isolated from other teammates.`);
15955
15988
  }
15956
15989
  if (usePlanApproval) {
15957
15990
  context.push("", "IMPORTANT: You are in PLAN MODE.", "Read and explore the codebase, then send your implementation plan to the lead via team_message.", "Do NOT write or modify any files until the lead approves your plan.", "Wait for the lead's approval message before proceeding with implementation.");
@@ -15974,6 +16007,9 @@ async function executeTeamSpawn(deps, args, sessionId) {
15974
16007
  deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId]);
15975
16008
  deps.registry.unregister(childSessionId);
15976
16009
  deps.client.session.abort({ sessionID: childSessionId }).catch(() => {});
16010
+ if (workspaceId) {
16011
+ deps.client.workspace.remove({ id: workspaceId }).catch(() => {});
16012
+ }
15977
16013
  if (worktreeDir) {
15978
16014
  deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => {});
15979
16015
  }
@@ -16149,7 +16185,7 @@ async function executeTeamCleanup(deps, args, sessionId) {
16149
16185
  throw new Error("This session is not in a team.");
16150
16186
  if (teamInfo.role !== "lead")
16151
16187
  throw new Error("Only the team lead can clean up the team.");
16152
- const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch FROM team_member WHERE team_id = ?").all(teamInfo.teamId);
16188
+ const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch, workspace_id FROM team_member WHERE team_id = ?").all(teamInfo.teamId);
16153
16189
  const active = members.filter((m) => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error");
16154
16190
  if (active.length > 0 && !args.force) {
16155
16191
  const names = active.map((m) => m.name).join(", ");
@@ -16164,9 +16200,16 @@ async function executeTeamCleanup(deps, args, sessionId) {
16164
16200
  }
16165
16201
  const branches = [];
16166
16202
  for (const member of members) {
16203
+ if (member.workspace_id) {
16204
+ try {
16205
+ await deps.client.workspace.remove({ id: member.workspace_id });
16206
+ deps.db.run("UPDATE team_member SET workspace_id = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name]);
16207
+ } catch {}
16208
+ }
16167
16209
  if (member.worktree_dir) {
16168
16210
  try {
16169
16211
  await deps.client.worktree.remove({ worktreeRemoveInput: { directory: member.worktree_dir } });
16212
+ deps.db.run("UPDATE team_member SET worktree_dir = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name]);
16170
16213
  } catch {}
16171
16214
  }
16172
16215
  if (member.worktree_branch) {
@@ -16638,10 +16681,13 @@ var plugin = async (input) => {
16638
16681
  output.env.ENSEMBLE_ROLE = teamInfo.role;
16639
16682
  if (teamInfo.memberName) {
16640
16683
  output.env.ENSEMBLE_MEMBER = teamInfo.memberName;
16641
- const member = db.query("SELECT worktree_branch FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, teamInfo.memberName);
16684
+ const member = db.query("SELECT worktree_branch, worktree_dir FROM team_member WHERE team_id = ? AND name = ?").get(teamInfo.teamId, teamInfo.memberName);
16642
16685
  if (member?.worktree_branch) {
16643
16686
  output.env.ENSEMBLE_BRANCH = member.worktree_branch;
16644
16687
  }
16688
+ if (member?.worktree_dir) {
16689
+ output.env.ENSEMBLE_WORKTREE_DIR = member.worktree_dir;
16690
+ }
16645
16691
  }
16646
16692
  },
16647
16693
  tool: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hueyexe/opencode-ensemble",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "Agent teams for OpenCode — parallel agents with peer-to-peer communication, shared tasks, and coordinated execution",
5
5
  "module": "src/index.ts",
6
6
  "main": "dist/index.js",
package/src/client.ts CHANGED
@@ -25,6 +25,7 @@ interface RawClient {
25
25
  session: { create: SdkMethod; promptAsync: SdkMethod; abort: SdkMethod; status: SdkMethod }
26
26
  tui: { showToast: SdkMethod; selectSession: SdkMethod }
27
27
  worktree: { create: SdkMethod; remove: SdkMethod; list: SdkMethod; reset: SdkMethod }
28
+ experimental: { workspace: { create: SdkMethod; remove: SdkMethod; list: SdkMethod } }
28
29
  }
29
30
 
30
31
  /**
@@ -50,5 +51,10 @@ export function wrapThrowingClient(raw: unknown): PluginClient {
50
51
  list: throwing(r.worktree.list.bind(r.worktree)),
51
52
  reset: throwing(r.worktree.reset.bind(r.worktree)),
52
53
  },
54
+ workspace: {
55
+ create: throwing(r.experimental.workspace.create.bind(r.experimental.workspace)),
56
+ remove: throwing(r.experimental.workspace.remove.bind(r.experimental.workspace)),
57
+ list: throwing(r.experimental.workspace.list.bind(r.experimental.workspace)),
58
+ },
53
59
  } as PluginClient
54
60
  }
package/src/index.ts CHANGED
@@ -217,11 +217,14 @@ const plugin: Plugin = async (input) => {
217
217
  output.env.ENSEMBLE_ROLE = teamInfo.role
218
218
  if (teamInfo.memberName) {
219
219
  output.env.ENSEMBLE_MEMBER = teamInfo.memberName
220
- const member = db.query("SELECT worktree_branch FROM team_member WHERE team_id = ? AND name = ?")
221
- .get(teamInfo.teamId, teamInfo.memberName) as { worktree_branch: string | null } | null
220
+ const member = db.query("SELECT worktree_branch, worktree_dir FROM team_member WHERE team_id = ? AND name = ?")
221
+ .get(teamInfo.teamId, teamInfo.memberName) as { worktree_branch: string | null; worktree_dir: string | null } | null
222
222
  if (member?.worktree_branch) {
223
223
  output.env.ENSEMBLE_BRANCH = member.worktree_branch
224
224
  }
225
+ if (member?.worktree_dir) {
226
+ output.env.ENSEMBLE_WORKTREE_DIR = member.worktree_dir
227
+ }
225
228
  }
226
229
  },
227
230
 
package/src/schema.ts CHANGED
@@ -78,6 +78,8 @@ export const MIGRATIONS: string[] = [
78
78
  CHECK(plan_approval IN ('none', 'pending', 'approved', 'rejected'));`,
79
79
  // Migration 5: Track lead's agent mode so message delivery preserves it
80
80
  `ALTER TABLE team ADD COLUMN lead_agent TEXT;`,
81
+ // Migration 6: Track workspace ID for worktree-session binding
82
+ `ALTER TABLE team_member ADD COLUMN workspace_id TEXT;`,
81
83
  ]
82
84
 
83
85
  /**
@@ -15,8 +15,8 @@ export async function executeTeamCleanup(
15
15
  if (!teamInfo) throw new Error("This session is not in a team.")
16
16
  if (teamInfo.role !== "lead") throw new Error("Only the team lead can clean up the team.")
17
17
 
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 }>
18
+ const members = deps.db.query("SELECT name, session_id, status, worktree_dir, worktree_branch, workspace_id 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; workspace_id: string | null }>
20
20
 
21
21
  const active = members.filter(m => m.status !== "shutdown" && m.status !== "shutdown_requested" && m.status !== "error")
22
22
 
@@ -34,12 +34,19 @@ export async function executeTeamCleanup(
34
34
  }
35
35
  }
36
36
 
37
- // Remove worktrees and collect branches for merging
37
+ // Remove workspaces, worktrees, and collect branches for merging
38
38
  const branches: string[] = []
39
39
  for (const member of members) {
40
+ if (member.workspace_id) {
41
+ try {
42
+ await deps.client.workspace.remove({ id: member.workspace_id })
43
+ deps.db.run("UPDATE team_member SET workspace_id = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name])
44
+ } catch { /* best effort — workspace may already be gone */ }
45
+ }
40
46
  if (member.worktree_dir) {
41
47
  try {
42
48
  await deps.client.worktree.remove({ worktreeRemoveInput: { directory: member.worktree_dir } })
49
+ deps.db.run("UPDATE team_member SET worktree_dir = NULL WHERE team_id = ? AND name = ?", [teamInfo.teamId, member.name])
43
50
  } catch { /* best effort — worktree may already be gone */ }
44
51
  }
45
52
  if (member.worktree_branch) {
@@ -78,6 +78,26 @@ export async function executeTeamSpawn(
78
78
  }
79
79
  }
80
80
 
81
+ // Create workspace from worktree branch — links session to worktree directory.
82
+ // OQ-workspace: assumes workspace.create({ branch }) auto-links to the worktree at that branch.
83
+ let workspaceId: string | null = null
84
+ if (worktreeDir && worktreeBranch) {
85
+ try {
86
+ log(`spawn:workspace:start name=${args.name}`)
87
+ const wsResult = await withTimeout(
88
+ deps.client.workspace.create({ branch: worktreeBranch }),
89
+ getSpawnTimeout(), `workspace.create for "${args.name}"`
90
+ )
91
+ if (wsResult.data) {
92
+ workspaceId = wsResult.data.id
93
+ }
94
+ log(`spawn:workspace:done name=${args.name} id=${workspaceId}`)
95
+ } catch (err) {
96
+ log(`spawn:workspace:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`)
97
+ // Non-fatal — prompt-based CWD instruction is the fallback
98
+ }
99
+ }
100
+
81
101
  // Permission rules on session.create are the hard gate (server-enforced).
82
102
  // For read-only agents, deny write tools and explicitly allow team tools
83
103
  // so the agent's built-in restrictive permissions don't block them.
@@ -91,7 +111,8 @@ export async function executeTeamSpawn(
91
111
  ]
92
112
  : undefined
93
113
 
94
- // Create child session — use worktree directory if available
114
+ // Create child session — bind to workspace if available (server-enforced CWD isolation).
115
+ // Falls back to no workspace binding if workspace.create failed.
95
116
  let childSessionId: string | undefined
96
117
  try {
97
118
  log(`spawn:session:start name=${args.name}`)
@@ -100,7 +121,7 @@ export async function executeTeamSpawn(
100
121
  parentID: sessionId,
101
122
  title: `${args.name} (@${args.agent} teammate)`,
102
123
  ...(permission ? { permission } : {}),
103
- ...(worktreeDir ? { directory: worktreeDir } : {}),
124
+ ...(workspaceId ? { workspaceID: workspaceId } : {}),
104
125
  }),
105
126
  getSpawnTimeout(), `session.create for "${args.name}"`
106
127
  )
@@ -108,7 +129,10 @@ export async function executeTeamSpawn(
108
129
  log(`spawn:session:done name=${args.name} sessionId=${childSessionId}`)
109
130
  } catch (err) {
110
131
  log(`spawn:session:failed name=${args.name} err=${err instanceof Error ? err.message : String(err)}`)
111
- // Rollback worktree if session creation failed
132
+ // Rollback workspace and worktree if session creation failed
133
+ if (workspaceId) {
134
+ try { await deps.client.workspace.remove({ id: workspaceId }) } catch { /* best effort */ }
135
+ }
112
136
  if (worktreeDir) {
113
137
  try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
114
138
  }
@@ -116,6 +140,9 @@ export async function executeTeamSpawn(
116
140
  }
117
141
 
118
142
  if (!childSessionId) {
143
+ if (workspaceId) {
144
+ try { await deps.client.workspace.remove({ id: workspaceId }) } catch { /* best effort */ }
145
+ }
119
146
  if (worktreeDir) {
120
147
  try { await deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }) } catch { /* best effort */ }
121
148
  }
@@ -126,9 +153,9 @@ export async function executeTeamSpawn(
126
153
  const planApproval = usePlanApproval ? "pending" : "none"
127
154
  const now = Date.now()
128
155
  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]
156
+ `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)
157
+ VALUES (?, ?, ?, ?, 'busy', 'starting', ?, ?, ?, ?, ?, ?, ?, ?)`,
158
+ [teamInfo.teamId, args.name, childSessionId, args.agent, args.model ?? null, args.prompt, worktreeDir, worktreeBranch, workspaceId, planApproval, now, now]
132
159
  )
133
160
 
134
161
  // Register in memory
@@ -140,8 +167,22 @@ export async function executeTeamSpawn(
140
167
  `Your agent type is "${args.agent}".`,
141
168
  ]
142
169
 
143
- if (worktreeBranch) {
144
- context.push(`You are working on branch "${worktreeBranch}" in your own worktree. Your changes are isolated from other teammates.`)
170
+ if (worktreeBranch && worktreeDir && !workspaceId) {
171
+ // Workspace binding failed — fallback to prompt-based CWD instruction
172
+ context.push(
173
+ `You are working on branch "${worktreeBranch}" in your own worktree at: ${worktreeDir}`,
174
+ `Your changes are isolated from other teammates.`,
175
+ `IMPORTANT: All file operations and shell commands MUST target your worktree directory.`,
176
+ `Before running shell commands, cd to: ${worktreeDir}`,
177
+ )
178
+ } else if (worktreeBranch && worktreeDir) {
179
+ // Workspace binding active — server handles CWD
180
+ context.push(
181
+ `You are working on branch "${worktreeBranch}" in your own isolated worktree.`,
182
+ `Your changes are isolated from other teammates.`,
183
+ )
184
+ } else if (worktreeBranch) {
185
+ context.push(`You are working on branch "${worktreeBranch}". Your changes are isolated from other teammates.`)
145
186
  }
146
187
 
147
188
  // Plan approval mode — teammate must send plan before writing
@@ -200,6 +241,9 @@ export async function executeTeamSpawn(
200
241
  deps.db.run("DELETE FROM team_member WHERE team_id = ? AND session_id = ?", [teamInfo.teamId, childSessionId])
201
242
  deps.registry.unregister(childSessionId)
202
243
  deps.client.session.abort({ sessionID: childSessionId }).catch(() => { /* best effort */ })
244
+ if (workspaceId) {
245
+ deps.client.workspace.remove({ id: workspaceId }).catch(() => { /* best effort */ })
246
+ }
203
247
  if (worktreeDir) {
204
248
  deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => { /* best effort */ })
205
249
  }
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
  /**