@hueyexe/opencode-ensemble 0.18.0 → 0.21.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.
@@ -4,23 +4,25 @@ import { requireLead } from "./shared"
4
4
  import { claimTask } from "./team-claim"
5
5
  import { notifyLead } from "../notify"
6
6
  import { releaseMemberTasks } from "../tasks"
7
- import { parseModelId } from "../member-model"
7
+ import { parseModelId, formatModelId, getSessionSelection } from "../member-model"
8
8
  import { log } from "../log"
9
9
  import type { EnsembleConfig } from "../config"
10
- import { getTeamResourceParts, teamWorktreeName } from "./merge-helper"
10
+ import { getTeamResourceParts, teamWorktreeName, preserveBranch, preservedBranchName } from "./merge-helper"
11
11
 
12
12
  /** Tracks consecutive spawn failures per team for circuit breaker. */
13
13
  export const spawnFailures = new Map<string, { count: number; lastError: string }>()
14
14
 
15
15
  /**
16
16
  * Resolve which model to use for a spawned agent.
17
- * Priority: explicit arg > modelsByAgent > rotation/random > defaultModel > undefined.
17
+ * Priority: explicit arg > modelsByAgent > rotation/random > defaultModel >
18
+ * the lead conversation's current model (fallbackModel) > undefined.
18
19
  */
19
20
  export function resolveModel(
20
21
  explicitModel: string | undefined,
21
22
  agentType: string,
22
23
  teamMemberCount: number,
23
24
  config: Required<EnsembleConfig>,
25
+ fallbackModel?: string,
24
26
  ): string | undefined {
25
27
  if (explicitModel) return explicitModel
26
28
  if (config.modelsByAgent[agentType]) return config.modelsByAgent[agentType]
@@ -31,7 +33,7 @@ export function resolveModel(
31
33
  return config.modelPool[Math.floor(Math.random() * config.modelPool.length)]
32
34
  }
33
35
  if (config.defaultModel) return config.defaultModel
34
- return undefined
36
+ return fallbackModel
35
37
  }
36
38
 
37
39
  /** Timeout for worktree.create and session.create to prevent hanging on git lock contention. */
