Compare commits

...
Author SHA1 Message Date
Shoubhit Dash b0dac0d5b3 refactor(core): read the Plan directory from plugin options
Core config no longer knows about Plan mode. This removes the plan key, its schema, and the regenerated OpenAPI and client output. The opencode.plan plugin reads directory from its plugin options instead. It logs and falls back to ~/.opencode/plan when the options are invalid, so a typo never leaves the Plan agent without its edit restrictions.
2026-10-06 19:44:50 +05:30
Shoubhit Dash 36e25abab9 feat(core): pass plugin options to built-in plugins
Package plugins already receive the options from their plugins entry. Built-in, SDK, and instance plugins were only enabled, and their options were dropped. An exact add entry now passes its options to that plugin and folds them into its revision, so an edit restarts the plugin with the new values. Wildcards never set options, and a later plain entry for the same ID resets them.
2026-10-06 19:44:34 +05:30
Shoubhit Dash 50867e1b2d feat(core): add plan.directory config
The Plan agent wrote plans only to a hard-coded ~/.opencode/plan. The new plan.directory key sets that directory. Relative paths resolve against the current checkout root and ~/ against the home directory; the default stays ~/.opencode/plan.

The Plan plugin reads the key through ConfigEntryObserver, so config edits apply without a restart. It names its edit allow rule with FileAccess.resource, so a directory inside the project matches the Location-relative resources that file tools check.
2026-10-06 18:52:00 +05:30
Shoubhit Dash d8da92780f refactor(core): export FileAccess.resource for permission naming
FileAccess.resolve names in-project targets relative to the Location directory and everything else by absolute path. Move that rule into an exported pure function so permission rule builders can name a directory the same way resolve names the files inside it.
2026-10-06 18:50:18 +05:30
8 changed files with 234 additions and 22 deletions

No files matched your search

