From 8b93bc395d1d0d85d9e1a58d700b92b0820487af Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 21 Aug 2026 02:29:50 -0500 Subject: [PATCH] feat(core): allow plan mode to write/edit PLAN files exclusively (#43710) --- packages/core/src/plugin/internal.ts | 2 +- packages/core/src/plugin/plan.ts | 59 +++++++---- packages/core/test/plugin/plan.test.ts | 138 ++++++++++++++++++++++++- 3 files changed, 175 insertions(+), 24 deletions(-) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 3205ac8c645..547528ab68b 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -236,7 +236,6 @@ const pre = [ MCPCodeModeExclusionPlugin.Plugin, WellKnownPlugin.Plugin, AgentPlugin.Plugin, - PlanPlugin.Plugin, CommandPlugin.Plugin, SkillPlugin.Plugin, ...SystemPromptPlugin.Plugins, @@ -275,6 +274,7 @@ const post = [ ConfigWebSearchPlugin.Plugin, VariantPlugin.Plugin, ConfigPolicyPlugin.Plugin, + PlanPlugin.Plugin, ] as const satisfies readonly InternalPlugin[] export const list = Effect.fn("PluginInternal.list")(function* () { diff --git a/packages/core/src/plugin/plan.ts b/packages/core/src/plugin/plan.ts index 4f9edb7c72f..905b8a9efbb 100644 --- a/packages/core/src/plugin/plan.ts +++ b/packages/core/src/plugin/plan.ts @@ -2,16 +2,23 @@ export * as PlanPlugin from "./plan.js" import { Message, ToolFailure } from "@opencode-ai/ai" import { define } from "@opencode-ai/plugin/effect/plugin" +import { Global } from "@opencode-ai/util/global" import { Effect, Stream } from "effect" +import path from "path" import { Agent } from "../agent.js" +import { Environment } from "../environment/index.js" +import { Permission } from "../permission.js" import { SessionEvent } from "../session/event.js" const plan = Agent.ID.make("plan") -const enter = ` -You are in Plan mode. You are not allowed to edit or create files, and you may not ask a subagent to do that either. +const enter = (directory: string) => ` +You are in Plan mode. You may optionally create or update plan documents in: +${directory} -You are in Plan mode until the user switches agents. Plan mode is not changed by user intent, tone, or imperative language. If the user asks you to change files, do not edit. Tell them they need to switch agents. +Do not modify any other files or ask a subagent to do so. + +You remain in Plan mode until the user switches agents. If the user asks you to implement changes, do not do so. Tell them they need to switch agents. ` const leave = ` @@ -21,30 +28,42 @@ You are NO LONGER in Plan mode. The previous Plan restrictions no longer apply. export const Plugin = define({ id: "opencode.plan", effect: Effect.fn(function* (ctx) { + const environment = yield* Environment.Service + const global = yield* Global.Service + const directory = path.join(global.home, ".opencode", "plan") + const enterReminder = enter(directory) + yield* environment.files.mkdir(directory).pipe(Effect.orDie) + yield* ctx.agent.transform((draft) => { draft.update(plan, (item) => { item.name = Agent.Name.make("Plan") item.description = "Read-only agent for exploring the codebase and planning work before implementation." item.mode = "primary" item.permissions.push({ action: "question", resource: "*", effect: "allow" }) + item.permissions.push({ action: "edit", resource: "*", effect: "deny" }) + item.permissions.push({ action: "edit", resource: path.join(directory, "*"), effect: "allow" }) + item.permissions.push({ action: "external_directory", resource: path.join(directory, "*"), effect: "allow" }) }) }) - yield* ctx.tool.hook("execute.before", (event) => { + yield* ctx.tool.hook("execute.after", (event) => { if (event.agent !== plan) return Effect.void + if (event.status !== "error") return Effect.void if (event.tool !== "edit" && event.tool !== "write" && event.tool !== "patch") return Effect.void - return new ToolFailure({ - message: `Cannot use ${event.tool} in Plan mode. You are in a read-only mode and must not modify files.`, + if (!(event.error.error instanceof Permission.BlockedError)) return Effect.void + event.error = new ToolFailure({ + message: `Cannot use ${event.tool} to modify files outside the Plan directory: ${directory}`, }) + return Effect.void }) // Compaction and committed reverts can strip reminders while the session's agent stays // put. Reconcile per request, appending near the tail so the cached prefix stays warm. yield* ctx.session.hook("context", (event) => { - const reminder = lastReminder(event.messages) - const missing = event.agent === plan && reminder !== enter - const stale = event.agent !== plan && reminder === enter - const text = missing ? enter : stale ? leave : undefined + const reminder = lastReminder(event.messages, enterReminder) + const missing = event.agent === plan && reminder !== enterReminder + const stale = event.agent !== plan && reminder === enterReminder + const text = missing ? enterReminder : stale ? leave : undefined if (!text) return Effect.void // Before the user's prompt, matching where agent-switch reminders land. const at = event.messages.at(-1)?.role === "user" ? event.messages.length - 1 : event.messages.length @@ -64,7 +83,7 @@ export const Plugin = define({ event.type === "session.created" || event.type === "session.agent.selected", ), Stream.runForEach((event) => { - const text = switchReminder(event) + const text = switchReminder(event, enterReminder) if (!text) return Effect.void return ctx.session .synthetic({ @@ -83,20 +102,24 @@ export const Plugin = define({ }), }) -function switchReminder(event: SessionEvent.Created | SessionEvent.AgentSelected) { +function switchReminder( + event: SessionEvent.Created | SessionEvent.AgentSelected, + enterReminder: string, +): string | undefined { if (event.type === "session.created") { - if (event.data.agent !== plan) return - return enter + if (event.data.agent !== plan) return undefined + return enterReminder } - if (event.data.agent === event.data.previous) return - if (event.data.agent === plan) return enter + if (event.data.agent === event.data.previous) return undefined + if (event.data.agent === plan) return enterReminder if (event.data.previous === plan) return leave + return undefined } -function lastReminder(messages: ReadonlyArray) { +function lastReminder(messages: ReadonlyArray, enterReminder: string) { return messages.reduce((found, message) => { const part = message.role === "user" && message.content.length === 1 ? message.content[0] : undefined if (part?.type !== "text") return found - return part.text === enter || part.text === leave ? part.text : found + return part.text === enterReminder || part.text === leave ? part.text : found }, undefined) } diff --git a/packages/core/test/plugin/plan.test.ts b/packages/core/test/plugin/plan.test.ts index 96c734240d8..86a358fd5f0 100644 --- a/packages/core/test/plugin/plan.test.ts +++ b/packages/core/test/plugin/plan.test.ts @@ -1,22 +1,30 @@ import { describe, expect } from "bun:test" -import { Message } from "@opencode-ai/ai" -import { DateTime, Effect, Stream } from "effect" +import { Message, ToolFailure } from "@opencode-ai/ai" +import { DateTime, Effect, Stream, Types } from "effect" import type { SessionContext } from "@opencode-ai/plugin/effect/session" +import type { ToolHooks } from "@opencode-ai/plugin/effect/tool" import { Agent } from "@opencode-ai/core/agent" +import { Environment } from "@opencode-ai/core/environment/index" import { Event } from "@opencode-ai/schema/event" import { Model } from "@opencode-ai/core/model" import { PlanPlugin } from "@opencode-ai/core/plugin/plan" +import { Permission } from "@opencode-ai/core/permission" import { Provider } from "@opencode-ai/core/provider" import { Session } from "@opencode-ai/core/session" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionInbox } from "@opencode-ai/core/session/inbox" import { SessionMessage } from "@opencode-ai/core/session/message" +import { Tool } from "@opencode-ai/schema/tool" +import { Global } from "@opencode-ai/util/global" +import path from "path" import { it } from "../lib/effect" import { host } from "./host" const sessionID = Session.ID.make("ses_plan_test") const plan = Agent.ID.make("plan") const build = Agent.ID.make("build") +const home = "/home/plan-test" +const planDirectory = path.join(home, ".opencode", "plan") const agentSelected = (agent: Agent.ID, previous: Agent.ID): SessionEvent.AgentSelected => ({ id: Event.ID.create(), @@ -30,17 +38,48 @@ const agentSelected = (agent: Agent.ID, previous: Agent.ID): SessionEvent.AgentS const run = Effect.fnUntraced(function* (events: ReadonlyArray = []) { const persisted = new Array() let contextHook: ((input: SessionContext) => Effect.Effect) | undefined + let toolHook: ((input: ToolHooks["execute.after"]) => Effect.Effect) | undefined + const planAgent = { + id: plan, + name: Agent.Name.make("Plan"), + request: { settings: {}, headers: {}, body: {} }, + mode: "primary", + hidden: false, + permissions: [ + { action: "*", resource: "*", effect: "allow" }, + { action: "external_directory", resource: "*", effect: "ask" }, + ], + } satisfies Types.DeepMutable + const driver = Environment.makeMemoryDriver() yield* PlanPlugin.Plugin.effect( host({ agent: { get: () => Effect.die("unused agent.get"), list: () => Effect.die("unused agent.list"), reload: () => Effect.die("unused agent.reload"), - transform: () => Effect.succeed({ dispose: Effect.void }), + transform: (callback) => { + callback({ + list: () => [planAgent], + get: (id) => (id === plan ? planAgent : undefined), + default: () => {}, + update: (id, update) => { + if (id === plan) update(planAgent) + }, + remove: () => {}, + }) + return Effect.succeed({ dispose: Effect.void }) + }, }, tool: { transform: () => Effect.die("unused tool.transform"), - hook: () => Effect.succeed({ dispose: Effect.void }), + hook: (name, callback) => { + if (name === "execute.after") { + // Hook names and callbacks are correlated, but TypeScript does not narrow this generic registration API. + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion + toolHook = callback as unknown as (input: ToolHooks["execute.after"]) => Effect.Effect + } + return Effect.succeed({ dispose: Effect.void }) + }, }, event: { subscribe: () => Stream.fromIterable(events), @@ -65,9 +104,16 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray): SessionContext => ({ @@ -79,6 +125,19 @@ const request = (agent: Agent.ID, messages: Array): SessionContext => ( tools: {}, }) +type ToolErrorEvent = Extract + +const toolError = (tool: "edit" | "write" | "patch", error: Tool.Error): ToolErrorEvent => ({ + tool, + input: {}, + sessionID, + agent: plan, + messageID: SessionMessage.ID.make("msg_plan_tool"), + id: Tool.CallID.make("call_plan_tool"), + status: "error", + error, +}) + const settle = (persisted: ReadonlyArray, expected: number, remaining = 1000): Effect.Effect => Effect.gen(function* () { if (persisted.length >= expected) return @@ -104,6 +163,9 @@ describe("plan plugin reminders", () => { const { persisted } = yield* run([agentSelected(plan, build), agentSelected(build, plan)]) yield* settle(persisted, 2) expect(persisted[0]).toContain("You are in Plan mode") + expect(persisted[0]).toContain("optionally create or update plan documents") + expect(persisted[0]).toContain(planDirectory) + expect(persisted[0]).toContain("Do not modify any other files") expect(persisted[1]).toContain("NO LONGER in Plan mode") }), ) @@ -178,3 +240,69 @@ describe("plan plugin reminders", () => { }), ) }) + +describe("plan plugin mutations", () => { + it.effect("creates the Plan directory", () => + Effect.gen(function* () { + const { files } = yield* run() + expect((yield* files.stat(planDirectory)).type).toBe("directory") + }), + ) + + it.effect("allows edits only inside the Plan directory", () => + Effect.gen(function* () { + const { planAgent } = yield* run() + expect(Permission.evaluate("edit", path.join(planDirectory, "work.md"), planAgent.permissions).effect).toBe( + "allow", + ) + expect(Permission.evaluate("edit", "/workspace/source.ts", planAgent.permissions).effect).toBe("deny") + expect(Permission.evaluate("edit", "source.ts", planAgent.permissions).effect).toBe("deny") + }), + ) + + it.effect("allows the Plan directory external boundary", () => + Effect.gen(function* () { + const { planAgent } = yield* run() + expect( + Permission.evaluate("external_directory", path.join(planDirectory, "*"), planAgent.permissions).effect, + ).toBe("allow") + expect( + Permission.evaluate("external_directory", path.join(planDirectory, "nested", "*"), planAgent.permissions) + .effect, + ).toBe("allow") + expect(Permission.evaluate("external_directory", "/outside/*", planAgent.permissions).effect).toBe("ask") + }), + ) + + it.effect("rewrites blocked mutation failures with the Plan directory", () => + Effect.gen(function* () { + const { toolHook } = yield* run() + for (const tool of ["edit", "write", "patch"] as const) { + const event = toolError( + tool, + new ToolFailure({ + message: "Unable to modify file", + error: new Permission.BlockedError({ + rules: [], + permission: "edit", + resources: ["source.ts"], + }), + }), + ) + yield* toolHook(event) + expect(event.error.message).toContain("outside the Plan directory") + expect(event.error.message).toContain(planDirectory) + } + }), + ) + + it.effect("preserves mutation failures unrelated to permissions", () => + Effect.gen(function* () { + const { toolHook } = yield* run() + const error = new ToolFailure({ message: "oldString was not found" }) + const event = toolError("edit", error) + yield* toolHook(event) + expect(event.error).toBe(error) + }), + ) +})