@@ -87,7 +89,11 @@ export async function executeTeamSpawn(
87
89
  .get(teamInfo.teamId, args.name)
88
90
  if (existing) throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`)
89
91
 
90
- const isReadOnly = agent === "plan" || agent === "explore"
92
+ // Prefer the agent's own permission rules (V2) so any agent that denies
93
+ // edits — not only the built-in plan/explore — is treated as read-only and
94
+ // never gets a worktree or a shell allow. Fall back to the built-in names.
95
+ const isReadOnly = (await deps.client.agent?.readOnly(agent).catch(() => undefined))
96
+ ?? (agent === "plan" || agent === "explore")
91
97
  const useWorktree = args.worktree !== false && !isReadOnly && !isWorktreeDirectory(deps.directory)
92
98
  const usePlanApproval = args.plan_approval === true
93
99
 
@@ -167,6 +173,12 @@ export async function executeTeamSpawn(
167
173
  if (worktreeDir) {
168
174
  permission.push(
169
175
  { permission: "edit", pattern: `${worktreeDir}/**`, action: "allow" },
176
+ // V2 checks files inside the session's location as paths relative to
177
+ // it. session.create above binds the location to the worktree (via
178
+ // workspaceID, or directory as fallback), so this rule scopes edits
179
+ // to the worktree. Files outside the worktree remain gated by
180
+ // external_directory.
181
+ { permission: "edit", pattern: "*", action: "allow" },
170
182
  )
171
183
  if (!isReadOnly) {
172
184
  permission.push({ permission: "bash", pattern: "*", action: "allow" })
@@ -200,7 +212,14 @@ export async function executeTeamSpawn(
200
212
  parentID: sessionId,
201
213
  title: `${args.name} (@${agent} teammate)`,
202
214
  permission,
203
- ...(workspaceId ? { workspaceID: workspaceId } : {}),
215
+ ...(workspaceId
216
+ ? { workspaceID: workspaceId }
217
+ // Workspace binding is preferred; when it is unavailable, bind the
218
+ // session to the worktree directory directly. Without a location,
219
+ // the teammate's location is the project root, edit paths are
220
+ // evaluated relative to it, and the `edit * allow` rule below
221
+ // would grant edits to the whole shared tree (PR #46 review).
222
+ : worktreeDir ? { directory: worktreeDir } : {}),
204
223
  }),
205
224
  getSpawnTimeout(), `session.create for "${args.name}"`
206
225
  )
@@ -237,7 +256,21 @@ export async function executeTeamSpawn(
237
256
  const now = Date.now()
238
257
  // Resolve model before DB insert so the stored value matches what promptAsync uses
239
258
  const memberCount = (deps.db.query("SELECT COUNT(*) as c FROM team_member WHERE team_id = ?").get(teamInfo.teamId) as { c: number }).c
240
- const resolvedModel = resolveModel(args.model, agent, memberCount, deps.config)
259
+ let resolvedModel = resolveModel(args.model, agent, memberCount, deps.config)
260
+ if (!resolvedModel) {
261
+ // No explicit/config model: inherit the lead conversation's current model
262
+ // (issue #55). Read at spawn time, bounded so a slow lookup cannot hang
263
+ // the spawn; unknown selection falls through to the host default.
264
+ const leadSelection = await withTimeout(
265
+ getSessionSelection(deps.client, sessionId),
266
+ 5000,
267
+ "lead model lookup",
268
+ ).catch(() => undefined)
269
+ if (leadSelection?.model) {
270
+ resolvedModel = formatModelId(leadSelection.model)
271
+ log(`spawn:model:inherited name=${args.name} model=${resolvedModel}`)
272
+ }
273
+ }
241
274
  if (resolvedModel) log(`spawn:model name=${args.name} model=${resolvedModel}`)
242
275
 
243
276
  deps.db.run(
@@ -408,7 +441,7 @@ export async function executeTeamSpawn(
408
441
  parts: [{ type: "text", text: contextStr }],
409
442
  agent,
410
443
  ...(modelParam ? { model: modelParam } : {}),
411
- }).catch((err) => {
444
+ }).catch(async (err) => {
412
445
  const errMsg = err instanceof Error ? err.message : String(err)
413
446
  log(`spawn:promptAsync:failed name=${args.name} err=${errMsg} — rolling back`)
414
447
  try {
@@ -421,13 +454,6 @@ export async function executeTeamSpawn(
421
454
  [Date.now(), args.claim_task, args.name]
422
455
  )
423
456
  }
424
- deps.client.session.abort({ sessionID: childSessionId }).catch(() => { /* best effort */ })
425
- if (workspaceId) {
426
- deps.client.workspace.remove({ id: workspaceId }).catch(() => { /* best effort */ })
427
- }
428
- if (worktreeDir) {
429
- deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => { /* best effort */ })
430
- }
431
457
  const modelInfo = resolvedModel ? ` (model: ${resolvedModel})` : ""
432
458
  deps.client.tui.showToast({
433
459
  title: "Team",
@@ -441,10 +467,36 @@ export async function executeTeamSpawn(
441
467
  teamInfo.teamId,
442
468
  `Teammate "${args.name}" failed to start and was removed${modelInfo}. Error: ${errMsg}. You may retry the spawn.`,
443
469
  )
470
+
471
+ // promptAsync can reject after the server accepted the prompt, so the
472
+ // teammate may have run and committed. Preserve the branch before abort:
473
+ // session cleanup asynchronously deletes the worktree branch.
474
+ if (worktreeBranch) {
475
+ try {
476
+ const resource = getTeamResourceParts(deps.db, teamInfo.teamId)
477
+ const safeBranch = preservedBranchName(resource.projectName, resource.teamName, resource.teamId, args.name)
478
+ const preserved = await preserveBranch(worktreeBranch, safeBranch, deps.directory)
479
+ if (preserved) log(`spawn:rollback:branch:preserved src=${worktreeBranch} target=${safeBranch}`)
480
+ } catch { /* best effort — still abort/clean up */ }
481
+ }
482
+ deps.client.session.abort({ sessionID: childSessionId }).catch(() => { /* best effort */ })
483
+ if (workspaceId) {
484
+ deps.client.workspace.remove({ id: workspaceId }).catch(() => { /* best effort */ })
485
+ }
486
+ if (worktreeDir) {
487
+ deps.client.worktree.remove({ worktreeRemoveInput: { directory: worktreeDir } }).catch(() => { /* best effort */ })
488
+ }
444
489
  } catch { /* rollback failed — watchdog will clean up stale member */ }
445
490
  })