+14 -11
View File
@@ -76,6 +76,18 @@ export const resolvePath = (directory: string, input: string, home = Global.Path
)
}
/** Name a path the way permission rules see it: Location-relative inside the project, absolute outside it. */
export const resource = (location: Location.Info, absolute: string) =>
internal(location, absolute) ? slash(path.relative(location.directory, absolute) || ".") : slash(absolute)
const internal = (location: Location.Info, absolute: string) => {
const worktree = path.resolve(location.project.directory)
return (
FSUtil.contains(location.directory, absolute) ||
(worktree !== path.parse(worktree).root && FSUtil.contains(worktree, absolute))
)
}
const slash = (value: string) => value.replaceAll("\\", "/")
const invocation = (context: Invocation) => ({
sessionID: context.sessionID,
@@ -92,16 +104,7 @@ const layer = Layer.effect(
const resolve = Effect.fn("FileAccess.resolve")(function* (input: ResolveInput) {
const absolute = AbsolutePath.make(resolvePath(location.directory, input.path))
const worktree = path.resolve(location.project.directory)
const internal =
FSUtil.contains(location.directory, absolute) ||
(worktree !== path.parse(worktree).root && FSUtil.contains(worktree, absolute))
if (internal) {
return {
absolute,
resource: slash(path.relative(location.directory, absolute) || "."),
} satisfies Target
}
if (internal(location, absolute)) return { absolute, resource: resource(location, absolute) } satisfies Target
const type =
input.kind === "directory"
? "Directory"
@@ -112,7 +115,7 @@ const layer = Layer.effect(
const directory = AbsolutePath.make(type === "Directory" ? absolute : path.dirname(absolute))
return {
absolute,
resource: slash(absolute),
resource: resource(location, absolute),
externalDirectory: {
action: "external_directory",
directory,
+19 -3
View File
@@ -5,12 +5,17 @@ import { define } from "@opencode/plugin/effect/plugin"
import { Agent } from "@opencode/schema/agent"
import type { SessionEvent } from "@opencode/schema/session-event"
import { Global } from "@opencode/util/global"
import { Effect, Stream } from "effect"
import { Effect, Option, Schema, Stream } from "effect"
import path from "path"
import { FileAccess } from "../file-access.js"
import { Permission } from "../permission.js"
const plan = Agent.ID.make("plan")
const Options = Schema.Struct({
directory: Schema.optional(Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()))),
})
const enter = (directory: string) => `<system-reminder>
You are in Plan mode. Discuss the plan with the user directly in the conversation. Do not create or update plan files unless the user explicitly asks you to; when they do, write them only in:
${directory}
@@ -28,7 +33,14 @@ export const Plugin = define({
id: "opencode.plan",
effect: Effect.fn(function* (ctx) {
const global = yield* Global.Service
const directory = path.join(global.home, ".opencode", "plan")
const options = Schema.decodeUnknownOption(Options)(ctx.options)
if (Option.isNone(options))
yield* Effect.logWarning("ignoring invalid Plan plugin options", { options: ctx.options })
const directory = FileAccess.resolvePath(
ctx.location.project.directory,
Option.getOrUndefined(options)?.directory ?? "~/.opencode/plan",
global.home,
)
const enterReminder = enter(directory)
yield* ctx.agent.transform((editor) => {
editor.update(plan, (item) => {
@@ -37,7 +49,11 @@ export const Plugin = define({
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: "edit",
resource: path.join(FileAccess.resource(ctx.location, directory), "*"),
effect: "allow",
})
item.permissions.push({ action: "external_directory", resource: path.join(directory, "*"), effect: "allow" })
})
})
+13 -2
View File
@@ -28,6 +28,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
const definitions = [...pre, ...post]
const enabled = new Set(definitions.map((plugin) => plugin.id))
const packages = new Map<string, Plugin.Generation>()
const options = new Map<string, Record<string, unknown>>()
const pending = new Set<string>()
const failures = new Map<
string,
@@ -52,6 +53,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
operation.target.startsWith("opencode.")
if (selectsPlugins) {
matched.forEach((plugin) => enabled.add(plugin.id))
if (definitions.some((plugin) => plugin.id === operation.target)) options.set(operation.target, operation.options)
continue
}
@@ -88,10 +90,19 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
enabled.add(plugin.id)
}
const withOptions = (plugin: Plugin.Generation): Plugin.Generation => {
const selected = options.get(plugin.id)
if (!selected || Object.keys(selected).length === 0) return plugin
return {
...plugin,
revision: JSON.stringify([plugin.revision, selected]),
effect: (host) => plugin.effect({ ...host, options: selected }),
}
}
const ordered = [
...pre.filter((plugin) => enabled.has(plugin.id)),
...pre.filter((plugin) => enabled.has(plugin.id)).map(withOptions),
...[...packages.values()].filter((plugin) => enabled.has(plugin.id)),
...post.filter((plugin) => enabled.has(plugin.id)),
...post.filter((plugin) => enabled.has(plugin.id)).map(withOptions),
]
// Registry activation dies on a duplicate ID, which would drop the whole generation including builtins.
// Keep the first occurrence in boot order and report later ones like any other plugin setup failure.
+94 -4
View File
@@ -5,11 +5,14 @@ import type { SessionContext } from "@opencode/plugin/effect/session"
import type { ToolHooks } from "@opencode/plugin/effect/tool"
import { Agent } from "@opencode/core/agent"
import { Environment } from "@opencode/core/environment/index"
import { Location } from "@opencode/core/location"
import { Event } from "@opencode/schema/event"
import { Model } from "@opencode/core/model"
import { PlanPlugin } from "@opencode/core/plugin/plan"
import { Permission } from "@opencode/core/permission"
import { Project } from "@opencode/core/project"
import { Provider } from "@opencode/core/provider"
import { AbsolutePath } from "@opencode/core/schema"
import { Session } from "@opencode/core/session"
import { SessionEvent } from "@opencode/core/session/event"
import { SessionInbox } from "@opencode/core/session/inbox"
@@ -35,7 +38,10 @@ const agentSelected = (agent: Agent.ID, previous: Agent.ID): SessionEvent.AgentS
})
/** Runs the plan plugin against stubbed domains, capturing persisted reminders and the context hook. */
const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.AgentSelected> = []) {
const run = Effect.fnUntraced(function* (
events: ReadonlyArray<SessionEvent.AgentSelected> = [],
input: { options?: Record<string, unknown>; location?: Location.Info } = {},
) {
const persisted = new Array<string>()
let contextHook: ((input: SessionContext) => Effect.Effect<void>) | undefined
let toolHook: ((input: ToolHooks["execute.after"]) => Effect.Effect<void>) | undefined
@@ -51,8 +57,9 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
],
} satisfies Types.DeepMutable<Agent.Info>
const driver = Environment.makeMemoryDriver()
yield* PlanPlugin.Plugin.effect(
host({
yield* PlanPlugin.Plugin.effect({
...host({
location: input.location,
agent: {
get: () => Effect.die("unused agent.get"),
list: () => Effect.die("unused agent.list"),
@@ -106,7 +113,8 @@ const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.Agen
},
},
}),
).pipe(
options: input.options ?? {},
}).pipe(
Effect.provideService(Global.Service, Global.Service.of({ ...Global.make(), home })),
Effect.provideService(
Environment.Service,
@@ -309,3 +317,85 @@ describe("plan plugin mutations", () => {
}),
)
})
describe("plan plugin directory", () => {
it.effect("uses an absolute directory from plugin options", () =>
Effect.gen(function* () {
const { planAgent, contextHook, toolHook } = yield* run([], { options: { directory: "/plans" } })
const messages = [Message.user("where do plans go?")]
yield* contextHook(request(plan, messages))
const reminder = messages[0]?.content[0]
expect(reminder?.type === "text" && reminder.text).toContain("/plans")
expect(Permission.evaluate("edit", "/plans/work.md", planAgent.permissions).effect).toBe("allow")
expect(Permission.evaluate("edit", "/home/plan-test/.opencode/plan/work.md", planAgent.permissions).effect).toBe(
"deny",
)
const event = toolError(
"edit",
new ToolFailure({
message: "Unable to modify file",
error: new Permission.BlockedError({ rules: [], permission: "edit", resources: ["source.ts"] }),
}),
)
yield* toolHook(event)
expect(event.error.message).toBe("Cannot use edit to modify files outside the Plan directory: /plans")
}),
)
it.effect("expands a home-relative directory from plugin options", () =>
Effect.gen(function* () {
const { planAgent, contextHook } = yield* run([], { options: { directory: "~/plans" } })
const messages = [Message.user("where do plans go?")]
yield* contextHook(request(plan, messages))
const reminder = messages[0]?.content[0]
expect(reminder?.type === "text" && reminder.text).toContain("/home/plan-test/plans")
expect(Permission.evaluate("edit", "/home/plan-test/plans/work.md", planAgent.permissions).effect).toBe("allow")
}),
)
it.effect("resolves a relative directory against the project root", () =>
Effect.gen(function* () {
const { planAgent, contextHook } = yield* run([], {
options: { directory: ".opencode/plans" },
location: new Location.Info({
directory: AbsolutePath.make("/workspace/packages/app"),
project: {
id: Project.ID.global,
directory: AbsolutePath.make("/workspace"),
canonical: AbsolutePath.make("/workspace/canonical"),
},
}),
})
const messages = [Message.user("where do plans go?")]
yield* contextHook(request(plan, messages))
const reminder = messages[0]?.content[0]
expect(reminder?.type === "text" && reminder.text).toContain("/workspace/.opencode/plans")
expect(Permission.evaluate("edit", "../../.opencode/plans/work.md", planAgent.permissions).effect).toBe("allow")
expect(Permission.evaluate("edit", "src/index.ts", planAgent.permissions).effect).toBe("deny")
}),
)
it.effect("allows the default directory when the location is the home directory", () =>
Effect.gen(function* () {
const { planAgent } = yield* run([], {
location: new Location.Info({
directory: AbsolutePath.make(home),
project: { id: Project.ID.global, directory: AbsolutePath.make(home), canonical: AbsolutePath.make(home) },
}),
})
expect(Permission.evaluate("edit", ".opencode/plan/work.md", planAgent.permissions).effect).toBe("allow")
expect(Permission.evaluate("edit", "notes.md", planAgent.permissions).effect).toBe("deny")
}),
)
it.effect("falls back to the default directory when options are invalid", () =>
Effect.gen(function* () {
for (const options of [{ directory: 42 }, { directory: " " }]) {
const { planAgent } = yield* run([], { options })
expect(Permission.evaluate("edit", "/home/plan-test/.opencode/plan/work.md", planAgent.permissions).effect).toBe(
"allow",
)
}
}),
)
})
@@ -258,4 +258,71 @@ describe("PluginSupervisor", () => {
expect(source.activations).toBe(2)
}),
)
it.effect("passes exact add options to a non-package plugin and restarts it when they change", () =>
Effect.gen(function* () {
const seen = new Array<Record<string, unknown>>()
const sdk = yield* SdkPlugins.Service
yield* sdk.register(
define({
id: "options-probe",
effect: (ctx) => Effect.sync(() => seen.push(ctx.options)),
}),
)
source.activations = 0
source.operations = [{ type: "add", target: "options-probe", options: { directory: "/plans" } }]
const directory = yield* tmpdirScoped()
const locations = yield* LocationServiceMap.Service
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.awaitActivation
expect(seen).toEqual([{ directory: "/plans" }])
source.operations = [{ type: "add", target: "options-probe", options: { directory: "/srv/plans" } }]
yield* sdk.register(define({ id: "options-reload", effect: () => Effect.void }))
yield* advance(() => source.activations === 2)
yield* plugins.awaitActivation
expect(seen).toEqual([{ directory: "/plans" }, { directory: "/srv/plans" }])
}).pipe(
Effect.scoped,
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
)
}).pipe(
Effect.ensuring(
Effect.sync(() => {
source.operations = []
}),
),
),
)
it.effect("ignores options from a wildcard selector", () =>
Effect.gen(function* () {
const seen = new Array<Record<string, unknown>>()
const sdk = yield* SdkPlugins.Service
yield* sdk.register(
define({
id: "options-probe",
effect: (ctx) => Effect.sync(() => seen.push(ctx.options)),
}),
)
source.operations = [{ type: "add", target: "*", options: { directory: "/plans" } }]
const directory = yield* tmpdirScoped()
const locations = yield* LocationServiceMap.Service
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.awaitActivation
expect(seen).toEqual([{}])
}).pipe(
Effect.scoped,
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
)
}).pipe(
Effect.ensuring(
Effect.sync(() => {
source.operations = []
}),
),
),
)
})
+17 -1
View File
@@ -135,7 +135,7 @@ OpenCode includes these visible agents:
| Agent | Mode | Purpose |
| --- | --- | --- |
| **Build** (`build`) | `primary` | Default coding agent. Tools are allowed by default; sensitive environment-file reads and access outside the workspace ask for approval. |
| **Plan** (`plan`) | `primary` | Explores and plans without editing normal project files. It may write OpenCode plan files when asked, and shell commands remain permission-controlled. |
| **Plan** (`plan`) | `primary` | Explores and plans without editing normal project files. It may write plan files to the [plan directory](/agents#plan-directory) when asked, and shell commands remain permission-controlled. |
| **General** (`general`) | `subagent` | Handles research and multi-step work with broad tool access, but cannot launch more subagents. |
| **Explore** (`explore`) | `subagent` | Searches and reads code or web sources without editing files. |
@@ -155,6 +155,22 @@ Override a built-in by using the same ID:
Hidden `compaction`, `title`, and `summary` agents perform maintenance and cannot be selected directly. V2 has no built-in `scout` agent.
### Plan directory
The Plan agent writes plan files to `~/.opencode/plan` and cannot edit other files. Set the `directory` option of the built-in `opencode.plan` plugin to use another directory.
```jsonc
{
"plugins": [{ "package": "opencode.plan", "options": { "directory": ".opencode/plans" } }],
}
```
- Relative paths resolve against the root of the current checkout, in global and project configuration alike. Each linked worktree gets its own plan directory.
- Outside version control, relative paths resolve against the opened directory.
- Absolute paths are used as-is, and `~/` resolves against your home directory.
Changing the directory does not move existing plan files.
## Merging
Agent definitions merge in configuration order. Later scalar values replace earlier values, request maps merge by key, and permission rules append:
@@ -213,7 +213,7 @@ Shipped agents append these policies:
| Agent | Additional policy |
| ------------ | ------------------------------------------------------------------------------ |
| `build` | Allows questions |
| `plan` | Allows questions; denies edits except files under `~/.opencode/plan` |
| `plan` | Allows questions; denies edits except files in the [plan directory](/agents#plan-directory) (default `~/.opencode/plan`) |
| `general` | Denies questions and launching subagents |
| `explore` | Denies everything except reads, globs, grep, web fetches, and web searches; asks for external directories and `.env` reads |
| `title` | Denies all actions |
@@ -81,6 +81,15 @@ Two built-in plugins ignore removals so that a repository cannot switch off
`opencode.provider.opencode`, the Console connection that delivers organization
policy.
To pass options to a built-in plugin, use the object form with the plugin ID as `package`. A later entry for the same
ID replaces its options. Wildcards enable plugins but never set options.
```jsonc title="opencode.jsonc"
{
"plugins": [{ "package": "opencode.plan", "options": { "directory": ".opencode/plans" } }]
}
```
## Manage
Install, list, check, update, or remove global package plugins with the CLI.