From 4a3c905e2453ef5816fd7c9f4d166499dbc61bc3 Mon Sep 17 00:00:00 2001 From: zod Date: Mon, 12 Oct 2026 00:04:41 -0500 Subject: [PATCH 1/2] fix: support OpenCode 2.0.19+ (team_spawn, worktrees, read-only agents) Fixes #40 against OpenCode 2.0.24: - session.create: only call permission.rules() when the host still has it (2.0.3-2.0.18). Since 2.0.19 the session ruleset merges after the agent's (core permission.ts merge(agent, session) with last-match evaluation), so create() permissions are enough and rules() no longer exists. - worktree create/list/remove/refresh: pass the location's projectID, which the 2.0.19+ worktree API requires; worktree creation silently failed without it and every teammate shared the base tree. - team_spawn: also allow `edit *` for worktree teammates. V2 checks files inside the session location as relative paths, so the absolute `/**` rule never matched and edits fell back to an ask nobody could answer. Files outside the worktree stay behind external_directory. - team_spawn: detect read-only agents from the agent's own permission rules (agent.get) instead of only the names plan/explore, so custom reviewer agents are not given a worktree and `shell * allow`. - team_tasks_add: reject unknown depends_on IDs up front instead of creating a task that can never unblock. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GUtz6gBn7rhXaufK97d8cm --- src/tools/team-spawn.ts | 11 +++++- src/tools/team-tasks-add.ts | 17 +++++++++ src/types.ts | 4 ++ src/v2-client.ts | 70 +++++++++++++++++++++++++++++------ src/v2-setup.ts | 3 +- test/tools/team-spawn.test.ts | 19 ++++++++++ test/tools/team-tasks.test.ts | 11 ++++++ test/v2-client.test.ts | 61 ++++++++++++++++++++++++++++-- 8 files changed, 178 insertions(+), 18 deletions(-) diff --git a/src/tools/team-spawn.ts b/src/tools/team-spawn.ts index f2b71fa..d0eefa8 100644 --- a/src/tools/team-spawn.ts +++ b/src/tools/team-spawn.ts @@ -87,7 +87,11 @@ export async function executeTeamSpawn( .get(teamInfo.teamId, args.name) if (existing) throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`) - const isReadOnly = agent === "plan" || agent === "explore" + // Prefer the agent's own permission rules (V2) so any agent that denies + // edits — not only the built-in plan/explore — is treated as read-only and + // never gets a worktree or a shell allow. Fall back to the built-in names. + const isReadOnly = (await deps.client.agent?.readOnly(agent).catch(() => undefined)) + ?? (agent === "plan" || agent === "explore") const useWorktree = args.worktree !== false && !isReadOnly && !isWorktreeDirectory(deps.directory) const usePlanApproval = args.plan_approval === true @@ -167,6 +171,11 @@ export async function executeTeamSpawn( if (worktreeDir) { permission.push( { permission: "edit", pattern: `${worktreeDir}/**`, action: "allow" }, + // V2 checks files inside the session's location as paths relative to + // it (the teammate's location is the worktree), so the absolute pattern + // above never matches there and edits fell back to an unanswerable ask. + // Files outside the worktree remain gated by external_directory. + { permission: "edit", pattern: "*", action: "allow" }, ) if (!isReadOnly) { permission.push({ permission: "bash", pattern: "*", action: "allow" }) diff --git a/src/tools/team-tasks-add.ts b/src/tools/team-tasks-add.ts index cd9334e..ba17bc1 100644 --- a/src/tools/team-tasks-add.ts +++ b/src/tools/team-tasks-add.ts @@ -19,6 +19,23 @@ export async function executeTeamTasksAdd( ): Promise { const teamInfo = requireTeamMember(deps, sessionId) + // Reject unknown dependency IDs before inserting anything: a task blocked on + // an ID that never existed can never unblock (issue #40). IDs are generated + // here, so new tasks can only depend on existing ones and no cycle can form. + const unknown = new Set() + for (const task of args.tasks) { + for (const depId of task.depends_on ?? []) { + const exists = deps.db.query("SELECT 1 FROM team_task WHERE id = ? AND team_id = ?").get(depId, teamInfo.teamId) + if (!exists) unknown.add(depId) + } + } + if (unknown.size > 0) { + throw new Error( + `Unknown task ID${unknown.size > 1 ? "s" : ""} in depends_on: ${[...unknown].join(", ")}. ` + + "Use IDs from team_tasks_list, or add the prerequisite tasks first and reference the IDs they return.", + ) + } + const ids: string[] = [] const now = Date.now() diff --git a/src/types.ts b/src/types.ts index 2bba05c..97c0da9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,10 @@ export interface PermissionRule { * Makes mocking trivial in tests. */ export interface PluginClient { + /** Optional agent introspection (V2). Undefined answers fall back to name checks. */ + agent?: { + readOnly(agent: string): Promise + } session: { create(options: { parentID?: string diff --git a/src/v2-client.ts b/src/v2-client.ts index e70b3c0..32eab6d 100644 --- a/src/v2-client.ts +++ b/src/v2-client.ts @@ -3,6 +3,8 @@ import { runCommand } from "./process" /** V2 plugin context subset Ensemble needs (structural, mock-friendly). */ export interface V2Context { + /** Plugin location; `project.id` is required by the 2.0.19+ worktree API. */ + location?: { directory?: string; project?: { id?: string } } session: { create(input: Record): Promise<{ id: string }> prompt(input: Record): Promise @@ -17,14 +19,41 @@ export interface V2Context { worktree: { create(input: Record): Promise<{ directory: string }> remove(input: Record): Promise - list(): Promise> - refresh(): Promise + list(input?: Record): Promise> + refresh(input?: Record): Promise } permission: { - rules(input: Record): Promise + /** Present on 2.0.3–2.0.18 only; removed in 2.0.19 (see issue #40). */ + rules?(input: Record): Promise + } + /** Agent lookup (2.0.19+): exposes each agent's resolved permission rules. */ + agent?: { + get(input: Record): Promise } } +type PermissionRuleV2 = { action?: unknown; resource?: unknown; effect?: unknown } + +/** OpenCode wildcard match: `*` matches any run of characters. */ +function wildcardMatch(value: string, pattern: string): boolean { + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + return new RegExp(`^${escaped}$`).test(value) +} + +/** + * True when an agent's own rules deny editing everywhere. OpenCode evaluates + * the last matching rule, so this mirrors `evaluate("edit", "*")` over the + * agent ruleset. Undefined when the rules carry no edit decision. + */ +export function agentRulesAreReadOnly(rules: unknown): boolean | undefined { + if (!Array.isArray(rules)) return undefined + const match = [...(rules as PermissionRuleV2[])].reverse().find((rule) => + typeof rule?.action === "string" && typeof rule?.resource === "string" + && wildcardMatch("edit", rule.action) && (rule.resource === "*" || rule.resource === "**")) + if (!match) return undefined + return match.effect === "deny" +} + /** Prefix marking a workspace id that is really a local directory (see below). */ const DIR_WORKSPACE_PREFIX = "v2dir:" @@ -88,7 +117,23 @@ function joinParts(parts: Array<{ type: "text"; text: string }>): string { */ export function createV2Client(ctx: V2Context, deps: V2ClientDeps = {}): PluginClient { const gitBranch = deps.gitBranch ?? gitBranchDefault + // 2.0.19+ worktree endpoints require the project; older hosts ignore it. + const project = (): Record => { + const projectID = ctx.location?.project?.id + return projectID ? { projectID } : {} + } return { + agent: { + readOnly: async (agent: string) => { + if (!ctx.agent) return undefined + try { + const info = (await ctx.agent.get({ agentID: agent })) as { permissions?: unknown; data?: { permissions?: unknown } } | undefined + return agentRulesAreReadOnly(info?.permissions ?? info?.data?.permissions) + } catch { + return undefined + } + }, + }, session: { create: async (options) => { const input: Record = { title: options.title } @@ -114,11 +159,12 @@ export function createV2Client(ctx: V2Context, deps: V2ClientDeps = {}): PluginC } } const created = await ctx.session.create(input) - if (permissions) { - // Session.create permissions lose to agent rules (last match wins - // and agent rules apply later), which strips team tools from - // read-only agents. Re-assert here: rules() evaluates after the - // agent's rules, restoring the tools. OQ-V2-rules. + if (permissions && typeof ctx.permission.rules === "function") { + // 2.0.3–2.0.18 merged agent rules after session rules, so create() + // permissions lost to the agent's and had to be re-asserted. From + // 2.0.19 the session ruleset merges last (core permission.ts + // `merge(agent.permissions, session.permissions)` with last-match + // evaluation) and rules() no longer exists, so create() is enough. await ctx.permission.rules({ sessionID: created.id, permissions }) } return { data: { id: created.id } } @@ -177,16 +223,16 @@ export function createV2Client(ctx: V2Context, deps: V2ClientDeps = {}): PluginC worktree: { create: async (options) => { const name = (options.worktreeCreateInput as { name?: string } | undefined)?.name ?? "ensemble-worktree" - const created = await ctx.worktree.create({ name }) + const created = await ctx.worktree.create({ ...project(), name }) const branch = (await gitBranch(created.directory)) ?? name return { data: { name, branch, directory: created.directory } } }, remove: async (options) => { const directory = (options.worktreeRemoveInput as { directory: string }).directory - return ctx.worktree.remove({ directory, force: false }) + return ctx.worktree.remove({ ...project(), directory, force: false }) }, list: async () => { - const entries = await ctx.worktree.list() + const entries = await ctx.worktree.list(project()) return { data: entries.map((entry) => ({ name: entry.directory.split("/").pop() ?? entry.directory, @@ -195,7 +241,7 @@ export function createV2Client(ctx: V2Context, deps: V2ClientDeps = {}): PluginC })), } }, - reset: async () => ctx.worktree.refresh(), + reset: async () => ctx.worktree.refresh(project()), }, workspace: { create: async (options) => { diff --git a/src/v2-setup.ts b/src/v2-setup.ts index 70b4ceb..77285bd 100644 --- a/src/v2-setup.ts +++ b/src/v2-setup.ts @@ -25,7 +25,8 @@ import { vlog } from "./log" /** V2 setup context subset (structural, mock-friendly). */ export interface V2SetupContext { - location: { directory: string } + location: { directory: string; project?: { id?: string } } + agent?: V2Context["agent"] options: Record session: V2Context["session"] & { hook(name: string, cb: (event: never) => unknown): Promise<{ dispose(): Promise }> diff --git a/test/tools/team-spawn.test.ts b/test/tools/team-spawn.test.ts index 609a6ca..6e318fe 100644 --- a/test/tools/team-spawn.test.ts +++ b/test/tools/team-spawn.test.ts @@ -830,11 +830,30 @@ describe("team_spawn — agent mode enforcement", () => { const opts = createCall!.args[0] as { permission?: Array<{ permission: string; pattern: string; action: string }> } expect(opts.permission).toEqual([ { permission: "edit", pattern: "/tmp/worktree-ensemble-test-project-my-team#t1-builder/**", action: "allow" }, + { permission: "edit", pattern: "*", action: "allow" }, { permission: "bash", pattern: "*", action: "allow" }, ...TEAM_TOOL_PERMISSIONS, ]) }) + test("an agent whose own rules deny edits is spawned read-only without a worktree", async () => { + const client = deps.client as unknown as { agent?: { readOnly(agent: string): Promise } } + client.agent = { readOnly: async (agent) => agent === "code-reviewer" } + try { + await executeTeamSpawn(deps, { name: "reviewer", agent: "code-reviewer", prompt: "Review it" }, "lead-sess") + expect(deps.client.calls.some(c => c.method === "worktree.create")).toBe(false) + const createCall = deps.client.calls.find(c => c.method === "session.create") + const opts = createCall!.args[0] as { permission?: Array<{ permission: string; pattern: string; action: string }> } + expect(opts.permission).toEqual([ + { permission: "edit", pattern: "*", action: "deny" }, + { permission: "bash", pattern: "*", action: "deny" }, + ...TEAM_TOOL_PERMISSIONS, + ]) + } finally { + delete client.agent + } + }) + test("plan agent gets agent type on promptAsync without deprecated tools field", async () => { await executeTeamSpawn(deps, { name: "planner", agent: "plan", prompt: "Plan it" }, "lead-sess") diff --git a/test/tools/team-tasks.test.ts b/test/tools/team-tasks.test.ts index 5f052e6..36f336a 100644 --- a/test/tools/team-tasks.test.ts +++ b/test/tools/team-tasks.test.ts @@ -32,6 +32,17 @@ describe("team_tasks_list", () => { expect(result).toContain("pending") }) + test("rejects unknown depends_on IDs without adding any task (issue #40)", async () => { + await executeTeamTasksAdd(deps, { tasks: [{ content: "Task A", priority: "high" }] }, "sess-alice") + const before = (deps.db.query("SELECT COUNT(*) as c FROM team_task").get() as { c: number }).c + await expect(executeTeamTasksAdd(deps, { tasks: [ + { content: "Task B", priority: "medium" }, + { content: "Task C", priority: "medium", depends_on: ["task_does_not_exist"] }, + ] }, "sess-alice")).rejects.toThrow(/Unknown task ID in depends_on: task_does_not_exist/) + const after = (deps.db.query("SELECT COUNT(*) as c FROM team_task").get() as { c: number }).c + expect(after).toBe(before) + }) + test("rejects if not in a team", async () => { await expect(executeTeamTasksList(deps, "random-sess")) .rejects.toThrow("not in a team") diff --git a/test/v2-client.test.ts b/test/v2-client.test.ts index eac4aec..2c98056 100644 --- a/test/v2-client.test.ts +++ b/test/v2-client.test.ts @@ -61,12 +61,12 @@ function mockCtx() { calls.push({ domain: "worktree", method: "remove", input }) return undefined }, - list: async () => { - calls.push({ domain: "worktree", method: "list", input: {} }) + list: async (input: Record = {}) => { + calls.push({ domain: "worktree", method: "list", input }) return [{ directory: "/wt/ensemble-proj-team-alice" }, { directory: "/wt/other" }] }, - refresh: async () => { - calls.push({ domain: "worktree", method: "refresh", input: {} }) + refresh: async (input: Record = {}) => { + calls.push({ domain: "worktree", method: "refresh", input }) return undefined }, }, @@ -261,3 +261,56 @@ describe("v2-client adapter (issue #36, DRY: reuses all tool logic)", () => { expect(calls.length).toBe(0) }) }) + +describe("createV2Client — OpenCode 2.0.19+ compatibility (issue #40)", () => { + test("session.create works when permission.rules no longer exists", async () => { + const { calls, ctx } = mockCtx() + const { rules: _removed, ...permission } = ctx.permission + const client = createV2Client({ ...ctx, permission } as never) + const result = await client.session.create({ + title: "alice", + permission: [{ permission: "team_message", pattern: "*", action: "allow" }], + }) + expect(result.data?.id).toBe("ses_new123") + expect(calls.find((c) => c.method === "create")?.input).toMatchObject({ + permissions: [{ action: "team_message", resource: "*", effect: "allow" }], + }) + expect(calls.some((c) => c.method === "rules")).toBe(false) + }) + + test("worktree calls pass the location project id required by 2.0.19+", async () => { + const { calls, ctx } = mockCtx() + const client = createV2Client({ ...ctx, location: { directory: "/p", project: { id: "prj_1" } } } as never, { + gitBranch: async () => "b", + }) + await client.worktree.create({ worktreeCreateInput: { name: "ensemble-x" } }) + await client.worktree.remove({ worktreeRemoveInput: { directory: "/wt/ensemble-x" } }) + await client.worktree.list() + await client.worktree.reset({ worktreeResetInput: { directory: "/wt/ensemble-x" } }) + const worktreeCalls = calls.filter((c) => c.domain === "worktree") + expect(worktreeCalls.map((c) => (c.input as { projectID?: string }).projectID)).toEqual(["prj_1", "prj_1", "prj_1", "prj_1"]) + expect(worktreeCalls[0]?.input).toMatchObject({ projectID: "prj_1", name: "ensemble-x" }) + }) + + test("agent.readOnly reads the agent's last matching edit rule", async () => { + const { ctx } = mockCtx() + const rulesFor: Record = { + reviewer: [{ action: "*", resource: "*", effect: "allow" }, { action: "edit", resource: "*", effect: "deny" }], + builder: [{ action: "edit", resource: "*", effect: "deny" }, { action: "*", resource: "*", effect: "allow" }], + plain: [{ action: "read", resource: "*", effect: "allow" }], + } + const client = createV2Client({ + ...ctx, + agent: { get: async (input: Record) => ({ permissions: rulesFor[input["agentID"] as string] }) }, + } as never) + expect(await client.agent?.readOnly("reviewer")).toBe(true) + expect(await client.agent?.readOnly("builder")).toBe(false) + expect(await client.agent?.readOnly("plain")).toBeUndefined() + }) + + test("agent.readOnly is undefined when the host has no agent domain", async () => { + const { ctx } = mockCtx() + const client = createV2Client(ctx as never) + expect(await client.agent?.readOnly("anything")).toBeUndefined() + }) +}) From 7e081351c5751ed88f1a994d341a302364b8eacb Mon Sep 17 00:00:00 2001 From: zod Date: Sat, 17 Oct 2026 04:33:58 -0500 Subject: [PATCH 2/2] fix: name detached worktrees so team_merge works on OpenCode 2.0.32+ OpenCode 2.0.32 creates worktrees with `git worktree add --detach`, so a teammate's commits landed on a detached HEAD and team_merge failed with "not something we can merge". When the new worktree has no branch, create the requested branch in it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GUtz6gBn7rhXaufK97d8cm --- src/v2-client.ts | 19 ++++++++++++++++++- test/v2-client.test.ts | 25 +++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/v2-client.ts b/src/v2-client.ts index 32eab6d..89d9be5 100644 --- a/src/v2-client.ts +++ b/src/v2-client.ts @@ -61,6 +61,8 @@ const DIR_WORKSPACE_PREFIX = "v2dir:" export interface V2ClientDeps { /** Current branch checked out in a directory, or null when unknown. */ gitBranch?: (directory: string) => Promise + /** Create and check out a branch in a directory (best-effort). */ + gitCreateBranch?: (directory: string, branch: string) => Promise /** Worktree directory currently on a branch, or null when none. */ gitDir?: (branch: string) => Promise /** RPC emitter for companion events (team_view navigation). */ @@ -78,6 +80,15 @@ async function gitBranchDefault(directory: string): Promise { } } +/** Create and check out a branch in a directory via git. */ +async function gitCreateBranchDefault(directory: string, branch: string): Promise { + try { + await runCommand(["git", "switch", "-c", branch], { cwd: directory }) + } catch { + // Merge reports the missing branch; spawning must not fail here. + } +} + /** Concatenate the text parts of a promptAsync payload into one string. */ function joinParts(parts: Array<{ type: "text"; text: string }>): string { return parts @@ -117,6 +128,7 @@ function joinParts(parts: Array<{ type: "text"; text: string }>): string { */ export function createV2Client(ctx: V2Context, deps: V2ClientDeps = {}): PluginClient { const gitBranch = deps.gitBranch ?? gitBranchDefault + const gitCreateBranch = deps.gitCreateBranch ?? gitCreateBranchDefault // 2.0.19+ worktree endpoints require the project; older hosts ignore it. const project = (): Record => { const projectID = ctx.location?.project?.id @@ -224,7 +236,12 @@ export function createV2Client(ctx: V2Context, deps: V2ClientDeps = {}): PluginC create: async (options) => { const name = (options.worktreeCreateInput as { name?: string } | undefined)?.name ?? "ensemble-worktree" const created = await ctx.worktree.create({ ...project(), name }) - const branch = (await gitBranch(created.directory)) ?? name + let branch = await gitBranch(created.directory) + if (!branch) { + // 2.0.32+ creates worktrees detached; team_merge needs a named branch. + await gitCreateBranch(created.directory, name) + branch = name + } return { data: { name, branch, directory: created.directory } } }, remove: async (options) => { diff --git a/test/v2-client.test.ts b/test/v2-client.test.ts index 2c98056..619bce8 100644 --- a/test/v2-client.test.ts +++ b/test/v2-client.test.ts @@ -218,11 +218,32 @@ describe("v2-client adapter (issue #36, DRY: reuses all tool logic)", () => { }) }) - test("worktree.create falls back to the requested name when git lookup fails", async () => { + test("worktree.create names a detached worktree after the requested name", async () => { const { ctx } = mockCtx() - const client = createV2Client(ctx as never, { gitBranch: mockGit({}) }) + const created: Array<[string, string]> = [] + const client = createV2Client(ctx as never, { + gitBranch: mockGit({}), + gitCreateBranch: async (directory, branch) => { + created.push([directory, branch]) + }, + }) const result = await client.worktree.create({ worktreeCreateInput: { name: "ensemble-x" } }) expect(result.data?.branch).toBe("ensemble-x") + expect(created).toEqual([["/wt/ensemble-proj-team-alice", "ensemble-x"]]) + }) + + test("worktree.create leaves a worktree that is already on a branch alone", async () => { + const { ctx } = mockCtx() + let createCalls = 0 + const client = createV2Client(ctx as never, { + gitBranch: async () => "existing", + gitCreateBranch: async () => { + createCalls++ + }, + }) + const result = await client.worktree.create({ worktreeCreateInput: { name: "ensemble-x" } }) + expect(result.data?.branch).toBe("existing") + expect(createCalls).toBe(0) }) test("worktree.remove/list/reset map to V2 remove/list/refresh", async () => {