446
491
 
447
492
  const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : ""
493
+ // Isolation is a claim the lead relies on, so say plainly when it did not
494
+ // complete. The toast alone left "spawned" as the only signal (issue #40).
495
+ // Covers both failure modes: no worktree at all (shared directory) or a
496
+ // worktree whose branch could not be created (unmergeable detached HEAD).
497
+ const isolationInfo = useWorktree && !worktreeBranch
498
+ ? ` (WARNING: worktree isolation did not complete — ${args.name} may share your working directory or lack a mergeable branch; edits can collide and team_merge may fail)`
499
+ : ""
448
500
  const planInfo = usePlanApproval ? " [plan mode — will send plan for approval]" : ""
449
501
  const claimInfo = claimedTaskContent
450
502
  ? ` (claimed task: ${args.claim_task})`
@@ -452,5 +504,5 @@ export async function executeTeamSpawn(
452
504
  // Reset circuit breaker on success
453
505
  spawnFailures.delete(teamInfo.teamId)
454
506
  log(`spawn:done name=${args.name} sessionId=${childSessionId}`)
455
- return `Teammate "${args.name}" spawned (agent: ${agent})${branchInfo}${planInfo}${claimInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
507
+ return `Teammate "${args.name}" spawned (agent: ${agent})${branchInfo}${isolationInfo}${planInfo}${claimInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
456
508
  }
@@ -1,6 +1,7 @@
1
1
  import type { ToolDeps } from "../types"
2
2
  import { requireTeamMember } from "./shared"
3
3
  import { generateId } from "../util"
4
+ import { inspectTaskGraph, parseTaskDependencies, depthForDependencies } from "../task-graph"
4
5
 
5
6
  interface TaskInput {
6
7
  content: string
@@ -18,17 +19,69 @@ export async function executeTeamTasksAdd(
18
19
  sessionId: string,
19
20
  ): Promise<string> {
20
21
  const teamInfo = requireTeamMember(deps, sessionId)
22
+ const tasks = args.tasks.map(task => ({
23
+ ...task,
24
+ depends_on: [...new Set(task.depends_on ?? [])],
25
+ }))
26
+
27
+ // Reject unknown dependency IDs before inserting anything: a task blocked on
28
+ // an ID that never existed can never unblock (issue #40). IDs are generated
29
+ // here, so new tasks can only depend on existing ones and no cycle can form.
30
+ const unknown = new Set<string>()
31
+ for (const task of tasks) {
32
+ for (const depId of task.depends_on) {
33
+ const exists = deps.db.query("SELECT 1 FROM team_task WHERE id = ? AND team_id = ?").get(depId, teamInfo.teamId)
34
+ if (!exists) unknown.add(depId)
35
+ }
36
+ }
37
+ if (unknown.size > 0) {
38
+ throw new Error(
39
+ `Unknown task ID${unknown.size > 1 ? "s" : ""} in depends_on: ${[...unknown].join(", ")}. ` +
40
+ "Use IDs from team_tasks_list, or add the prerequisite tasks first and reference the IDs they return.",
41
+ )
42
+ }
43
+
44
+ // Optional depth guard (issue #42). Default is unlimited; when configured,
45
+ // reject the whole batch before any insert so a failed add is atomic.
46
+ const depthLimit = deps.config.taskDepthLimit
47
+ if (depthLimit > 0) {
48
+ const existing = deps.db.query("SELECT id, status, depends_on FROM team_task WHERE team_id = ?")
49
+ .all(teamInfo.teamId) as Array<{ id: string; status: string; depends_on: string | null }>
50
+ const graph = inspectTaskGraph(existing.map(row => ({
51
+ id: row.id,
52
+ status: row.status,
53
+ dependsOn: parseTaskDependencies(row.depends_on),
54
+ })), { activeOnly: true })
55
+
56
+ if (graph.cyclic.length > 0) {
57
+ throw new Error(
58
+ `Task graph has a dependency cycle (${graph.cyclic.join(", ")}). Repair it before adding more tasks.`,
59
+ )
60
+ }
61
+
62
+ for (const task of tasks) {
63
+ const depth = depthForDependencies(task.depends_on, graph)
64
+ if (depth <= depthLimit) continue
65
+ const resolution = deps.config.taskDepthLimitOverflowAction === "user_resolve"
66
+ ? "Ask the user whether to split the work into a shallower chain, then retry."
67
+ : "Split the work into shallower batches, add an independent task, or raise taskDepthLimit."
68
+ throw new Error(
69
+ `Task depth ${depth} exceeds taskDepthLimit ${depthLimit}. ` +
70
+ `The new task depends on ${task.depends_on.join(", ")}. ${resolution}`,
71
+ )
72
+ }
73
+ }
21
74
 
22
75
  const ids: string[] = []
23
76
  const now = Date.now()
24
77
 
25
- for (const task of args.tasks) {
78
+ for (const task of tasks) {
26
79
  const id = generateId("task")
27
- const depsJson = task.depends_on?.length ? JSON.stringify(task.depends_on) : null
80
+ const depsJson = task.depends_on.length ? JSON.stringify(task.depends_on) : null
28
81
 
29
82
  // Determine initial status — blocked if has unresolved dependencies
30
83
  let status = "pending"
31
- if (task.depends_on?.length) {
84
+ if (task.depends_on.length) {
32
85
  const resolved = task.depends_on.every(depId => {
33
86
  const dep = deps.db.query("SELECT status FROM team_task WHERE id = ? AND team_id = ?")
34
87
  .get(depId, teamInfo.teamId) as { status: string } | null
@@ -1,5 +1,6 @@
1
1
  import type { ToolDeps } from "../types"
2
2
  import { requireTeamMember } from "./shared"
3
+ import { parseTaskDependencies } from "../task-graph"
3
4
  import { log } from "../log"
4
5
 
5
6
  /**
@@ -51,7 +52,7 @@ export async function executeTeamTasksComplete(
51
52
  let unblocked = 0
52
53
  for (const t of allTasks) {
53
54
  if (t.status !== "blocked" || !t.depends_on) continue
54
- const depIds: string[] = JSON.parse(t.depends_on)
55
+ const depIds = parseTaskDependencies(t.depends_on)
55
56
  if (!depIds.includes(args.task_id)) continue
56
57
 
57
58
  const allResolved = depIds.every(depId => {
package/src/types.ts CHANGED
@@ -35,6 +35,10 @@ export interface PermissionRule {
35
35
  * Makes mocking trivial in tests.
36
36
  */
37
37
  export interface PluginClient {
38
+ /** Optional agent introspection (V2). Undefined answers fall back to name checks. */
39
+ agent?: {
40
+ readOnly(agent: string): Promise<boolean | undefined>
41
+ }
38
42
  session: {
39
43
  create(options: {
40
44
  parentID?: string
@@ -46,7 +50,7 @@ export interface PluginClient {
46
50
  promptAsync(options: {
47
51
  sessionID: string
48
52
  parts: Array<{ type: "text"; text: string }>
49
- model?: { providerID: string; modelID: string }
53
+ model?: { providerID: string; modelID: string; variant?: string }
50
54
  agent?: string
51
55
  tools?: Record<string, boolean>
52
56
  system?: string
package/src/v2-client.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import type { PluginClient } from "./types"
2
+ import { log } from "./log"
2
3
  import { runCommand } from "./process"
3
4
 
4
5
  /** V2 plugin context subset Ensemble needs (structural, mock-friendly). */
5
6
  export interface V2Context {
7
+ /** Plugin location; `project.id` is required by the 2.0.19+ worktree API. */
8
+ location?: { directory?: string; project?: { id?: string } }
6
9
  session: {
7
10
  create(input: Record<string, unknown>): Promise<{ id: string }>
8
11
  prompt(input: Record<string, unknown>): Promise<unknown>
@@ -17,12 +20,39 @@ export interface V2Context {
17
20
  worktree: {
18
21
  create(input: Record<string, unknown>): Promise<{ directory: string }>
19
22
  remove(input: Record<string, unknown>): Promise<unknown>
20
- list(): Promise<Array<{ directory: string }>>
21
- refresh(): Promise<unknown>
23
+ list(input?: Record<string, unknown>): Promise<Array<{ directory: string }>>
24
+ refresh(input?: Record<string, unknown>): Promise<unknown>
22
25
  }
23
26
  permission: {
24
- rules(input: Record<string, unknown>): Promise<unknown>
27
+ /** Present on 2.0.3–2.0.18 only; removed in 2.0.19 (see issue #40). */
28
+ rules?(input: Record<string, unknown>): Promise<unknown>
25
29
  }
30
+ /** Agent lookup (2.0.19+): exposes each agent's resolved permission rules. */
31
+ agent?: {
32
+ get(input: Record<string, unknown>): Promise<unknown>
33
+ }
34
+ }
35
+
36
+ type PermissionRuleV2 = { action?: unknown; resource?: unknown; effect?: unknown }
37
+
38
+ /** OpenCode wildcard match: `*` matches any run of characters. */
39
+ function wildcardMatch(value: string, pattern: string): boolean {
40
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")
41
+ return new RegExp(`^${escaped}$`).test(value)
42
+ }
43
+
44
+ /**
45
+ * True when an agent's own rules deny editing everywhere. OpenCode evaluates
46
+ * the last matching rule, so this mirrors `evaluate("edit", "*")` over the
47
+ * agent ruleset. Undefined when the rules carry no edit decision.
48
+ */
49
+ export function agentRulesAreReadOnly(rules: unknown): boolean | undefined {
50
+ if (!Array.isArray(rules)) return undefined
51
+ const match = [...(rules as PermissionRuleV2[])].reverse().find((rule) =>
52
+ typeof rule?.action === "string" && typeof rule?.resource === "string"
53
+ && wildcardMatch("edit", rule.action) && (rule.resource === "*" || rule.resource === "**"))
54
+ if (!match) return undefined
55
+ return match.effect === "deny"
26
56
  }
27
57
 
28
58
  /** Prefix marking a workspace id that is really a local directory (see below). */
@@ -32,6 +62,10 @@ const DIR_WORKSPACE_PREFIX = "v2dir:"
32
62
  export interface V2ClientDeps {
33
63
  /** Current branch checked out in a directory, or null when unknown. */
34
64
  gitBranch?: (directory: string) => Promise<string | null>
65
+ /** Create and check out a branch in a directory. Returns the effective
66
+ * branch name, or null when the branch could not be created or checked
67
+ * out — never record the requested name without this confirmation. */
68
+ gitCreateBranch?: (directory: string, branch: string) => Promise<string | null>
35
69
  /** Worktree directory currently on a branch, or null when none. */
36
70
  gitDir?: (branch: string) => Promise<string | null>
37
71
  /** RPC emitter for companion events (team_view navigation). */
@@ -49,6 +83,31 @@ async function gitBranchDefault(directory: string): Promise<string | null> {
49
83
  }
50
84
  }
51
85
 
86
+ /** Create and check out a branch in a directory via git. Returns the
87
+ * effective branch name, or null when neither create nor switch worked —
88
+ * callers must record the return value, not the requested name, so a
89
+ * failed create can never be recorded as a live branch. */
90
+ async function gitCreateBranchDefault(directory: string, branch: string): Promise<string | null> {
91
+ try {
92
+ await runCommand(["git", "switch", "-c", branch], { cwd: directory })
93
+ return branch
94
+ } catch (err) {
95
+ // The branch may already exist (re-spawn after the abort-cleanup race
96
+ // documented in AGENTS.md) — switch to it instead of recording a name
97
+ // with no ref behind it. Log both attempts; swallowing failures silently
98
+ // made team_merge squash a stale branch and silently lose new work
99
+ // (PR #46 review).
100
+ log(`v2-client:gitCreateBranch:create-failed directory=${directory} branch=${branch} err=${err instanceof Error ? err.message : String(err)} — trying existing branch`)
101
+ try {
102
+ await runCommand(["git", "switch", branch], { cwd: directory })
103
+ return branch
104
+ } catch (err2) {
105
+ log(`v2-client:gitCreateBranch:switch-failed directory=${directory} branch=${branch} err=${err2 instanceof Error ? err2.message : String(err2)}`)
106
+ return null
107
+ }
108
+ }
109
+ }
110
+
52
111
  /** Concatenate the text parts of a promptAsync payload into one string. */
53
112
  function joinParts(parts: Array<{ type: "text"; text: string }>): string {
54
113
  return parts
@@ -88,7 +147,24 @@ function joinParts(parts: Array<{ type: "text"; text: string }>): string {
88
147
  */
89
148
  export function createV2Client(ctx: V2Context, deps: V2ClientDeps = {}): PluginClient {
90
149
  const gitBranch = deps.gitBranch ?? gitBranchDefault
150
+ const gitCreateBranch = deps.gitCreateBranch ?? gitCreateBranchDefault
151
+ // 2.0.19+ worktree endpoints require the project; older hosts ignore it.
152
+ const project = (): Record<string, unknown> => {
153
+ const projectID = ctx.location?.project?.id
154
+ return projectID ? { projectID } : {}
155
+ }
91
156
  return {
157
+ agent: {
158
+ readOnly: async (agent: string) => {
159
+ if (!ctx.agent) return undefined
160
+ try {
161
+ const info = (await ctx.agent.get({ agentID: agent })) as { permissions?: unknown; data?: { permissions?: unknown } } | undefined
162
+ return agentRulesAreReadOnly(info?.permissions ?? info?.data?.permissions)
163
+ } catch {
164
+ return undefined
165
+ }
166
+ },
167
+ },
92
168
  session: {
93
169
  create: async (options) => {
94
170
  const input: Record<string, unknown> = { title: options.title }
@@ -114,11 +190,12 @@ export function createV2Client(ctx: V2Context, deps: V2ClientDeps = {}): PluginC
114
190
  }
115
191
  }
116
192
  const created = await ctx.session.create(input)
117
- if (permissions) {
118
- // Session.create permissions lose to agent rules (last match wins
119
- // and agent rules apply later), which strips team tools from
120
- // read-only agents. Re-assert here: rules() evaluates after the
121
- // agent's rules, restoring the tools. OQ-V2-rules.
193
+ if (permissions && typeof ctx.permission?.rules === "function") {
194
+ // 2.0.3–2.0.18 merged agent rules after session rules, so create()
195
+ // permissions lost to the agent's and had to be re-asserted. From
196
+ // 2.0.19 the session ruleset merges last (core permission.ts
197
+ // `merge(agent.permissions, session.permissions)` with last-match
198
+ // evaluation) and rules() no longer exists, so create() is enough.
122
199
  await ctx.permission.rules({ sessionID: created.id, permissions })
123
200
  }
124
201
  return { data: { id: created.id } }
@@ -138,7 +215,11 @@ export function createV2Client(ctx: V2Context, deps: V2ClientDeps = {}): PluginC
138
215
  if (options.model) {
139
216
  await ctx.session.switchModel({
140
217
  sessionID: options.sessionID,
141
- model: { providerID: options.model.providerID, id: options.model.modelID },
218
+ model: {
219
+ providerID: options.model.providerID,
220
+ id: options.model.modelID,
221
+ ...(options.model.variant ? { variant: options.model.variant } : {}),
222
+ },
142
223
  })
143
224
  }
144
225
  const text = joinParts(options.parts)
@@ -177,16 +258,24 @@ export function createV2Client(ctx: V2Context, deps: V2ClientDeps = {}): PluginC
177
258
  worktree: {
178
259
  create: async (options) => {
179
260
  const name = (options.worktreeCreateInput as { name?: string } | undefined)?.name ?? "ensemble-worktree"
180
- const created = await ctx.worktree.create({ name })
181
- const branch = (await gitBranch(created.directory)) ?? name
261
+ const created = await ctx.worktree.create({ ...project(), name })
262
+ let branch = await gitBranch(created.directory)
263
+ if (!branch) {
264
+ // 2.0.32+ creates worktrees detached; team_merge needs a named
265
+ // branch. Record the effective branch only — never the requested
266
+ // name — so a failed create cannot point team_merge at a stale
267
+ // same-named branch whose commits would silently shadow new work
268
+ // (PR #46 review).
269
+ branch = (await gitCreateBranch(created.directory, name)) ?? ""
270
+ }
182
271
  return { data: { name, branch, directory: created.directory } }
183
272
  },
184
273
  remove: async (options) => {
185
274
  const directory = (options.worktreeRemoveInput as { directory: string }).directory
186
- return ctx.worktree.remove({ directory, force: false })
275
+ return ctx.worktree.remove({ ...project(), directory, force: false })
187
276
  },
188
277
  list: async () => {
189
- const entries = await ctx.worktree.list()
278
+ const entries = await ctx.worktree.list(project())
190
279
  return {
191
280
  data: entries.map((entry) => ({
192
281
  name: entry.directory.split("/").pop() ?? entry.directory,
@@ -195,7 +284,7 @@ export function createV2Client(ctx: V2Context, deps: V2ClientDeps = {}): PluginC
195
284
  })),
196
285
  }
197
286
  },
198
- reset: async () => ctx.worktree.refresh(),
287
+ reset: async () => ctx.worktree.refresh(project()),
199
288
  },
200
289
  workspace: {
201
290
  create: async (options) => {
package/src/v2-setup.ts CHANGED
@@ -11,6 +11,7 @@ import type { ToolDeps } from "./types"
11
11
  import { ProgressTracker } from "./progress"
12
12
  import { checkToolIsolation, shouldNudgeIdleMember } from "./hooks"
13
13
  import { hasReportedCompletion } from "./messaging"
14
+ import { enrichToolError } from "./tool-arg-hint"
14
15
  import { rehydrateRegistry } from "./recovery"
15
16
  import { findTeamBySession } from "./types"
16
17
  import { loadConfig } from "./config"
@@ -24,7 +25,8 @@ import { vlog } from "./log"
24
25
 
25
26
  /** V2 setup context subset (structural, mock-friendly). */
26
27
  export interface V2SetupContext {
27
- location: { directory: string }
28
+ location: { directory: string; project?: { id?: string } }
29
+ agent?: V2Context["agent"]
28
30
  options: Record<string, unknown>
29
31
  session: V2Context["session"] & {
30
32
  hook(name: string, cb: (event: never) => unknown): Promise<{ dispose(): Promise<void> }>
@@ -251,6 +253,17 @@ export async function setupEnsemble(
251
253
  input: unknown
252
254
  status: string
253
255
  result?: { content?: string | Array<{ type?: string; text?: string }> }
256
+ error?: { message?: unknown }
257
+ }
258
+ if (event.status === "error" && event.error) {
259
+ // Weak-model arg fumbles (issue #39): the call is already dead from
260
+ // validation, so append an explicit "the param is X, not Y" hint to
261
+ // the error the model sees. Arguments are never rewritten — the
262
+ // model stays in the correction loop. Fail-open by design: a
263
+ // non-fumble error is returned untouched.
264
+ if (enrichToolError(event.tool, event.error, event.input)) {
265
+ vlog(`tool-arg-hint:fired tool=${event.tool} session=${event.sessionID}`)
266
+ }
254
267
  }
255
268
  if (event.tool === "question") {
256
269
  // Feed the answer text to purge approvals (team_cleanup confirm flow).
@@ -281,7 +294,7 @@ export async function setupEnsemble(
281
294
  const prompt =
282
295
  teamInfo.role === "lead"
283
296
  ? buildLeadSystemPrompt(db, teamInfo.teamId, config)
284
- : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown")
297
+ : buildTeammateSystemPrompt(db, teamInfo.teamId, teamInfo.memberName ?? "unknown", config)
285
298
  vlog(`system-prompt:injected role=${teamInfo.role} len=${prompt.length}`)
286
299
  event.system.push({ type: "text", text: prompt })
287
300
  if (teamInfo.role === "member") {
@@ -330,7 +343,7 @@ export async function setupEnsemble(
330
343
  let dashboard: DashboardServer | null = null
331
344
  const dashboardPort = options.dashboardPort ?? config.dashboardPort
332
345
  if (dashboardPort !== 0 && !isWorktreeInstance(ctx.location.directory)) {
333
- dashboard = await startDashboard(db, dashboardPort, { activityBuffer, client }).catch((err) => {
346
+ dashboard = await startDashboard(db, dashboardPort, { activityBuffer, client, host: config.dashboardHost }).catch((err) => {
334
347
  vlog(`init:dashboard:failed err=${err instanceof Error ? err.message : String(err)}`)
335
348
  return null
336
349
  })