From be4b0c7a5abd337493e263e59b7765de6fb11359 Mon Sep 17 00:00:00 2001 From: Terada Kousuke Date: Sat, 25 Apr 2026 00:58:24 +0900 Subject: [PATCH] fix(team): harden isolated worktree execution --- .../profile/plugins/guardrail-git.ts | 16 +- packages/guardrails/profile/plugins/team.ts | 412 ++- packages/opencode/test/plugin/team.test.ts | 3056 +++++++++++++++-- 3 files changed, 3020 insertions(+), 464 deletions(-) diff --git a/packages/guardrails/profile/plugins/guardrail-git.ts b/packages/guardrails/profile/plugins/guardrail-git.ts index 8a58e93a3a64..3f857c325dcf 100644 --- a/packages/guardrails/profile/plugins/guardrail-git.ts +++ b/packages/guardrails/profile/plugins/guardrail-git.ts @@ -17,6 +17,10 @@ type Review = { } export function createGitHandlers(ctx: GuardrailContext, review: Review) { + function teamWorker() { + return /(?:^|[\\/])\.opencode[\\/]team[\\/]/.test(ctx.input.worktree) + } + async function bashBeforeGit(cmd: string, out: { output?: string }, data: Record) { const isMerge = /\bgit\s+merge(\s|$)/i.test(cmd) || /\bgh\s+pr\s+merge(\s|$)/i.test(cmd) const isPrMerge = /\bgh\s+pr\s+merge\b/i.test(cmd) @@ -138,6 +142,11 @@ export function createGitHandlers(ctx: GuardrailContext, review: Review) { } const protectedBranch = /^(main|master|develop|dev)$/ + if (/\bgit\s+stash\s+pop\b/i.test(cmd)) { + await ctx.mark({ last_block: "bash", last_command: cmd, last_reason: "stash pop blocked: use apply then drop" }) + throw new Error("Guardrail policy blocked this action: stash pop blocked: use `git stash apply`, inspect conflicts, then `git stash drop` after verification") + } + if (/\bgit\s+push\b/i.test(cmd)) { const explicitMatch = cmd.match(/\bgit\s+push\s+(?:(?:-\w+|--[\w-]+)\s+)*\S+\s+(?:HEAD:)?(\S+)/i) if (explicitMatch && protectedBranch.test(explicitMatch[1])) { @@ -161,6 +170,11 @@ export function createGitHandlers(ctx: GuardrailContext, review: Review) { if (/\bgit\s+(checkout\s+-b|switch\s+-c)\b/i.test(cmd)) { try { + const status = await git(ctx.input.worktree, ["status", "--porcelain"]) + if (status.code === 0 && status.stdout.trim()) { + await ctx.mark({ last_block: "bash", last_command: cmd, last_reason: "branch creation with dirty worktree blocked" }) + throw new Error("Guardrail policy blocked this action: branch creation blocked because the worktree has uncommitted changes. Commit or use `git stash push --include-untracked` before creating the branch.") + } const devCheck = await git(ctx.input.worktree, ["rev-parse", "--verify", "origin/develop"]) if (devCheck.code === 0 && devCheck.stdout.trim()) { const branchCheck = await git(ctx.input.worktree, ["branch", "--show-current"]) @@ -176,6 +190,7 @@ export function createGitHandlers(ctx: GuardrailContext, review: Review) { } if (/\bgit\s+(cherry-pick)\b/i.test(cmd) && !/--abort\b/i.test(cmd)) { + if (teamWorker()) return await ctx.mark({ last_block: "bash", last_command: cmd, last_reason: "cherry-pick blocked: delegate to Codex CLI" }) throw new Error("Guardrail policy blocked this action: cherry-pick blocked: delegate to Codex CLI for context-heavy merge operations") } @@ -433,4 +448,3 @@ export function createGitHandlers(ctx: GuardrailContext, review: Review) { return { bashBeforeGit, bashAfterGit } } - diff --git a/packages/guardrails/profile/plugins/team.ts b/packages/guardrails/profile/plugins/team.ts index 28c85b827b6b..c98f95daa9d0 100644 --- a/packages/guardrails/profile/plugins/team.ts +++ b/packages/guardrails/profile/plugins/team.ts @@ -90,7 +90,10 @@ type Client = { } session: { get(input: { path: { id: string }; query: { directory: string } }): Promise<{ data?: { permission?: Rule[] } }> - create(input: { body: { parentID: string; title: string; permission?: Rule[] }; query: { directory: string } }): Promise<{ data: { id: string } }> + create(input: { + body: { parentID: string; title: string; permission?: Rule[] } + query: { directory: string } + }): Promise<{ data: { id: string } }> promptAsync(input: { path: { id: string } query: { directory: string } @@ -209,9 +212,7 @@ function summary(parts: Note[]) { return parts .filter( (item): item is { type: "tool"; state: { status: string; output: string } } => - item.type === "tool" && - item.state?.status === "completed" && - typeof item.state.output === "string", + item.type === "tool" && item.state?.status === "completed" && typeof item.state.output === "string", ) .map((item) => item.state.output.trim()) .filter(Boolean) @@ -286,20 +287,22 @@ function redir(cmd: string) { function mut(cmd: string) { const data = scrub(cmd) - return [ - /\brm\s+/i, - /\bmv\s+/i, - /\bcp\s+/i, - /\bchmod\b/i, - /\bchown\b/i, - /\btouch\b/i, - /\btruncate\b/i, - /\btee\b/i, - /\bsed\s+-i\b/i, - /\bperl\s+-pi\b/i, - /\bgit\s+(apply|am|rebase|cherry-pick|checkout\s+--|reset\s+--hard)\b/i, - /\bgit\s+merge(\s|$)/i, - ].some((item) => item.test(data)) || redir(data) + return ( + [ + /\brm\s+/i, + /\bmv\s+/i, + /\bcp\s+/i, + /\bchmod\b/i, + /\bchown\b/i, + /\btouch\b/i, + /\btruncate\b/i, + /\btee\b/i, + /\bsed\s+-i\b/i, + /\bperl\s+-pi\b/i, + /\bgit\s+(apply|am|rebase|cherry-pick|checkout\s+--|reset\s+--hard)\b/i, + /\bgit\s+merge(\s|$)/i, + ].some((item) => item.test(data)) || redir(data) + ) } function big(text: string) { @@ -307,13 +310,16 @@ function big(text: string) { if (!data) return false // Exempt read-only investigation requests that start with investigation verbs // and do NOT contain write-intent keywords - const readOnly = /^\s*(investigate|diagnose|explain|analyze|check|status|report|describe|show|list|review|audit|inspect|確認|調査|分析|説明|レビュー)/i.test(data) - && !/(implement|create|rewrite|patch|refactor|fix|add|edit|write|modify|実装|改修|修正|追加)/i.test(data) + const readOnly = + /^\s*(investigate|diagnose|explain|analyze|check|status|report|describe|show|list|review|audit|inspect|確認|調査|分析|説明|レビュー)/i.test( + data, + ) && !/(implement|create|rewrite|patch|refactor|fix|add|edit|write|modify|実装|改修|修正|追加)/i.test(data) if (readOnly) return false const plan = (data.match(/^\s*([-*]|\d+\.)\s+/gm) ?? []).length - const impl = /(implement|implementation|build|create|add|fix|refactor|rewrite|patch|parallel|subagent|team|background|worker|修正|実装|追加|改修|並列|サブエージェント|チーム)/i.test( - data, - ) + const impl = + /(implement|implementation|build|create|add|fix|refactor|rewrite|patch|parallel|subagent|team|background|worker|修正|実装|追加|改修|並列|サブエージェント|チーム)/i.test( + data, + ) const wide = data.length >= 500 || plan >= 3 || @@ -328,15 +334,18 @@ function write(text: string, flag?: boolean) { function direct(text: string) { const next = /\bopencode\s+run\s+\/init\b/i.test(text) - ? text.replace( - /\bopencode\s+run\s+\/init\b/gi, - "perform the equivalent /init repository inspection and AGENTS.md bootstrap directly in this worktree", - ).trim() + ? text + .replace( + /\bopencode\s+run\s+\/init\b/gi, + "perform the equivalent /init repository inspection and AGENTS.md bootstrap directly in this worktree", + ) + .trim() : text.trim() return `Worker execution rules: - Prefer file inspection tools such as Glob, Read, and Grep over bash for repository discovery whenever possible. - Use bash only when the non-shell tools cannot answer the question or complete the step. - Do not invoke nested OpenCode slash commands from inside this team worker. +- Do not create git branches, clones, nested repositories, or nested worktrees. The team tool already created the isolated worktree; edit files in the current directory directly. - If you are running in an isolated worktree, operate only on files inside the current worktree directory. Do not read from or write to the parent repository path directly. ${next}` @@ -362,6 +371,15 @@ function permit(base: Rule[]) { { permission: "bash", pattern: "git show*", action: "allow" as const }, { permission: "bash", pattern: "git ls-files*", action: "allow" as const }, { permission: "bash", pattern: "git grep *", action: "allow" as const }, + { permission: "bash", pattern: "git restore *", action: "allow" as const }, + { permission: "bash", pattern: "git checkout -- *", action: "allow" as const }, + { permission: "bash", pattern: "git rebase origin/develop", action: "allow" as const }, + { permission: "bash", pattern: "git rebase develop", action: "allow" as const }, + { permission: "bash", pattern: "git rebase origin/main", action: "allow" as const }, + { permission: "bash", pattern: "git rebase main", action: "allow" as const }, + { permission: "bash", pattern: "git rebase --continue", action: "allow" as const }, + { permission: "bash", pattern: "git rebase --skip", action: "allow" as const }, + { permission: "bash", pattern: "git cherry-pick *", action: "allow" as const }, { permission: "bash", pattern: "opencode *", action: "deny" as const }, { permission: "bash", pattern: "claude *", action: "deny" as const }, { permission: "bash", pattern: "codex *", action: "deny" as const }, @@ -406,10 +424,6 @@ function yard(dir: string) { return path.join(dir, ".opencode", "team") } -function projectRoot(directory: string, worktree: string) { - return worktree && worktree !== "/" ? worktree : directory -} - function within(root: string, file: string) { const rel = path.relative(root, file) return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)) @@ -422,6 +436,27 @@ function rebase(text: string, root: string, box: string) { return text.split(src).join(dst) } +function cleanHint(text: string) { + return text.trim().replace(/^[`'"]+|[`'",.)\]]+$/g, "") +} + +function worktreeHint(text: string) { + const direct = [ + /(?:git\s+)?worktree[\s\S]{0,120}?(?:at|path|directory|dir|:|:)[\s\S]{0,40}?`([^`\n]+)`/i, + /(?:ワークツリー|作業ツリー)[\s\S]{0,120}?`([^`\n]+)`/i, + /`(\/[^`\n]*\/\.worktrees\/[^`\n]+)`/i, + ] + for (const rule of direct) { + const match = text.match(rule) + const hit = cleanHint(match?.[1] ?? "") + if (hit && path.isAbsolute(hit)) return hit + } +} + +function projectRoot(directory: string, worktree: string) { + return worktree && worktree !== "/" ? worktree : directory +} + function rootkeep(dir: string) { if (dir === ".opencode") { return [ @@ -449,55 +484,32 @@ function rootkeep(dir: string) { } if (dir === ".claude") { - return [ - "agents", - "commands", - "hooks", - "settings.json", - "settings.local.json", - "skills", - ] + return ["agents", "commands", "hooks", "settings.json", "settings.local.json", "skills"] } if (dir === ".agents") { - return [ - "agents", - "commands", - "hooks", - "skills", - ] + return ["agents", "commands", "hooks", "skills"] } if (dir === ".cursor") { - return [ - "rules", - ] + return ["rules"] } if (dir === ".github") { - return [ - "copilot-instructions.md", - ] + return ["copilot-instructions.md"] } return [] } -const runtime = [ - ".opencode/guardrails", - ".opencode/memory", -] +const runtime = [".opencode/guardrails", ".opencode/memory"] function runtimeSpec() { return runtime.map((item) => `:(exclude)${item}`) } async function ignoredCarrySpec(dir: string, kept: string[]) { - const roots = new Set( - kept - .map((item) => item.split(/[\\/]/)[0]) - .filter((item): item is string => Boolean(item)), - ) + const roots = new Set(kept.map((item) => item.split(/[\\/]/)[0]).filter((item): item is string => Boolean(item))) const spec: string[] = [] for (const root of roots) { const ignored = await git(dir, ["check-ignore", "-q", "--", root, `${root}/`]) @@ -507,42 +519,37 @@ async function ignoredCarrySpec(dir: string, kept: string[]) { } async function workFiles(dir: string, spec: string[]) { - const out = await git(dir, ["ls-files", "-z", "--modified", "--deleted", "--others", "--exclude-standard", "--", ".", ...spec]) + const out = await git(dir, [ + "ls-files", + "-z", + "--modified", + "--deleted", + "--others", + "--exclude-standard", + "--", + ".", + ...spec, + ]) if (out.code !== 0) throw new Error(out.err || out.out || "Failed to list worktree changes") return out.out.split("\0").filter(Boolean) } function docs() { - return [ - "AGENTS.md", - "OPENCODE.md", - "CLAUDE.md", - "CONTEXT.md", - ] + return ["AGENTS.md", "OPENCODE.md", "CLAUDE.md", "CONTEXT.md"] } function roots() { - return [ - ".opencode", - ".claude", - ".agents", - ".cursor", - ".github", - ] + return [".opencode", ".claude", ".agents", ".cursor", ".github"] } function workspaceRuntime() { - return [ - "node_modules", - ".pnpm-store", - ".yarn", - ".pnp.cjs", - ".pnp.loader.mjs", - ] + return ["node_modules", ".pnpm-store", ".yarn", ".pnp.cjs", ".pnp.loader.mjs"] } async function has(file: string) { - return lstat(file).then(() => true).catch(() => false) + return lstat(file) + .then(() => true) + .catch(() => false) } async function graft(src: string, dst: string) { @@ -551,7 +558,9 @@ async function graft(src: string, dst: string) { const kind = stat.isDirectory() ? (process.platform === "win32" ? "junction" : "dir") : "file" const link = stat.isSymbolicLink() ? await readlink(src) : src - const made = await symlink(link, dst, kind as Parameters[2]).then(() => true).catch(() => false) + const made = await symlink(link, dst, kind as Parameters[2]) + .then(() => true) + .catch(() => false) if (made) return true return cp(src, dst, { @@ -569,7 +578,7 @@ async function carry(root: string, dir: string, next: string) { if (!within(base, cwd)) throw new Error(`Cannot prepare team worktree: directory is outside worktree (${dir})`) const kept: string[] = [] - for (let cur = dir;; cur = path.dirname(cur)) { + for (let cur = dir; ; cur = path.dirname(cur)) { const rel = path.relative(root, cur) for (const name of docs()) { @@ -635,6 +644,46 @@ async function git(dir: string, args: string[]) { return { out, err, code } } +async function gitTop(dir: string) { + const out = await git(dir, ["rev-parse", "--show-toplevel"]) + if (out.code !== 0) return + const top = out.out.trim() + if (!top) return + return realpath(top).catch(() => path.resolve(top)) +} + +async function gitCommon(dir: string) { + const out = await git(dir, ["rev-parse", "--path-format=absolute", "--git-common-dir"]) + if (out.code !== 0) return + const common = out.out.trim() + if (!common) return + return realpath(common).catch(() => path.resolve(common)) +} + +async function hasPopulatedFiles(dir: string) { + const out = await git(dir, ["ls-files", "-z"]) + if (out.code !== 0) throw new Error(out.err || out.out || "Failed to inspect worktree files") + const files = out.out.split("\0").filter(Boolean) + if (!files.length) return false + for (const file of files) { + if (await has(path.join(dir, file))) return true + } + return false +} + +async function externalWorktree(root: string, prompt: string) { + const hint = worktreeHint(prompt) + if (!hint) return + const target = await gitTop(hint) + if (!target) return + const base = await gitTop(root) + if (!base) return + if (target === base) return + const [baseCommon, targetCommon] = await Promise.all([gitCommon(base), gitCommon(target)]) + if (!baseCommon || !targetCommon || baseCommon !== targetCommon) return + return target +} + async function save(dir: string, run: Run) { live.set(run.id, run) await mkdir(root(dir), { recursive: true }) @@ -642,19 +691,24 @@ async function save(dir: string, run: Run) { } async function load(dir: string, id: string) { - const data = await Bun.file(file(dir, id)).json().catch(() => undefined) + const data = await Bun.file(file(dir, id)) + .json() + .catch(() => undefined) return isRun(data) ? data : undefined } async function scan(dir: string) { await mkdir(root(dir), { recursive: true }) const list = await readdir(root(dir)).catch(() => []) - return Promise.all(list.filter((item) => item.endsWith(".json")).map((item) => Bun.file(path.join(root(dir), item)).json().catch(() => undefined))).then( - (list) => - list - .filter(isRun) - .toSorted((a, b) => String(b.updated_at).localeCompare(String(a.updated_at))), - ) + return Promise.all( + list + .filter((item) => item.endsWith(".json")) + .map((item) => + Bun.file(path.join(root(dir), item)) + .json() + .catch(() => undefined), + ), + ).then((list) => list.filter(isRun).toSorted((a, b) => String(b.updated_at).localeCompare(String(a.updated_at)))) } async function yardadd(dir: string, id: string) { @@ -702,11 +756,19 @@ async function yardadd(dir: string, id: string) { throw new Error(`Worktree created but population failed: ${populated.err || populated.out}`) } - // Step 3: Verify files are actually present in the working directory - const check = await git(next, ["ls-files", "--cached"]) - if (check.code !== 0 || !check.out.trim()) { + // Step 3: Verify files are actually present in the working directory. + // Some git/sandbox combinations can leave only metadata after add --no-checkout. + if (!(await hasPopulatedFiles(next))) { + const checkout = await git(next, ["checkout", "-f", "HEAD", "--", "."]) + if (checkout.code !== 0) { + await drop() + throw new Error(`Worktree created but checkout failed: ${checkout.err || checkout.out}`) + } + } + + if (!(await hasPopulatedFiles(next))) { await drop() - throw new Error("Worktree is empty after reset --hard — cannot proceed with delegation") + throw new Error("Worktree is empty after checkout — cannot proceed with delegation") } return next @@ -716,26 +778,37 @@ async function yardrm(dir: string, item: string) { const base = path.resolve(yard(dir)) const next = path.resolve(item) if (!within(base, next)) return - await git(dir, ["worktree", "remove", "--force", next]).catch(() => undefined) - await rm(next, { force: true, recursive: true }).catch(() => undefined) + await git(dir, ["worktree", "remove", "--force", next]) + await rm(next, { force: true, recursive: true }).catch(() => {}) } async function merge(dir: string, item: string, run: string, id: string, kept: string[] = []) { + let next = "" try { - await Promise.all(kept.map((file) => rm(path.join(item, file), { force: true, recursive: true }).catch(() => undefined))) + await Promise.all( + kept.map((file) => rm(path.join(item, file), { force: true, recursive: true }).catch(() => undefined)), + ) const spec = [...runtimeSpec(), ...(await ignoredCarrySpec(item, kept))] // Stage all worker-owned files while excluding runtime state that is created locally during execution. const files = await workFiles(item, spec) if (!files.length) return { patch: "", merged: true } const add = await git(item, ["add", "-A", "--", ...files]) if (add.code !== 0) throw new Error(add.err || add.out || "Failed to stage worktree changes") - const changed = await git(item, ["diff", "--cached", "--name-only", "-z", "--diff-filter=ACDMRTUXB", "--", ...files]) + const changed = await git(item, [ + "diff", + "--cached", + "--name-only", + "-z", + "--diff-filter=ACDMRTUXB", + "--", + ...files, + ]) if (changed.code !== 0) throw new Error(changed.err || changed.out || "Failed to list worktree changes") const staged = changed.out.split("\0").filter(Boolean) if (!staged.length) return { patch: "", merged: true } const diff = await git(item, ["diff", "--cached", "--binary", "--", ...staged]) if (diff.code !== 0) throw new Error(diff.err || diff.out || "Failed to read worktree diff") - const next = patch(dir, run, id) + next = patch(dir, run, id) await Bun.write(next, diff.out) const out = await git(dir, ["apply", "--3way", next]) if (out.code !== 0) return { patch: next, merged: false, error: out.err || out.out || "Failed to apply patch" } @@ -748,11 +821,16 @@ async function merge(dir: string, item: string, run: string, id: string, kept: s verification.issues.push("Patch was non-empty but no diff detected after apply") } const status = await git(dir, ["status", "--porcelain"]) - const untracked = status.out.trim().split("\n").filter((l: string) => l.startsWith("??")).length + const untracked = status.out + .trim() + .split("\n") + .filter((l: string) => l.startsWith("??")).length if (untracked > 5) { verification.issues.push(`${untracked} untracked files after merge — check for stale artifacts`) } - } catch { /* verification is advisory */ } + } catch { + /* verification is advisory */ + } // Auto-commit only the files produced by the worker patch so unrelated parent edits stay untouched. try { if (staged.length > 0) { @@ -763,16 +841,21 @@ async function merge(dir: string, item: string, run: string, id: string, kept: s verification.issues.push(`Auto-commit failed: ${commit.err || commit.out}`) } } - } catch { /* auto-commit is best-effort; parent session can still commit manually */ } + } catch { + /* auto-commit is best-effort; parent session can still commit manually */ + } return { patch: next, merged: true, verification } } finally { - await yardrm(dir, item).catch(() => undefined) + await yardrm(dir, item).catch(() => {}) } } async function idle(client: Client, id: string, dir: string, abort: AbortSignal) { let seen = false const hit = mark(client) + if (process.env.DEBUG_TEAM) { + console.log("idle.begin", id, hit.idle.has(id), hit.per.size, hit.on) + } for (;;) { if (abort.aborted) throw new Error("Aborted") if (hit.idle.has(id)) return @@ -788,7 +871,6 @@ async function idle(client: Client, id: string, dir: string, abort: AbortSignal) const item = stat.data?.[id] if (!item) { if (hit.idle.has(id)) return - if (seen) return await Bun.sleep(gap) continue } @@ -799,6 +881,9 @@ async function idle(client: Client, id: string, dir: string, abort: AbortSignal) } async function snap(client: Client, id: string, dir: string, completedOnly = false) { + if (process.env.DEBUG_TEAM) { + console.log("snap.call", id, completedOnly, dir) + } const list = await client.session.messages({ path: { id }, query: { directory: dir }, @@ -811,17 +896,23 @@ async function snap(client: Client, id: string, dir: string, completedOnly = fal } function stage(err: string): Step["failure_stage"] { - return /blocked on (permission|question)/i.test(err) ? "blocked" - : /abort/i.test(err) ? "aborted" - : /timeout/i.test(err) ? "timeout" - : "execution" + return /blocked on (permission|question)/i.test(err) + ? "blocked" + : /abort/i.test(err) + ? "aborted" + : /timeout/i.test(err) + ? "timeout" + : "execution" } function why(item: Step | undefined, err: string): Step["failure_stage"] { - return !item?.dir ? "worktree_setup" - : !item?.session ? "session_create" - : /merge|patch|apply/.test(err) ? "merge_back" - : stage(err) + return !item?.dir + ? "worktree_setup" + : !item?.session + ? "session_create" + : /merge|patch|apply/.test(err) + ? "merge_back" + : stage(err) } function fail(run: Run, err: string, id?: string) { @@ -929,6 +1020,9 @@ function defs(list: { id: string; depends?: string[] }[]) { function todo(run: Run, id: string, data: Partial) { const next = run.tasks.find((item) => item.id === id) + if (process.env.DEBUG_TEAM) { + console.log("todo", run.id, id, data.state, data.error || "", data.dir || "") + } if (!next) return Object.assign(next, data, { updated_at: now() }) run.updated_at = now() @@ -959,10 +1053,24 @@ function mark(client: Client) { const props = evt.properties ?? {} const session = typeof props.sessionID === "string" ? props.sessionID : "" if (!session) continue - const permission = typeof props.permission === "string" ? props.permission : typeof props.type === "string" ? props.type : "unknown" + const permission = + typeof props.permission === "string" + ? props.permission + : typeof props.type === "string" + ? props.type + : "unknown" const raw = props.patterns ?? props.pattern - const patterns = Array.isArray(raw) ? raw.filter((item): item is string => typeof item === "string") : typeof raw === "string" ? [raw] : [] - const label = typeof props.title === "string" && props.title ? props.title : typeof props.description === "string" ? props.description : "" + const patterns = Array.isArray(raw) + ? raw.filter((item): item is string => typeof item === "string") + : typeof raw === "string" + ? [raw] + : [] + const label = + typeof props.title === "string" && props.title + ? props.title + : typeof props.description === "string" + ? props.description + : "" const title = label ? ` (${label})` : "" next.per.set(session, { permission, patterns, hint: title }) } @@ -989,12 +1097,19 @@ function mark(client: Client) { async function wait(client: Client, id: string, dir: string) { const hit = mark(client) - const perm = await client.permission?.list?.({ directory: dir }).catch(() => ({ data: [] as { sessionID: string; permission: string; patterns: string[]; metadata?: Record }[] })) + if (process.env.DEBUG_TEAM) { + console.log("wait.call", id, dir, "cached?", hit.per.has(id), "idle?", hit.idle.has(id)) + } + const perm = await client.permission?.list?.({ directory: dir }).catch(() => ({ + data: [] as { sessionID: string; permission: string; patterns: string[]; metadata?: Record }[], + })) const blocked = perm?.data?.find((item) => item.sessionID === id) if (blocked) { const meta = blocked.metadata?.description const hint = typeof meta === "string" && meta ? ` (${meta})` : "" - return `Blocked on permission: ${blocked.permission}${hint} :: ${blocked.patterns.join(" | ")}` + const out = `Blocked on permission: ${blocked.permission}${hint} :: ${blocked.patterns.join(" | ")}` + if (process.env.DEBUG_TEAM) console.log("wait.blocked", out) + return out } const local = client.session.permission?.(id)?.[0] if (local?.permission) { @@ -1006,16 +1121,18 @@ async function wait(client: Client, id: string, dir: string) { if (hold) { return `Blocked on permission: ${hold.permission}${hold.hint} :: ${hold.patterns.join(" | ")}` } - const localState = await Bun.file(path.join(dir, ".opencode", "guardrails", "state.json")).json().catch(() => undefined) as - | { last_event?: unknown; last_permission?: unknown; last_patterns?: unknown } - | undefined + const localState = (await Bun.file(path.join(dir, ".opencode", "guardrails", "state.json")) + .json() + .catch(() => undefined)) as { last_event?: unknown; last_permission?: unknown; last_patterns?: unknown } | undefined if (localState?.last_event === "permission.asked" && typeof localState.last_permission === "string") { const patterns = Array.isArray(localState.last_patterns) ? localState.last_patterns.filter((item): item is string => typeof item === "string") : [] return `Blocked on permission: ${localState.last_permission} :: ${patterns.join(" | ")}` } - const ask = await client.question?.list?.({ directory: dir }).catch(() => ({ data: [] as { sessionID: string; questions: { question: string; header: string }[] }[] })) + const ask = await client.question + ?.list?.({ directory: dir }) + .catch(() => ({ data: [] as { sessionID: string; questions: { question: string; header: string }[] }[] })) const asked = ask?.data?.find((item) => item.sessionID === id) if (asked) { const text = asked.questions.map((item) => `${item.header}: ${item.question}`).join(" | ") @@ -1040,36 +1157,38 @@ async function stop(client: Client, run: Run) { ).catch(() => undefined) } -export default async function team(input: { - client: Client - worktree: string - directory: string -}) { +export default async function team(input: { client: Client; worktree: string; directory: string }) { const inputRoot = projectRoot(input.directory, input.worktree) void sweep(input.client, inputRoot) const job = async (ctx: Ctx, run: Run, item: Step) => { const runRoot = projectRoot(ctx.directory, ctx.worktree) const repoRoot = ctx.worktree && ctx.worktree !== "/" ? ctx.worktree : undefined const push = write(item.prompt, item.write) - const useWorktree = push && item.worktree && !!repoRoot + const target = repoRoot && push ? await externalWorktree(repoRoot, item.prompt) : undefined + const useExistingWorktree = push && item.worktree && !!target + const useWorktree = push && item.worktree && !!repoRoot && !useExistingWorktree let box = ctx.directory let kept: string[] = [] let child = "" try { - if (useWorktree && repoRoot) { + if (useExistingWorktree && target) { + box = target + } else if (useWorktree && repoRoot) { box = await yardadd(repoRoot, `${run.id}-${item.id}`) kept = await carry(repoRoot, ctx.directory, box) } const prompt = direct(useWorktree && repoRoot ? rebase(item.prompt, repoRoot, box) : item.prompt) + if (process.env.DEBUG_TEAM) console.log("job.start", run.id, item.id) todo(run, item.id, { state: "running", dir: box, }) await save(runRoot, run) + if (process.env.DEBUG_TEAM) console.log("job.saved.running", run.id, item.id) - const made = await input.client.session.create({ + const next = await input.client.session.create({ body: { parentID: ctx.sessionID, title: item.description, @@ -1079,13 +1198,15 @@ export default async function team(input: { directory: box, }, }) - child = made.data.id + if (process.env.DEBUG_TEAM) console.log("job.created", run.id, item.id, next?.data?.id) + child = next.data.id kids.add(child) todo(run, item.id, { session: child, }) await save(runRoot, run) + if (process.env.DEBUG_TEAM) console.log("job.saved.session", run.id, item.id, child) await input.client.session.promptAsync({ path: { id: child }, @@ -1116,9 +1237,12 @@ export default async function team(input: { ], }, }) + if (process.env.DEBUG_TEAM) console.log("job.prompted", run.id, item.id) await idle(input.client, child, box, ctx.abort) + if (process.env.DEBUG_TEAM) console.log("job.idle-return", run.id, item.id) const out = await snap(input.client, child, box) + if (process.env.DEBUG_TEAM) console.log("job.snapped", run.id, item.id, out.completed) let patchfile = "" let err = out.error @@ -1130,6 +1254,9 @@ export default async function team(input: { if (!merged.merged) { err = merged.error || "Failed to merge worktree patch" failure_stage = "merge_back" + } else if (item.write && patchfile === "") { + err = "Write task completed without producing a patch" + failure_stage = "execution" } } if (err && !failure_stage) failure_stage = stage(err) @@ -1142,11 +1269,12 @@ export default async function team(input: { error: err, failure_stage: err ? failure_stage : undefined, }) + if (process.env.DEBUG_TEAM) console.log("job.done", run.id, item.id, err ? "error" : "done") await save(runRoot, run) if (err) throw new Error(err) return out.text } finally { - if (repoRoot && box !== ctx.directory) { + if (useWorktree && repoRoot && box !== ctx.directory) { await yardrm(repoRoot, box).catch(() => {}) } } @@ -1242,7 +1370,9 @@ export default async function team(input: { try { for (;;) { - const ready = list.filter((item) => item.state === "pending" && item.depends.every((dep) => done.has(dep)) && !active.has(item.id)) + const ready = list.filter( + (item) => item.state === "pending" && item.depends.every((dep) => done.has(dep)) && !active.has(item.id), + ) if (args.strategy === "wave" && ready.length) { ready.forEach((item) => todo(run, item.id, { state: "queued" })) @@ -1264,8 +1394,9 @@ export default async function team(input: { await save(runRoot, run) } } catch (err) { - const item = run.tasks.find((item) => item.state === "error") - ?? run.tasks.find((item) => item.state === "running" || item.state === "queued") + const item = + run.tasks.find((item) => item.state === "error") ?? + run.tasks.find((item) => item.state === "running" || item.state === "queued") fail(run, err instanceof Error ? err.message : String(err), item?.id) await save(runRoot, run) await stop(input.client, run) @@ -1369,7 +1500,9 @@ export default async function team(input: { }) }) .catch(async (err: Error) => { - const item = run.tasks.find((item) => item.state === "error" || item.state === "running" || item.state === "queued") || run.tasks[0] + const item = + run.tasks.find((item) => item.state === "error" || item.state === "running" || item.state === "queued") || + run.tasks[0] fail(run, err.message || "Unknown error", item?.id) await save(runRoot, run) if (!args.notify) return @@ -1404,7 +1537,9 @@ export default async function team(input: { async execute(args, ctx) { const runRoot = projectRoot(ctx.directory, ctx.worktree) await sweep(input.client, runRoot) - const list = args.run_id ? [live.get(args.run_id) ?? (await load(runRoot, args.run_id))].filter(isRun) : await scan(runRoot) + const list = args.run_id + ? [live.get(args.run_id) ?? (await load(runRoot, args.run_id))].filter(isRun) + : await scan(runRoot) const settled = await Promise.all(list.map((item) => reconcile(input.client, runRoot, item))) if (!settled.length) return "No team runs found." return settled.map((item) => note(item)).join("\n\n") @@ -1459,8 +1594,7 @@ export default async function team(input: { sessionID: out.message.sessionID, messageID: out.message.id, type: "text", - text: - "Parallel implementation policy is active for this request. Before any edit, write, apply_patch, or mutating bash call, you MUST call the `team` tool and fan out at least one worker task. Mark tasks that should edit code with `write: true`; those tasks will be isolated in git worktrees and merged back when possible. Use `background` only for side work that should keep running after this turn.", + text: "Parallel implementation policy is active for this request. Before any edit, write, apply_patch, or mutating bash call, you MUST call the `team` tool and fan out at least one worker task. Mark tasks that should edit code with `write: true`; those tasks will be isolated in git worktrees and merged back when possible. Use `background` only for side work that should keep running after this turn.", }) }, "tool.execute.before": async ( diff --git a/packages/opencode/test/plugin/team.test.ts b/packages/opencode/test/plugin/team.test.ts index 52f5f9edc823..e961358f5553 100644 --- a/packages/opencode/test/plugin/team.test.ts +++ b/packages/opencode/test/plugin/team.test.ts @@ -1,88 +1,107 @@ import { afterEach, expect, test } from "bun:test" -import { mkdir, readdir, rm } from "fs/promises" +import { Effect } from "effect" +import fs from "fs/promises" import path from "path" import team from "../../../../packages/guardrails/profile/plugins/team" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" -type TeamClient = Parameters[0]["client"] - afterEach(async () => { await Instance.disposeAll() }) -async function createPlugin( - dir: string, - worktree = dir, - overrides?: Partial>, -) { - return team({ - client: { - permission: { - async list() { - return { data: [] } - }, - }, - question: { - async list() { - return { data: [] } - }, - }, - session: { - async get() { - return { data: { permission: [] } } - }, - async create() { - return { data: { id: "ses_child" } } - }, - async promptAsync() { - return {} - }, - async prompt() { - return {} - }, - async status() { - return { data: { ses_child: { type: "idle" } } } - }, - async messages() { - return { - data: [ - { - info: { - role: "assistant", - time: { completed: Date.now() }, - }, - parts: [{ type: "text", text: "done" }], - }, - ], - } - }, - async abort() { - return {} - }, - ...overrides, - }, - }, - worktree, - directory: dir, - }) -} - -test("team merges worker output when local .opencode config is gitignored", async () => { +test("team carries local .opencode files into worker worktrees and inherits parent permission", async () => { await using tmp = await tmpdir({ git: true, init: async (dir) => { - await Bun.write(path.join(dir, ".gitignore"), ".opencode/\n") await Bun.write(path.join(dir, "README.md"), "# test\n") - await mkdir(path.join(dir, ".opencode", "plugins"), { recursive: true }) - await Bun.write(path.join(dir, ".opencode", "opencode.jsonc"), `{"plugin":["./plugins/team.ts"]}\n`) - await Bun.write(path.join(dir, ".opencode", "plugins", "team.ts"), "export default async function team() {}\n") - await Bun.$`git add .gitignore README.md`.cwd(dir).quiet() + await Bun.write(path.join(dir, "CLAUDE.md"), "# local claude\n") + await fs.mkdir(path.join(dir, ".opencode"), { recursive: true }) + await fs.mkdir(path.join(dir, ".claude", "skills", "local-claude"), { recursive: true }) + await fs.mkdir(path.join(dir, ".agents", "skills", "local-agent"), { recursive: true }) + await fs.mkdir(path.join(dir, ".cursor", "rules"), { recursive: true }) + await Bun.write( + path.join(dir, ".opencode", "opencode.jsonc"), + `{ + "permission": { + "bash": { + "gh *": "allow" + } + } +} +`, + ) + await Bun.write( + path.join(dir, ".claude", "settings.local.json"), + `{ + "permissions": { + "allow": ["Bash(gh:*)"] + } +} +`, + ) + await Bun.write( + path.join(dir, ".claude", "skills", "local-claude", "SKILL.md"), + `--- +name: local-claude +description: local claude skill +--- + +# Local Claude Skill +`, + ) + await Bun.write( + path.join(dir, ".agents", "skills", "local-agent", "SKILL.md"), + `--- +name: local-agent +description: local agent skill +--- + +# Local Agent Skill +`, + ) + await Bun.write(path.join(dir, ".github", "copilot-instructions.md"), "# Local Copilot\n") + await Bun.write( + path.join(dir, ".cursor", "rules", "global.mdc"), + `--- +description: global +alwaysApply: true +--- + +# Local Cursor Rule +`, + ) + await Bun.$`git add README.md`.cwd(dir).quiet() await Bun.$`git commit -m "seed"`.cwd(dir).quiet() }, }) + const perm = [ + { + permission: "bash", + pattern: "gh *", + action: "allow" as const, + }, + { + permission: "bash", + pattern: "gh pr merge *", + action: "deny" as const, + }, + ] + let box = "" + let body: + | { + parentID: string + title: string + permission?: { + permission: string + pattern: string + action: string + }[] + } + | undefined + const plugin = await team({ client: { permission: { @@ -97,22 +116,48 @@ test("team merges worker output when local .opencode config is gitignored", asyn }, session: { async get() { - return { data: { permission: [] } } + return { + data: { + permission: perm, + }, + } }, - async create() { - return { data: { id: "ses_child_ignored_opencode" } } + async create(input) { + body = input.body + return { + data: { + id: "ses_child", + }, + } }, async promptAsync(input) { box = input.query.directory - expect(await Bun.file(path.join(box, ".opencode", "opencode.jsonc")).text()).toContain("./plugins/team.ts") - expect(await Bun.file(path.join(box, ".opencode", "plugins", "team.ts")).text()).toContain("export default") + expect(box).not.toBe(tmp.path) + expect(await Bun.file(path.join(box, "README.md")).text()).toContain("# test") + expect(await Bun.file(path.join(box, "CLAUDE.md")).text()).toContain("local claude") + expect(await Bun.file(path.join(box, ".opencode", "opencode.jsonc")).text()).toContain(`"gh *": "allow"`) + expect(await Bun.file(path.join(box, ".claude", "settings.local.json")).text()).toContain(`"Bash(gh:*)"`) + expect(await Bun.file(path.join(box, ".claude", "skills", "local-claude", "SKILL.md")).text()).toContain( + "local-claude", + ) + expect(await Bun.file(path.join(box, ".agents", "skills", "local-agent", "SKILL.md")).text()).toContain( + "local-agent", + ) + expect(await Bun.file(path.join(box, ".github", "copilot-instructions.md")).text()).toContain("Local Copilot") + expect(await Bun.file(path.join(box, ".cursor", "rules", "global.mdc")).text()).toContain("Local Cursor Rule") await Bun.write(path.join(box, "worker.txt"), "worker output\n") }, async prompt() { return {} }, async status() { - return { data: { ses_child_ignored_opencode: { type: "idle" } } } + return { + data: { + ses_child: { + type: "idle", + }, + }, + } }, async messages() { return { @@ -120,9 +165,13 @@ test("team merges worker output when local .opencode config is gitignored", asyn { info: { role: "assistant", - time: { completed: Date.now() }, }, - parts: [{ type: "text", text: "done" }], + parts: [ + { + type: "text", + text: "done", + }, + ], }, ], } @@ -142,8 +191,8 @@ test("team merges worker output when local .opencode config is gitignored", asyn limit: 1, tasks: [ { - id: "ignored-opencode", - prompt: "write worker output", + id: "copy", + prompt: "check local config", write: true, }, ], @@ -154,36 +203,46 @@ test("team merges worker output when local .opencode config is gitignored", asyn agent: "implement", directory: tmp.path, worktree: tmp.path, - abort: AbortSignal.timeout(5000), + abort: new AbortController().signal, + ask: () => Effect.void, metadata() {}, - ask() { - return undefined as never - }, }, ) - expect(out).toContain("- ignored-opencode: done") + expect(out).toContain("run_id:") + expect(body?.parentID).toBe("ses_parent") + expect(body?.permission?.slice(0, perm.length)).toEqual(perm) + expect(body?.permission).toContainEqual({ permission: "bash", pattern: "rg *", action: "allow" }) + expect(body?.permission).toContainEqual({ permission: "bash", pattern: "git ls-tree*", action: "allow" }) + expect(body?.permission).toContainEqual({ permission: "bash", pattern: "git rebase origin/develop", action: "allow" }) + expect(body?.permission).toContainEqual({ permission: "bash", pattern: "git checkout -- *", action: "allow" }) + expect(body?.permission).toContainEqual({ permission: "bash", pattern: "git cherry-pick *", action: "allow" }) + expect(body?.permission).toContainEqual({ permission: "bash", pattern: "opencode *", action: "deny" }) expect(box).toContain(path.join(".opencode", "team")) - expect(await Bun.file(path.join(tmp.path, "worker.txt")).text()).toBe("worker output\n") - expect(await Bun.file(path.join(tmp.path, ".opencode", "opencode.jsonc")).exists()).toBeTrue() }) -test("team merge tolerates uncommitted removal of the .opencode gitignore rule", async () => { +test("team carries local .opencode config even when the project gitignore ignores .opencode", async () => { await using tmp = await tmpdir({ git: true, init: async (dir) => { await Bun.write(path.join(dir, ".gitignore"), ".opencode/\n") await Bun.write(path.join(dir, "README.md"), "# test\n") - await mkdir(path.join(dir, ".opencode", "plugins"), { recursive: true }) - await Bun.write(path.join(dir, ".opencode", "opencode.jsonc"), `{"plugin":["./plugins/team.ts"]}\n`) + await fs.mkdir(path.join(dir, ".opencode", "plugins"), { recursive: true }) + await Bun.write( + path.join(dir, ".opencode", "opencode.jsonc"), + `{ + "plugin": ["./plugins/team.ts"] +} +`, + ) await Bun.write(path.join(dir, ".opencode", "plugins", "team.ts"), "export default async function team() {}\n") await Bun.$`git add .gitignore README.md`.cwd(dir).quiet() await Bun.$`git commit -m "seed"`.cwd(dir).quiet() - await Bun.write(path.join(dir, ".gitignore"), "# temporarily removed during repair\n") }, }) let box = "" + const plugin = await team({ client: { permission: { @@ -201,19 +260,30 @@ test("team merge tolerates uncommitted removal of the .opencode gitignore rule", return { data: { permission: [] } } }, async create() { - return { data: { id: "ses_child_uncommitted_gitignore" } } + return { + data: { + id: "ses_child_ignored_opencode", + }, + } }, async promptAsync(input) { box = input.query.directory - expect(await Bun.file(path.join(box, ".gitignore")).text()).toContain(".opencode/") + expect(box).not.toBe(tmp.path) expect(await Bun.file(path.join(box, ".opencode", "opencode.jsonc")).text()).toContain("./plugins/team.ts") - await Bun.write(path.join(box, "worker-uncommitted.txt"), "worker output\n") + expect(await Bun.file(path.join(box, ".opencode", "plugins", "team.ts")).text()).toContain("export default") + await Bun.write(path.join(box, "worker.txt"), "worker output\n") }, async prompt() { return {} }, async status() { - return { data: { ses_child_uncommitted_gitignore: { type: "idle" } } } + return { + data: { + ses_child_ignored_opencode: { + type: "idle", + }, + }, + } }, async messages() { return { @@ -221,9 +291,13 @@ test("team merge tolerates uncommitted removal of the .opencode gitignore rule", { info: { role: "assistant", - time: { completed: Date.now() }, }, - parts: [{ type: "text", text: "done" }], + parts: [ + { + type: "text", + text: "done", + }, + ], }, ], } @@ -243,8 +317,8 @@ test("team merge tolerates uncommitted removal of the .opencode gitignore rule", limit: 1, tasks: [ { - id: "uncommitted-gitignore", - prompt: "write worker output", + id: "ignored-opencode", + prompt: "inspect local opencode config", write: true, }, ], @@ -255,25 +329,21 @@ test("team merge tolerates uncommitted removal of the .opencode gitignore rule", agent: "implement", directory: tmp.path, worktree: tmp.path, - abort: AbortSignal.timeout(5000), + abort: new AbortController().signal, + ask: () => Effect.void, metadata() {}, - ask() { - return undefined as never - }, }, ) - expect(out).toContain("- uncommitted-gitignore: done") + expect(out).toContain("run_id:") expect(box).toContain(path.join(".opencode", "team")) - expect(await Bun.file(path.join(tmp.path, "worker-uncommitted.txt")).text()).toBe("worker output\n") - expect(await Bun.file(path.join(tmp.path, ".gitignore")).text()).not.toContain(".opencode/") }) test("team rewrites parent absolute paths into isolated worker prompts", async () => { await using tmp = await tmpdir({ git: true, init: async (dir) => { - await mkdir(path.join(dir, "src"), { recursive: true }) + await fs.mkdir(path.join(dir, "src"), { recursive: true }) await Bun.write(path.join(dir, "src", "target.txt"), "before\n") await Bun.$`git add src/target.txt`.cwd(dir).quiet() await Bun.$`git commit -m "seed"`.cwd(dir).quiet() @@ -299,7 +369,11 @@ test("team rewrites parent absolute paths into isolated worker prompts", async ( return { data: { permission: [] } } }, async create() { - return { data: { id: "ses_child_absolute_path" } } + return { + data: { + id: "ses_child_absolute_path", + }, + } }, async promptAsync(input) { box = input.query.directory @@ -313,7 +387,13 @@ test("team rewrites parent absolute paths into isolated worker prompts", async ( return {} }, async status() { - return { data: { ses_child_absolute_path: { type: "idle" } } } + return { + data: { + ses_child_absolute_path: { + type: "idle", + }, + }, + } }, async messages() { return { @@ -323,7 +403,12 @@ test("team rewrites parent absolute paths into isolated worker prompts", async ( role: "assistant", time: { completed: Date.now() }, }, - parts: [{ type: "text", text: "done" }], + parts: [ + { + type: "text", + text: "done", + }, + ], }, ], } @@ -355,220 +440,120 @@ test("team rewrites parent absolute paths into isolated worker prompts", async ( agent: "implement", directory: tmp.path, worktree: tmp.path, - abort: AbortSignal.timeout(5000), + abort: new AbortController().signal, + ask: () => Effect.void, metadata() {}, - ask() { - return undefined as never - }, }, ) expect(out).toContain("- absolute-path: done") expect(box).toContain(path.join(".opencode", "team")) expect(await Bun.file(parent).text()).toBe("after\n") - const list = await readdir(path.join(tmp.path, ".opencode", "team"), { withFileTypes: true }).catch(() => []) - expect(list.filter((item) => item.isDirectory()).length).toBe(0) }) -test("background success clears the parallel implementation gate", async () => { - await using tmp = await tmpdir({ git: true }) - const plugin = await createPlugin(tmp.path) - - await plugin["chat.message"]?.( - { sessionID: "ses_background_done", agent: "implement" }, - { - message: { id: "msg_background_done", sessionID: "ses_background_done", role: "user" }, - parts: [ - { - type: "text", - text: - "Implement the following across packages/a, packages/b, and packages/c:\n" + - "- Add new types\n" + - "- Update imports\n" + - "- Add tests\n" + - "- Fix consumers\n" + - "Large multi-file implementation. Keep working in the background.", - }, - ], +test("team fails isolated write tasks that produce no patch", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "README.md"), "# test\n") + await Bun.$`git add README.md`.cwd(dir).quiet() + await Bun.$`git commit -m "seed"`.cwd(dir).quiet() }, - ) - - await expect( - plugin["tool.execute.before"]?.( - { tool: "edit", sessionID: "ses_background_done" }, - { args: { filePath: path.join(tmp.path, "src", "a.ts"), oldString: "a", newString: "b" } }, - ), - ).rejects.toThrow("Parallel implementation is enforced") - - await plugin["tool.execute.after"]?.( - { tool: "background", sessionID: "ses_background_done" }, - { title: "background run", output: "done", metadata: {} }, - ) - - await expect( - plugin["tool.execute.before"]?.( - { tool: "edit", sessionID: "ses_background_done" }, - { args: { filePath: path.join(tmp.path, "src", "a.ts"), oldString: "a", newString: "b" } }, - ), - ).resolves.toBeUndefined() -}) - -test("background failure also clears the parallel implementation gate", async () => { - await using tmp = await tmpdir({ git: true }) - const plugin = await createPlugin(tmp.path) + }) - await plugin["chat.message"]?.( - { sessionID: "ses_background_fail", agent: "implement" }, - { - message: { id: "msg_background_fail", sessionID: "ses_background_fail", role: "user" }, - parts: [ - { - type: "text", - text: - "Implement the following across packages/a, packages/b, and packages/c:\n" + - "- Add new types\n" + - "- Update imports\n" + - "- Add tests\n" + - "- Fix consumers\n" + - "Large multi-file implementation. Fan the work out.", + const plugin = await team({ + client: { + permission: { + async list() { + return { data: [] } }, - ], - }, - ) - - await expect( - plugin["tool.execute.before"]?.( - { tool: "edit", sessionID: "ses_background_fail" }, - { args: { filePath: path.join(tmp.path, "src", "a.ts"), oldString: "a", newString: "b" } }, - ), - ).rejects.toThrow("Parallel implementation is enforced") - - await plugin["tool.execute.error"]?.( - { tool: "background", sessionID: "ses_background_fail" }, - { error: new Error("background failed") }, - ) - - await expect( - plugin["tool.execute.before"]?.( - { tool: "edit", sessionID: "ses_background_fail" }, - { args: { filePath: path.join(tmp.path, "src", "a.ts"), oldString: "a", newString: "b" } }, - ), - ).resolves.toBeUndefined() -}) - -test("background uses the session directory for state when no git worktree is available", async () => { - await using tmp = await tmpdir() - const plugin = await createPlugin(tmp.path, "/") - - const result = await plugin.tool.background.execute( - { - description: "read-only non-git check", - prompt: "Run a read-only check in the current directory and report success.", - notify: false, - worktree: false, - }, - { - sessionID: "ses_non_git_background", - messageID: "msg_non_git_background", - agent: "build", - directory: tmp.path, - worktree: "/", - abort: AbortSignal.timeout(5000), - metadata() {}, - ask() { - return undefined as never }, - }, - ) - - expect(result).toContain("state: done") - const entries = await readdir(path.join(tmp.path, ".opencode", "guardrails", "team-runs")) - expect(entries.some((item) => item.endsWith(".json"))).toBeTrue() -}) - -test("background detaches worker execution from the parent abort signal", async () => { - await using tmp = await tmpdir() - const plugin = await createPlugin(tmp.path, "/") - const controller = new AbortController() - controller.abort() - - const result = await plugin.tool.background.execute( - { - description: "detached background check", - prompt: "Run a read-only check in the current directory and report success.", - notify: false, - worktree: false, - }, - { - sessionID: "ses_detached_background", - messageID: "msg_detached_background", - agent: "build", - directory: tmp.path, - worktree: "/", - abort: controller.signal, - metadata() {}, - ask() { - return undefined as never + question: { + async list() { + return { data: [] } + }, }, - }, - ) - - expect(result).toContain("state: done") -}) - -test("team worktrees carry root node_modules into isolated write tasks", async () => { - await using tmp = await tmpdir({ git: true }) - await Bun.write(path.join(tmp.path, "package.json"), '{"name":"fixture","private":true}') - await Bun.write(path.join(tmp.path, "tracked.txt"), "tracked\n") - await Bun.$`git add package.json tracked.txt`.cwd(tmp.path).quiet() - await Bun.$`git commit -m "test: seed worktree fixture"`.cwd(tmp.path).quiet() - await Bun.$`mkdir -p node_modules`.cwd(tmp.path).quiet() - await Bun.write(path.join(tmp.path, "node_modules", ".keep"), "") - - let workerDir = "" - let workerSawNodeModules = false - const plugin = await createPlugin(tmp.path, tmp.path, { - async promptAsync(input) { - workerDir = input.query.directory - workerSawNodeModules = await Bun.file(path.join(workerDir, "node_modules", ".keep")).exists() - return {} - }, - }) - - const result = await plugin.tool.team.execute( - { - strategy: "parallel", - limit: 1, - tasks: [ - { - id: "write-task", - description: "write task in isolated worktree", - prompt: "Edit a file in the repository and verify dependencies are available.", - write: true, + session: { + async get() { + return { data: { permission: [] } } + }, + async create() { + return { + data: { + id: "ses_child_no_patch", + }, + } + }, + async promptAsync() { + return {} + }, + async prompt() { + return {} + }, + async status() { + return { + data: { + ses_child_no_patch: { + type: "idle", + }, + }, + } + }, + async messages() { + return { + data: [ + { + info: { + role: "assistant", + time: { completed: Date.now() }, + }, + parts: [ + { + type: "text", + text: "done", + }, + ], + }, + ], + } + }, + async abort() { + return {} }, - ], - }, - { - sessionID: "ses_team_worktree", - messageID: "msg_team_worktree", - agent: "implement", - directory: tmp.path, - worktree: tmp.path, - abort: AbortSignal.timeout(5000), - metadata() {}, - ask() { - return undefined as never }, }, - ) + worktree: tmp.path, + directory: tmp.path, + }) - expect(workerDir).not.toBe(tmp.path) - expect(workerSawNodeModules).toBeTrue() - expect(result).toContain("state: done") - expect(result).toContain("no_patch=true") + await expect( + plugin.tool.team.execute( + { + strategy: "parallel", + limit: 1, + tasks: [ + { + id: "no-patch", + prompt: "write a note file", + write: true, + }, + ], + }, + { + sessionID: "ses_parent", + messageID: "msg_parent", + agent: "implement", + directory: tmp.path, + worktree: tmp.path, + abort: new AbortController().signal, + ask: () => Effect.void, + metadata() {}, + }, + ), + ).rejects.toThrow("Write task completed without producing a patch") }) -test("team rejects a task directory outside the active worktree and removes provisional worktrees", async () => { +test("team removes worktree when session create fails", async () => { await using tmp = await tmpdir({ git: true, init: async (dir) => { @@ -578,23 +563,2448 @@ test("team rejects a task directory outside the active worktree and removes prov }, }) - const outside = path.join(path.dirname(tmp.path), `opencode-outside-${crypto.randomUUID()}`) - await mkdir(outside, { recursive: true }) - - const plugin = await createPlugin(tmp.path, tmp.path, { - async create() { - throw new Error("session create should not run") - }, - async promptAsync() { - throw new Error("prompt should not run") - }, - }) - - try { - await expect( - plugin.tool.team.execute( - { - strategy: "parallel", + const plugin = await team({ + client: { + permission: { + async list() { + return { data: [] } + }, + }, + question: { + async list() { + return { data: [] } + }, + }, + session: { + async get() { + return { + data: { + permission: [], + }, + } + }, + async create() { + throw new Error("session create failed") + }, + async promptAsync() { + throw new Error("should not run") + }, + async prompt() { + return {} + }, + async status() { + return { data: {} } + }, + async messages() { + return { data: [] } + }, + async abort() { + return {} + }, + }, + }, + worktree: tmp.path, + directory: tmp.path, + }) + + await expect( + plugin.tool.team.execute( + { + strategy: "parallel", + limit: 1, + tasks: [ + { + id: "create-fail", + prompt: "write a note file", + write: true, + }, + ], + }, + { + sessionID: "ses_parent", + messageID: "msg_parent", + agent: "implement", + directory: tmp.path, + worktree: tmp.path, + abort: new AbortController().signal, + ask: () => Effect.void, + metadata() {}, + }, + ), + ).rejects.toThrow("session create failed") + + const list = await fs.readdir(path.join(tmp.path, ".opencode", "team"), { withFileTypes: true }).catch(() => []) + expect(list.filter((item) => item.isDirectory()).length).toBe(0) +}) + +test("team surfaces blocked child permissions instead of hanging", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "README.md"), "# test\n") + await Bun.$`git add README.md`.cwd(dir).quiet() + await Bun.$`git commit -m "seed"`.cwd(dir).quiet() + }, + }) + + const plugin = await team({ + client: { + permission: { + async list() { + return { + data: [ + { + id: "per_test", + sessionID: "ses_child_blocked", + permission: "bash", + patterns: ["npx vite --port 5173"], + metadata: { + description: "Check gstack browse availability", + }, + }, + ], + } + }, + }, + question: { + async list() { + return { data: [] } + }, + }, + session: { + async get() { + return { data: { permission: [] } } + }, + async create() { + return { + data: { + id: "ses_child_blocked", + }, + } + }, + async promptAsync() { + return {} + }, + async prompt() { + return {} + }, + async status() { + return { + data: { + ses_child_blocked: { + type: "busy", + }, + }, + } + }, + async messages() { + return { data: [] } + }, + async abort() { + return {} + }, + }, + }, + worktree: tmp.path, + directory: tmp.path, + }) + + await expect( + plugin.tool.team.execute( + { + strategy: "parallel", + limit: 1, + tasks: [ + { + id: "browser", + prompt: "run browser check", + write: false, + worktree: false, + }, + ], + }, + { + sessionID: "ses_parent", + messageID: "msg_parent", + agent: "implement", + directory: tmp.path, + worktree: tmp.path, + abort: new AbortController().signal, + ask: () => Effect.void, + metadata() {}, + }, + ), + ).rejects.toThrow("Blocked on permission: bash (Check gstack browse availability)") +}) + +test("team removes worktree when child prompt fails", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "README.md"), "# test\n") + await Bun.$`git add README.md`.cwd(dir).quiet() + await Bun.$`git commit -m "seed"`.cwd(dir).quiet() + }, + }) + + const plugin = await team({ + client: { + permission: { + async list() { + return { data: [] } + }, + }, + question: { + async list() { + return { data: [] } + }, + }, + session: { + async get() { + return { + data: { + permission: [], + }, + } + }, + async create() { + return { + data: { + id: "ses_child_prompt_fail", + }, + } + }, + async promptAsync() { + throw new Error("prompt failed") + }, + async prompt() { + return {} + }, + async status() { + return { + data: { + ses_child_prompt_fail: { + type: "idle", + }, + }, + } + }, + async messages() { + return { data: [] } + }, + async abort() { + return {} + }, + }, + }, + worktree: tmp.path, + directory: tmp.path, + }) + + await expect( + plugin.tool.team.execute( + { + strategy: "parallel", + limit: 1, + tasks: [ + { + id: "prompt-fail", + prompt: "write a note file", + write: true, + }, + ], + }, + { + sessionID: "ses_parent", + messageID: "msg_parent", + agent: "implement", + directory: tmp.path, + worktree: tmp.path, + abort: new AbortController().signal, + ask: () => Effect.void, + metadata() {}, + }, + ), + ).rejects.toThrow("prompt failed") + + const list = await fs.readdir(path.join(tmp.path, ".opencode", "team"), { withFileTypes: true }).catch(() => []) + expect(list.filter((item) => item.isDirectory()).length).toBe(0) +}) + +test("team persists failed runs without leaving tasks nonterminal", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "README.md"), "# test\n") + await Bun.$`git add README.md`.cwd(dir).quiet() + await Bun.$`git commit -m "seed"`.cwd(dir).quiet() + }, + }) + + const plugin = await team({ + client: { + permission: { + async list() { + return { + data: [ + { + id: "per_blocked_run", + sessionID: "ses_child_blocked_run", + permission: "bash", + patterns: ["npx vite --port 5173"], + metadata: { + description: "Check gstack browse availability", + }, + }, + ], + } + }, + }, + question: { + async list() { + return { data: [] } + }, + }, + session: { + async get() { + return { data: { permission: [] } } + }, + async create() { + return { + data: { + id: "ses_child_blocked_run", + }, + } + }, + async promptAsync() { + return {} + }, + async prompt() { + return {} + }, + async status() { + return { + data: { + ses_child_blocked_run: { + type: "busy", + }, + }, + } + }, + async messages() { + return { data: [] } + }, + async abort() { + return {} + }, + }, + }, + worktree: tmp.path, + directory: tmp.path, + }) + + await expect( + plugin.tool.team.execute( + { + strategy: "parallel", + limit: 1, + tasks: [ + { + id: "browser", + prompt: "run browser check", + write: false, + worktree: false, + }, + { + id: "follow", + prompt: "summarize the browser result", + depends: ["browser"], + write: false, + worktree: false, + }, + ], + }, + { + sessionID: "ses_parent", + messageID: "msg_parent", + agent: "implement", + directory: tmp.path, + worktree: tmp.path, + abort: new AbortController().signal, + ask: () => Effect.void, + metadata() {}, + }, + ), + ).rejects.toThrow("Blocked on permission: bash (Check gstack browse availability)") + + const root = path.join(tmp.path, ".opencode", "guardrails", "team-runs") + const list = await fs.readdir(root) + const saved = JSON.parse(await Bun.file(path.join(root, list[0]!)).text()) + + expect(saved.state).toBe("error") + expect(saved.tasks.map((item: { state: string }) => item.state)).toEqual(["error", "error"]) + expect(saved.tasks.every((item: { state: string }) => item.state === "done" || item.state === "error")).toBe(true) + expect(saved.tasks[0].failure_stage).toBe("blocked") + expect(saved.tasks[1].failure_stage).toBe("blocked") +}) + +test("background surfaces blocked child permissions before returning", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "README.md"), "# test\n") + await Bun.$`git add README.md`.cwd(dir).quiet() + await Bun.$`git commit -m "seed"`.cwd(dir).quiet() + }, + }) + + const plugin = await team({ + client: { + permission: { + async list() { + return { + data: [ + { + id: "per_bg", + sessionID: "ses_child_bg", + permission: "bash", + patterns: ['osascript -e "beep"'], + metadata: { + description: "background worker", + }, + }, + ], + } + }, + }, + question: { + async list() { + return { data: [] } + }, + }, + session: { + async get() { + return { data: { permission: [] } } + }, + async create() { + return { + data: { + id: "ses_child_bg", + }, + } + }, + async promptAsync() { + return {} + }, + async prompt() { + return {} + }, + async status() { + return { + data: { + ses_child_bg: { + type: "busy", + }, + }, + } + }, + async messages() { + return { data: [] } + }, + async abort() { + return {} + }, + }, + }, + worktree: tmp.path, + directory: tmp.path, + }) + + const out = await plugin.tool.background.execute( + { + description: "blocked-check", + prompt: "run the bash command 'osascript -e \"beep\"'", + write: false, + worktree: false, + notify: false, + }, + { + sessionID: "ses_parent", + messageID: "msg_parent", + agent: "implement", + directory: tmp.path, + worktree: tmp.path, + abort: new AbortController().signal, + ask: () => Effect.void, + metadata() {}, + }, + ) + + expect(out).toContain("state: error") + expect(out).toContain("failure_stage=blocked") +}) + +test("team falls back to tool output when child returns no text", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "README.md"), "# test\n") + await Bun.$`git add README.md`.cwd(dir).quiet() + await Bun.$`git commit -m "seed"`.cwd(dir).quiet() + }, + }) + + const plugin = await team({ + client: { + permission: { + async list() { + return { data: [] } + }, + }, + question: { + async list() { + return { data: [] } + }, + }, + session: { + async get() { + return { data: { permission: [] } } + }, + async create() { + return { + data: { + id: "ses_child_tool", + }, + } + }, + async promptAsync() { + return {} + }, + async prompt() { + return {} + }, + async status() { + return { + data: { + ses_child_tool: { + type: "idle", + }, + }, + } + }, + async messages() { + return { + data: [ + { + info: { + role: "assistant", + }, + parts: [ + { + type: "tool", + state: { + status: "completed", + output: "OPEN", + }, + }, + ], + }, + ], + } + }, + async abort() { + return {} + }, + }, + }, + worktree: tmp.path, + directory: tmp.path, + }) + + const out = await plugin.tool.team.execute( + { + strategy: "parallel", + limit: 1, + tasks: [ + { + id: "verify", + prompt: "check issue", + write: false, + worktree: false, + }, + ], + }, + { + sessionID: "ses_parent", + messageID: "msg_parent", + agent: "implement", + directory: tmp.path, + worktree: tmp.path, + abort: new AbortController().signal, + ask: () => Effect.void, + metadata() {}, + }, + ) + + expect(out).toContain("output=OPEN") +}) + +test("team keeps bash enabled for read-only workers and disables recursive delegation", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "README.md"), "# test\n") + await Bun.$`git add README.md`.cwd(dir).quiet() + await Bun.$`git commit -m "seed"`.cwd(dir).quiet() + }, + }) + + let tools: Record | undefined + + const plugin = await team({ + client: { + permission: { + async list() { + return { data: [] } + }, + }, + question: { + async list() { + return { data: [] } + }, + }, + session: { + async get() { + return { data: { permission: [] } } + }, + async create() { + return { + data: { + id: "ses_child_tools", + }, + } + }, + async promptAsync(input) { + tools = input.body.tools + }, + async prompt() { + return {} + }, + async status() { + return { + data: { + ses_child_tools: { + type: "idle", + }, + }, + } + }, + async messages() { + return { + data: [ + { + info: { + role: "assistant", + }, + parts: [ + { + type: "text", + text: "done", + }, + ], + }, + ], + } + }, + async abort() { + return {} + }, + }, + }, + worktree: tmp.path, + directory: tmp.path, + }) + + await plugin.tool.team.execute( + { + strategy: "parallel", + limit: 1, + tasks: [ + { + id: "verify", + prompt: "run read-only verification", + write: false, + worktree: false, + }, + ], + }, + { + sessionID: "ses_parent", + messageID: "msg_parent", + agent: "implement", + directory: tmp.path, + worktree: tmp.path, + abort: new AbortController().signal, + ask: () => Effect.void, + metadata() {}, + }, + ) + + expect(tools).toEqual({ + edit: false, + write: false, + apply_patch: false, + task: false, + todowrite: false, + }) +}) + +test("team rewrites nested opencode init prompts to direct bootstrap work", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "README.md"), "# test\n") + await Bun.$`git add README.md`.cwd(dir).quiet() + await Bun.$`git commit -m "seed"`.cwd(dir).quiet() + }, + }) + + let prompt = "" + + const plugin = await team({ + client: { + permission: { + async list() { + return { data: [] } + }, + }, + question: { + async list() { + return { data: [] } + }, + }, + session: { + async get() { + return { data: { permission: [] } } + }, + async create() { + return { + data: { + id: "ses_child_init_rewrite", + }, + } + }, + async promptAsync(input) { + prompt = input.body.parts[0]?.text ?? "" + await Bun.write(path.join(input.query.directory, "AGENTS.md"), "# test agent instructions\n") + }, + async prompt() { + return {} + }, + async status() { + return { + data: { + ses_child_init_rewrite: { + type: "idle", + }, + }, + } + }, + async messages() { + return { + data: [ + { + info: { + role: "assistant", + }, + parts: [ + { + type: "text", + text: "done", + }, + ], + }, + ], + } + }, + async abort() { + return {} + }, + }, + }, + worktree: tmp.path, + directory: tmp.path, + }) + + await plugin.tool.team.execute( + { + strategy: "parallel", + limit: 1, + tasks: [ + { + id: "run-init", + prompt: "Use bash to run `opencode run /init` in this isolated worktree and confirm AGENTS.md was created.", + write: true, + worktree: true, + }, + ], + }, + { + sessionID: "ses_parent", + messageID: "msg_parent", + agent: "implement", + directory: tmp.path, + worktree: tmp.path, + abort: new AbortController().signal, + ask: () => Effect.void, + metadata() {}, + }, + ) + + expect(prompt).not.toContain("Use bash to run `opencode run /init`") + expect(prompt).toContain("Worker execution rules:") + expect(prompt).toContain( + "perform the equivalent /init repository inspection and AGENTS.md bootstrap directly in this worktree", + ) + expect(prompt).toContain("Do not invoke nested OpenCode slash commands") + expect(prompt).toContain("Do not create git branches, clones, nested repositories, or nested worktrees") + expect(prompt).toContain("operate only on files inside the current worktree directory") +}) + +test("team surfaces permission.asked events instead of polling forever", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "README.md"), "# test\n") + await Bun.$`git add README.md`.cwd(dir).quiet() + await Bun.$`git commit -m "seed"`.cwd(dir).quiet() + }, + }) + + const plugin = await team({ + client: { + permission: { + async list() { + return { data: [] } + }, + }, + question: { + async list() { + return { data: [] } + }, + }, + event: { + async subscribe() { + return { + stream: (async function* () { + await Bun.sleep(20) + yield { + type: "permission.asked", + properties: { + sessionID: "ses_child_permission_asked", + permission: "bash", + patterns: ["opencode run /init"], + }, + } + })(), + } + }, + }, + session: { + async get() { + return { data: { permission: [] } } + }, + async create() { + return { + data: { + id: "ses_child_permission_asked", + }, + } + }, + async promptAsync() { + return {} + }, + async prompt() { + return {} + }, + async status() { + return { + data: { + ses_child_permission_asked: { + type: "busy", + }, + }, + } + }, + async messages() { + return { data: [] } + }, + async abort() { + return {} + }, + }, + }, + worktree: tmp.path, + directory: tmp.path, + }) + + await expect( + plugin.tool.team.execute( + { + strategy: "parallel", + limit: 1, + tasks: [ + { + id: "run-init", + prompt: "rerun opencode init", + write: true, + worktree: true, + }, + ], + }, + { + sessionID: "ses_parent", + messageID: "msg_parent", + agent: "implement", + directory: tmp.path, + worktree: tmp.path, + abort: new AbortController().signal, + ask: () => Effect.void, + metadata() {}, + }, + ), + ).rejects.toThrow("Blocked on permission: bash :: opencode run /init") +}) + +test("team surfaces child worktree-local permission asks from guardrail state", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "README.md"), "# test\n") + await fs.mkdir(path.join(dir, ".opencode", "guardrails"), { recursive: true }) + await Bun.write( + path.join(dir, ".opencode", "guardrails", "state.json"), + JSON.stringify({ + last_event: "permission.asked", + last_permission: "bash", + last_patterns: ["git ls-tree --name-only -r HEAD"], + }), + ) + await Bun.$`git add README.md`.cwd(dir).quiet() + await Bun.$`git commit -m "seed"`.cwd(dir).quiet() + }, + }) + + const plugin = await team({ + client: { + permission: { + async list() { + return { data: [] } + }, + }, + question: { + async list() { + return { data: [] } + }, + }, + session: { + async get() { + return { data: { permission: [] } } + }, + async create() { + return { + data: { + id: "ses_child_local_permission", + }, + } + }, + async promptAsync() { + return {} + }, + async prompt() { + return {} + }, + async status() { + return { + data: { + ses_child_local_permission: { + type: "busy", + }, + }, + } + }, + async messages() { + return { data: [] } + }, + async abort() { + return {} + }, + }, + }, + worktree: tmp.path, + directory: tmp.path, + }) + + await expect( + plugin.tool.team.execute( + { + strategy: "parallel", + limit: 1, + tasks: [ + { + id: "writer", + prompt: "write AGENTS.md", + write: false, + worktree: false, + }, + ], + }, + { + sessionID: "ses_parent", + messageID: "msg_parent", + agent: "implement", + directory: tmp.path, + worktree: tmp.path, + abort: new AbortController().signal, + ask: () => Effect.void, + metadata() {}, + }, + ), + ).rejects.toThrow("Blocked on permission: bash :: git ls-tree --name-only -r HEAD") +}) + +test("team waits when child status is temporarily missing before idle", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "README.md"), "# test\n") + await Bun.$`git add README.md`.cwd(dir).quiet() + await Bun.$`git commit -m "seed"`.cwd(dir).quiet() + }, + }) + + let turn = 0 + + const plugin = await team({ + client: { + permission: { + async list() { + return { data: [] } + }, + }, + question: { + async list() { + return { data: [] } + }, + }, + session: { + async get() { + return { data: { permission: [] } } + }, + async create() { + return { + data: { + id: "ses_child_wait", + }, + } + }, + async promptAsync() { + return {} + }, + async prompt() { + return {} + }, + async status() { + turn += 1 + if (turn === 1) return { data: {} as Record } + if (turn === 2) { + return { + data: { + ses_child_wait: { + type: "busy", + }, + }, + } + } + return { + data: { + ses_child_wait: { + type: "idle", + }, + }, + } + }, + async messages() { + return { + data: [ + { + info: { + role: "assistant", + }, + parts: [ + { + type: "text", + text: "finished", + }, + ], + }, + ], + } + }, + async abort() { + return {} + }, + }, + }, + worktree: tmp.path, + directory: tmp.path, + }) + + const out = await plugin.tool.team.execute( + { + strategy: "parallel", + limit: 1, + tasks: [ + { + id: "wait", + prompt: "wait for child", + write: false, + worktree: false, + }, + ], + }, + { + sessionID: "ses_parent", + messageID: "msg_parent", + agent: "implement", + directory: tmp.path, + worktree: tmp.path, + abort: new AbortController().signal, + ask: () => Effect.void, + metadata() {}, + }, + ) + + expect(turn).toBe(3) + expect(out).toContain("output=finished") +}) + +test("team does not finish on non-completed progress when child status disappears", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "README.md"), "# test\n") + await Bun.$`git add README.md`.cwd(dir).quiet() + await Bun.$`git commit -m "seed"`.cwd(dir).quiet() + }, + }) + + let turn = 0 + const abort = new AbortController() + + const plugin = await team({ + client: { + permission: { + async list() { + return { data: [] } + }, + }, + question: { + async list() { + return { data: [] } + }, + }, + session: { + async get() { + return { data: { permission: [] } } + }, + async create() { + return { + data: { + id: "ses_child_gone_idle", + }, + } + }, + async promptAsync() { + return {} + }, + async prompt() { + return {} + }, + async status() { + turn += 1 + if (turn === 1) { + return { + data: { + ses_child_gone_idle: { + type: "busy", + }, + }, + } + } + return { data: {} as Record } + }, + async messages() { + return { + data: [ + { + info: { + role: "assistant", + }, + parts: [ + { + type: "text", + text: "Inspecting the repository before editing.", + }, + ], + }, + ], + } + }, + async abort() { + return {} + }, + }, + }, + worktree: tmp.path, + directory: tmp.path, + }) + + setTimeout(() => abort.abort(), 1100) + + await expect( + plugin.tool.team.execute( + { + strategy: "parallel", + limit: 1, + tasks: [ + { + id: "verify", + prompt: "check repo facts", + write: false, + worktree: false, + }, + ], + }, + { + sessionID: "ses_parent", + messageID: "msg_parent", + agent: "implement", + directory: tmp.path, + worktree: tmp.path, + abort: abort.signal, + ask: () => Effect.void, + metadata() {}, + }, + ), + ).rejects.toThrow("Aborted") + + expect(turn).toBeGreaterThanOrEqual(2) +}) + +test("team treats session.idle event as completion even when status never appears", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "README.md"), "# test\n") + await Bun.$`git add README.md`.cwd(dir).quiet() + await Bun.$`git commit -m "seed"`.cwd(dir).quiet() + }, + }) + + const plugin = await team({ + client: { + permission: { + async list() { + return { data: [] } + }, + }, + question: { + async list() { + return { data: [] } + }, + }, + event: { + async subscribe() { + return { + stream: (async function* () { + await Bun.sleep(20) + yield { + type: "session.idle", + properties: { + sessionID: "ses_child_event_idle", + }, + } + })(), + } + }, + }, + session: { + async get() { + return { data: { permission: [] } } + }, + async create() { + return { + data: { + id: "ses_child_event_idle", + }, + } + }, + async promptAsync() { + return {} + }, + async prompt() { + return {} + }, + async status() { + return { data: {} as Record } + }, + async messages() { + return { + data: [ + { + info: { + role: "assistant", + }, + parts: [ + { + type: "text", + text: "finished", + }, + ], + }, + ], + } + }, + async abort() { + return {} + }, + }, + }, + worktree: tmp.path, + directory: tmp.path, + }) + + const out = await plugin.tool.team.execute( + { + strategy: "parallel", + limit: 1, + tasks: [ + { + id: "verify", + prompt: "check repo facts", + write: false, + worktree: false, + }, + ], + }, + { + sessionID: "ses_parent", + messageID: "msg_parent", + agent: "implement", + directory: tmp.path, + worktree: tmp.path, + abort: new AbortController().signal, + ask: () => Effect.void, + metadata() {}, + }, + ) + + expect(out).toContain("- verify: done") + expect(out).toContain("output=finished") +}) + +test("team treats completed assistant messages as completion even when status stays busy", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "README.md"), "# test\n") + await Bun.$`git add README.md`.cwd(dir).quiet() + await Bun.$`git commit -m "seed"`.cwd(dir).quiet() + }, + }) + + const plugin = await team({ + client: { + permission: { + async list() { + return { data: [] } + }, + }, + question: { + async list() { + return { data: [] } + }, + }, + session: { + async get() { + return { data: { permission: [] } } + }, + async create() { + return { + data: { + id: "ses_child_completed_busy", + }, + } + }, + async promptAsync() { + return {} + }, + async prompt() { + return {} + }, + async status() { + return { + data: { + ses_child_completed_busy: { + type: "busy", + }, + }, + } + }, + async messages() { + return { + data: [ + { + info: { + role: "assistant", + time: { + completed: Date.now(), + }, + }, + parts: [ + { + type: "text", + text: "finished from completed message", + }, + ], + }, + ], + } + }, + async abort() { + return {} + }, + }, + }, + worktree: tmp.path, + directory: tmp.path, + }) + + const out = await plugin.tool.team.execute( + { + strategy: "parallel", + limit: 1, + tasks: [ + { + id: "verify", + prompt: "check repo facts", + write: false, + worktree: false, + }, + ], + }, + { + sessionID: "ses_parent", + messageID: "msg_parent", + agent: "implement", + directory: tmp.path, + worktree: tmp.path, + abort: new AbortController().signal, + ask: () => Effect.void, + metadata() {}, + }, + ) + + expect(out).toContain("- verify: done") + expect(out).toContain("output=finished from completed message") +}) + +test("team retries event subscriptions before consuming session.idle", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "README.md"), "# test\n") + await Bun.$`git add README.md`.cwd(dir).quiet() + await Bun.$`git commit -m "seed"`.cwd(dir).quiet() + }, + }) + + let calls = 0 + + const plugin = await team({ + client: { + permission: { + async list() { + return { data: [] } + }, + }, + question: { + async list() { + return { data: [] } + }, + }, + event: { + async subscribe() { + calls += 1 + if (calls === 1) + return {} as { stream: AsyncIterable<{ type?: string; properties?: Record }> } + return { + stream: (async function* () { + await Bun.sleep(20) + yield { + type: "session.idle", + properties: { + sessionID: "ses_child_retry_idle", + }, + } + })(), + } + }, + }, + session: { + async get() { + return { data: { permission: [] } } + }, + async create() { + return { + data: { + id: "ses_child_retry_idle", + }, + } + }, + async promptAsync() { + return {} + }, + async prompt() { + return {} + }, + async status() { + return { data: {} as Record } + }, + async messages() { + return { data: [] } + }, + async abort() { + return {} + }, + }, + }, + worktree: tmp.path, + directory: tmp.path, + }) + + const out = await plugin.tool.team.execute( + { + strategy: "parallel", + limit: 1, + tasks: [ + { + id: "verify", + prompt: "check repo facts", + write: false, + worktree: false, + }, + ], + }, + { + sessionID: "ses_parent", + messageID: "msg_parent", + agent: "implement", + directory: tmp.path, + worktree: tmp.path, + abort: new AbortController().signal, + ask: () => Effect.void, + metadata() {}, + }, + ) + + expect(calls).toBeGreaterThanOrEqual(2) + expect(out).toContain("- verify: done") +}) + +test("team_status reconciles stale running runs from completed child messages", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "README.md"), "# test\n") + await fs.mkdir(path.join(dir, ".opencode", "guardrails", "team-runs"), { recursive: true }) + await Bun.write( + path.join(dir, ".opencode", "guardrails", "team-runs", "run-1.json"), + JSON.stringify( + { + id: "run-1", + kind: "team", + state: "running", + session: "ses_parent", + directory: dir, + created_at: new Date(0).toISOString(), + updated_at: new Date(0).toISOString(), + tasks: [ + { + id: "verify", + description: "verify", + prompt: "check repo facts", + depends: [], + agent: "explore", + write: false, + worktree: false, + provider: "openai", + model: "gpt-5.4", + variant: "high", + state: "running", + dir, + session: "ses_child_stale_run", + patch: "", + output: "", + error: "", + }, + ], + }, + null, + 2, + ) + "\n", + ) + await Bun.$`git add README.md`.cwd(dir).quiet() + await Bun.$`git commit -m "seed"`.cwd(dir).quiet() + }, + }) + + const plugin = await team({ + client: { + permission: { + async list() { + return { data: [] } + }, + }, + question: { + async list() { + return { data: [] } + }, + }, + session: { + async get() { + return { data: { permission: [] } } + }, + async create() { + return { data: { id: "unused" } } + }, + async promptAsync() { + return {} + }, + async prompt() { + return {} + }, + async status() { + return { + data: { + ses_child_stale_run: { + type: "busy", + }, + }, + } + }, + async messages() { + return { + data: [ + { + info: { + role: "assistant", + time: { + completed: Date.now(), + }, + }, + parts: [ + { + type: "text", + text: "stale run completed", + }, + ], + }, + ], + } + }, + async abort() { + return {} + }, + }, + }, + worktree: tmp.path, + directory: tmp.path, + }) + + const out = await plugin.tool.team_status.execute( + {}, + { + sessionID: "ses_parent", + messageID: "msg_parent", + agent: "implement", + directory: tmp.path, + worktree: tmp.path, + abort: new AbortController().signal, + ask: () => Effect.void, + metadata() {}, + }, + ) + + const saved = JSON.parse( + await Bun.file(path.join(tmp.path, ".opencode", "guardrails", "team-runs", "run-1.json")).text(), + ) + expect(out).toContain("run_id: run-1") + expect(out).toContain("state: done") + expect(out).toContain("output=stale run completed") + expect(saved.state).toBe("done") + expect(saved.tasks[0].state).toBe("done") + expect(saved.tasks[0].output).toBe("stale run completed") +}) + +test("team execute sweeps stale running runs before launching new work", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "README.md"), "# test\n") + await fs.mkdir(path.join(dir, ".opencode", "guardrails", "team-runs"), { recursive: true }) + await Bun.write( + path.join(dir, ".opencode", "guardrails", "team-runs", "stale-run.json"), + JSON.stringify( + { + id: "stale-run", + kind: "team", + state: "running", + session: "ses_parent", + directory: dir, + created_at: new Date(0).toISOString(), + updated_at: new Date(0).toISOString(), + tasks: [ + { + id: "stale", + description: "stale", + prompt: "old work", + depends: [], + agent: "general", + write: false, + worktree: false, + provider: "openai", + model: "gpt-5.4", + variant: "high", + state: "running", + dir, + session: "ses_child_stale_run_execute", + patch: "", + output: "", + error: "", + }, + ], + }, + null, + 2, + ) + "\n", + ) + await Bun.$`git add README.md`.cwd(dir).quiet() + await Bun.$`git commit -m "seed"`.cwd(dir).quiet() + }, + }) + + let created = 0 + + const plugin = await team({ + client: { + permission: { + async list() { + return { data: [] } + }, + }, + question: { + async list() { + return { data: [] } + }, + }, + session: { + async get() { + return { data: { permission: [] } } + }, + async create() { + created += 1 + return { data: { id: "ses_child_new_execute" } } + }, + async promptAsync() { + return {} + }, + async prompt() { + return {} + }, + async status() { + return { + data: { + ses_child_stale_run_execute: { type: "busy" }, + ses_child_new_execute: { type: "idle" }, + }, + } + }, + async messages(input) { + if (input.path.id === "ses_child_stale_run_execute") { + return { + data: [ + { + info: { + role: "assistant", + time: { + completed: Date.now(), + }, + }, + parts: [ + { + type: "text", + text: "stale execute completed", + }, + ], + }, + ], + } + } + return { + data: [ + { + info: { + role: "assistant", + time: { + completed: Date.now(), + }, + }, + parts: [ + { + type: "text", + text: "fresh execute completed", + }, + ], + }, + ], + } + }, + async abort() { + return {} + }, + }, + }, + worktree: tmp.path, + directory: tmp.path, + }) + + const out = await plugin.tool.team.execute( + { + strategy: "parallel", + limit: 1, + tasks: [ + { + id: "fresh", + prompt: "new read-only work", + write: false, + worktree: false, + }, + ], + }, + { + sessionID: "ses_parent", + messageID: "msg_parent", + agent: "implement", + directory: tmp.path, + worktree: tmp.path, + abort: new AbortController().signal, + ask: () => Effect.void, + metadata() {}, + }, + ) + + const stale = JSON.parse( + await Bun.file(path.join(tmp.path, ".opencode", "guardrails", "team-runs", "stale-run.json")).text(), + ) + expect(created).toBe(1) + expect(out).toContain("- fresh: done") + expect(stale.state).toBe("done") + expect(stale.tasks[0].state).toBe("done") + expect(stale.tasks[0].output).toBe("stale execute completed") +}) + +test("team startup sweep reconciles stale runs without explicit tool use", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "README.md"), "# test\n") + await fs.mkdir(path.join(dir, ".opencode", "guardrails", "team-runs"), { recursive: true }) + await Bun.write( + path.join(dir, ".opencode", "guardrails", "team-runs", "startup-stale.json"), + JSON.stringify( + { + id: "startup-stale", + kind: "team", + state: "running", + session: "ses_parent", + directory: dir, + created_at: new Date(0).toISOString(), + updated_at: new Date(0).toISOString(), + tasks: [ + { + id: "stale", + description: "stale", + prompt: "old work", + depends: [], + agent: "general", + write: false, + worktree: false, + provider: "openai", + model: "gpt-5.4", + variant: "high", + state: "running", + dir, + session: "ses_child_startup_stale", + patch: "", + output: "", + error: "", + }, + ], + }, + null, + 2, + ) + "\n", + ) + await Bun.$`git add README.md`.cwd(dir).quiet() + await Bun.$`git commit -m "seed"`.cwd(dir).quiet() + }, + }) + + await team({ + client: { + permission: { + async list() { + return { data: [] } + }, + }, + question: { + async list() { + return { data: [] } + }, + }, + session: { + async get() { + return { data: { permission: [] } } + }, + async create() { + return { data: { id: "unused" } } + }, + async promptAsync() { + return {} + }, + async prompt() { + return {} + }, + async status() { + return { + data: { + ses_child_startup_stale: { type: "busy" }, + }, + } + }, + async messages() { + return { + data: [ + { + info: { + role: "assistant", + time: { + completed: Date.now(), + }, + }, + parts: [ + { + type: "text", + text: "startup stale completed", + }, + ], + }, + ], + } + }, + async abort() { + return {} + }, + }, + }, + worktree: tmp.path, + directory: tmp.path, + }) + + for (let i = 0; i < 20; i += 1) { + const stale = JSON.parse( + await Bun.file(path.join(tmp.path, ".opencode", "guardrails", "team-runs", "startup-stale.json")).text(), + ) + if (stale.state === "done") { + expect(stale.tasks[0].state).toBe("done") + expect(stale.tasks[0].output).toBe("startup stale completed") + return + } + await Bun.sleep(50) + } + + throw new Error("startup sweep did not reconcile stale run") +}) + +test("chat.message hook also sweeps stale runs during normal lifecycle", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "README.md"), "# test\n") + await fs.mkdir(path.join(dir, ".opencode", "guardrails", "team-runs"), { recursive: true }) + await Bun.write( + path.join(dir, ".opencode", "guardrails", "team-runs", "chat-stale.json"), + JSON.stringify( + { + id: "chat-stale", + kind: "team", + state: "running", + session: "ses_parent", + directory: dir, + created_at: new Date(0).toISOString(), + updated_at: new Date(0).toISOString(), + tasks: [ + { + id: "stale", + description: "stale", + prompt: "old work", + depends: [], + agent: "general", + write: false, + worktree: false, + provider: "openai", + model: "gpt-5.4", + variant: "high", + state: "running", + dir, + session: "ses_child_chat_stale", + patch: "", + output: "", + error: "", + }, + ], + }, + null, + 2, + ) + "\n", + ) + await Bun.$`git add README.md`.cwd(dir).quiet() + await Bun.$`git commit -m "seed"`.cwd(dir).quiet() + }, + }) + + const plugin = await team({ + client: { + permission: { + async list() { + return { data: [] } + }, + }, + question: { + async list() { + return { data: [] } + }, + }, + session: { + async get() { + return { data: { permission: [] } } + }, + async create() { + return { data: { id: "unused" } } + }, + async promptAsync() { + return {} + }, + async prompt() { + return {} + }, + async status() { + return { + data: { + ses_child_chat_stale: { type: "busy" }, + }, + } + }, + async messages() { + return { + data: [ + { + info: { + role: "assistant", + time: { + completed: Date.now(), + }, + }, + parts: [ + { + type: "text", + text: "chat stale completed", + }, + ], + }, + ], + } + }, + async abort() { + return {} + }, + }, + }, + worktree: tmp.path, + directory: tmp.path, + }) + + await plugin["chat.message"]?.( + { + sessionID: "ses_parent", + agent: "implement", + }, + { + message: { + id: "msg_parent", + sessionID: "ses_parent", + role: "user", + }, + parts: [ + { + type: "text", + text: "Please inspect the repo and tell me what to change.", + }, + ], + }, + ) + + for (let i = 0; i < 20; i += 1) { + const stale = JSON.parse( + await Bun.file(path.join(tmp.path, ".opencode", "guardrails", "team-runs", "chat-stale.json")).text(), + ) + if (stale.state === "done") { + expect(stale.tasks[0].state).toBe("done") + expect(stale.tasks[0].output).toBe("chat stale completed") + return + } + await Bun.sleep(50) + } + + throw new Error("chat.message sweep did not reconcile stale run") +}) + +test("team merge excludes runtime artifacts and leaves unrelated parent edits untouched", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "README.md"), "# test\n") + await Bun.$`git add README.md`.cwd(dir).quiet() + await Bun.$`git commit -m "seed"`.cwd(dir).quiet() + await Bun.write(path.join(dir, "local-note.txt"), "keep me local\n") + }, + }) + + let box = "" + + const plugin = await team({ + client: { + permission: { + async list() { + return { data: [] } + }, + }, + question: { + async list() { + return { data: [] } + }, + }, + session: { + async get() { + return { data: { permission: [] } } + }, + async create() { + return { + data: { + id: "ses_child_runtime_merge", + }, + } + }, + async promptAsync(input) { + box = input.query.directory + await fs.mkdir(path.join(box, ".opencode", "guardrails"), { recursive: true }) + await fs.mkdir(path.join(box, ".opencode", "memory"), { recursive: true }) + await Bun.write(path.join(box, ".opencode", "guardrails", "state.json"), `{"last_event":"permission.asked"}`) + await Bun.write(path.join(box, ".opencode", "memory", "MEMORY.md"), "# runtime memory\n") + await Bun.write(path.join(box, "worker.txt"), "worker output\n") + return {} + }, + async prompt() { + return {} + }, + async status() { + return { + data: { + ses_child_runtime_merge: { + type: "idle", + }, + }, + } + }, + async messages() { + return { + data: [ + { + info: { + role: "assistant", + time: { + completed: Date.now(), + }, + }, + parts: [ + { + type: "text", + text: "worker finished", + }, + ], + }, + ], + } + }, + async abort() { + return {} + }, + }, + }, + worktree: tmp.path, + directory: tmp.path, + }) + + const out = await plugin.tool.team.execute( + { + strategy: "parallel", + limit: 1, + tasks: [ + { + id: "merge-runtime", + prompt: "write worker output", + write: true, + worktree: true, + }, + ], + }, + { + sessionID: "ses_parent", + messageID: "msg_parent", + agent: "implement", + directory: tmp.path, + worktree: tmp.path, + abort: new AbortController().signal, + ask: () => Effect.void, + metadata() {}, + }, + ) + + expect(out).toContain("merge-runtime: done") + expect(box).toContain(path.join(".opencode", "team")) + expect(await Bun.file(path.join(tmp.path, "worker.txt")).text()).toBe("worker output\n") + expect(await Bun.file(path.join(tmp.path, ".opencode", "guardrails", "state.json")).exists()).toBe(false) + expect(await Bun.file(path.join(tmp.path, ".opencode", "memory", "MEMORY.md")).exists()).toBe(false) + + const statusWorker = await Bun.$`git status --porcelain -- worker.txt`.cwd(tmp.path).text() + const statusLocal = await Bun.$`git status --porcelain -- local-note.txt`.cwd(tmp.path).text() + expect(statusWorker.trim()).toBe("") + expect(statusLocal.trim()).toBe("?? local-note.txt") + + const patches = (await fs.readdir(path.join(tmp.path, ".opencode", "guardrails", "team-runs"))).filter((item) => + item.endsWith(".patch"), + ) + expect(patches.length).toBeGreaterThan(0) + const patchBody = await Bun.file(path.join(tmp.path, ".opencode", "guardrails", "team-runs", patches[0]!)).text() + expect(patchBody).toContain("worker.txt") + expect(patchBody).not.toContain(".opencode/guardrails/state.json") + expect(patchBody).not.toContain(".opencode/memory/MEMORY.md") +}) + +test("team uses an existing sibling git worktree mentioned in the task prompt", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "README.md"), "# test\n") + await Bun.$`git add README.md`.cwd(dir).quiet() + await Bun.$`git commit -m "seed"`.cwd(dir).quiet() + }, + }) + + const target = path.join(path.dirname(tmp.path), `opencode-existing-worktree-${crypto.randomUUID()}`) + await Bun.$`git worktree add --detach ${target} HEAD`.cwd(tmp.path).quiet() + + let box = "" + try { + const plugin = await team({ + client: { + permission: { + async list() { + return { data: [] } + }, + }, + question: { + async list() { + return { data: [] } + }, + }, + session: { + async get() { + return { data: { permission: [] } } + }, + async create(input) { + box = input.query.directory + return { + data: { + id: "ses_existing_worktree", + }, + } + }, + async promptAsync(input) { + expect(input.query.directory).toBe(await fs.realpath(target)) + await Bun.write(path.join(input.query.directory, "worker.txt"), "worker output\n") + return {} + }, + async prompt() { + return {} + }, + async status() { + return { + data: { + ses_existing_worktree: { + type: "idle", + }, + }, + } + }, + async messages() { + return { + data: [ + { + info: { + role: "assistant", + time: { + completed: Date.now(), + }, + }, + parts: [ + { + type: "text", + text: "worker finished", + }, + ], + }, + ], + } + }, + async abort() { + return {} + }, + }, + }, + worktree: tmp.path, + directory: tmp.path, + }) + + const out = await plugin.tool.team.execute( + { + strategy: "parallel", + limit: 1, + tasks: [ + { + id: "existing-worktree", + prompt: `You are working in a **git worktree** at:\n\`${target}\`\n\nModify worker.txt.`, + write: true, + worktree: true, + }, + ], + }, + { + sessionID: "ses_parent", + messageID: "msg_parent", + agent: "implement", + directory: tmp.path, + worktree: tmp.path, + abort: new AbortController().signal, + ask: () => Effect.void, + metadata() {}, + }, + ) + + expect(box).toBe(await fs.realpath(target)) + expect(box).not.toContain(path.join(".opencode", "team")) + expect(await Bun.file(path.join(target, "worker.txt")).text()).toBe("worker output\n") + expect(out).toContain("existing-worktree: done") + expect(out).not.toContain("no_patch=true") + } finally { + await Bun.$`git worktree remove --force ${target}` + .cwd(tmp.path) + .quiet() + .catch(() => undefined) + await fs.rm(target, { recursive: true, force: true }) + } +}) + +test("team rejects directories outside the project worktree and cleans provisional worktrees", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "README.md"), "# test\n") + await Bun.$`git add README.md`.cwd(dir).quiet() + await Bun.$`git commit -m "seed"`.cwd(dir).quiet() + }, + }) + + const out = path.join(path.dirname(tmp.path), `opencode-outside-${crypto.randomUUID()}`) + await fs.mkdir(out, { recursive: true }) + + const plugin = await team({ + client: { + permission: { + async list() { + return { data: [] } + }, + }, + question: { + async list() { + return { data: [] } + }, + }, + session: { + async get() { + return { data: { permission: [] } } + }, + async create() { + throw new Error("session create should not run") + }, + async promptAsync() { + throw new Error("prompt should not run") + }, + async prompt() { + return {} + }, + async status() { + return { data: {} } + }, + async messages() { + return { data: [] } + }, + async abort() { + return {} + }, + }, + }, + worktree: tmp.path, + directory: tmp.path, + }) + + try { + await expect( + plugin.tool.team.execute( + { + strategy: "parallel", limit: 1, tasks: [ { @@ -608,20 +3018,18 @@ test("team rejects a task directory outside the active worktree and removes prov sessionID: "ses_parent", messageID: "msg_parent", agent: "implement", - directory: outside, + directory: out, worktree: tmp.path, - abort: AbortSignal.timeout(5000), + abort: new AbortController().signal, + ask: () => Effect.void, metadata() {}, - ask() { - return undefined as never - }, }, ), ).rejects.toThrow("directory is outside worktree") - const list = await readdir(path.join(tmp.path, ".opencode", "team"), { withFileTypes: true }).catch(() => []) + const list = await fs.readdir(path.join(tmp.path, ".opencode", "team"), { withFileTypes: true }).catch(() => []) expect(list.filter((item) => item.isDirectory()).length).toBe(0) } finally { - await rm(outside, { recursive: true, force: true }) + await fs.rm(out, { recursive: true, force: true }) } })