Compare commits

...

6 Commits

Author SHA1 Message Date
Aiden Cline b295fb2f5f fix(core): derive previous agent in projection (#41685) 2026-08-11 01:24:30 -05:00
Aiden Cline cc792f79b7 fix(core): tighten plan switch reminder copy
Rewrite enter and leave synthetics to state the read-only constraint
and its removal without extra workflow instructions.
2026-08-11 01:12:44 -05:00
Aiden Cline 640bf46d6c feat(core): inject plan mode reminders on agent switch
Subscribe to session.agent.selected and admit a synthetic system reminder
when entering or leaving Plan, without waking the session.
2026-08-11 01:03:17 -05:00
Aiden Cline 5c6f8ae371 feat(session): include previous agent on switch events
Add optional previous to session.agent.selected so plugins can see both
agents without reading session messages.
2026-08-11 00:55:41 -05:00
Aiden Cline 1cada111a4 fix(core): tighten plan plugin copy
Use a user-facing Plan description and a per-tool read-only failure message.
2026-08-11 00:50:19 -05:00
Aiden Cline e9ee05a843 feat(core): extract plan agent into a plugin
Move Plan out of opencode.agent into opencode.plan. Keep edit/write/patch
advertised and reject those calls from execute.before when Plan is selected.
2026-08-11 00:40:13 -05:00
8 changed files with 68 additions and 12 deletions
-10
View File
@@ -101,16 +101,6 @@ export const Plugin = define({
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
})
draft.update(Agent.ID.make("plan"), (item) => {
item.name = Agent.Name.make("Plan")
item.description = "Plan mode. Disallows all edit tools."
item.mode = "primary"
item.permissions.push(
{ action: "question", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
)
})
draft.update(Agent.ID.make("general"), (item) => {
item.name = Agent.Name.make("General")
item.description =
+1
View File
@@ -349,6 +349,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
input?.location ?? Location.Ref.make({ directory: location.directory, workspaceID: location.workspaceID }),
}),
get: (input) => runtime.session.get(input.sessionID),
message: runtime.session.message,
prompt: runtime.session.prompt,
generate: (input) => runtime.session.generate(input).pipe(Effect.map((text) => ({ text }))),
command: runtime.session.command,
+2
View File
@@ -60,6 +60,7 @@ import { WellKnown } from "../wellknown"
import { WriteTool } from "../tool/plugin/write"
import { AgentPlugin } from "./agent"
import { CommandPlugin } from "./command"
import { PlanPlugin } from "./plan"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
import { WebSearchPlugins } from "./websearch"
@@ -192,6 +193,7 @@ export type InternalPlugin = Plugin<Requirements | Scope.Scope>
const pre = [
WellKnownPlugin.Plugin,
AgentPlugin.Plugin,
PlanPlugin.Plugin,
CommandPlugin.Plugin,
SkillPlugin.Plugin,
...SystemPromptPlugin.Plugins,
+56
View File
@@ -0,0 +1,56 @@
export * as PlanPlugin from "./plan"
import { ToolFailure } from "@opencode-ai/ai"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { SessionMessage } from "@opencode-ai/schema/session-message"
import { Effect, Stream } from "effect"
import { Agent } from "../agent"
const plan = Agent.ID.make("plan")
const enter = `<system-reminder>
You are in Plan mode. This is a READ-ONLY environment. You are not allowed to edit files, and you may not ask a subagent to edit them either.
</system-reminder>`
const leave = `<system-reminder>
You are no longer in Plan mode. The previous read-only restrictions no longer apply. You may edit files again.
</system-reminder>`
export const Plugin = define({
id: "opencode.plan",
effect: Effect.fn(function* (ctx) {
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" })
})
})
yield* ctx.tool.hook("execute.before", (event) => {
if (event.agent !== plan) 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.`,
})
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "session.agent.selected"),
Stream.runForEach((event) =>
Effect.gen(function* () {
const message = yield* ctx.session.message({
sessionID: event.data.sessionID,
messageID: SessionMessage.ID.fromEvent(event.id),
})
if (message?.type !== "agent-switched" || message.agent === message.previous) return
const text = message.agent === plan ? enter : message.previous === plan ? leave : undefined
if (!text) return
yield* ctx.session.synthetic({ sessionID: event.data.sessionID, text, resume: false })
}).pipe(Effect.catch(() => Effect.void)),
),
Effect.forkScoped({ startImmediately: true }),
)
}),
})
+2
View File
@@ -14,6 +14,7 @@ export interface Interface {
| "get"
| "create"
| "messages"
| "message"
| "prompt"
| "generate"
| "command"
@@ -59,6 +60,7 @@ export const layerWithCell = (cell: Cell) =>
get: (sessionID) => require(cell, (runtime) => runtime.session.get(sessionID)),
create: (input) => require(cell, (runtime) => runtime.session.create(input)),
messages: (input) => require(cell, (runtime) => runtime.session.messages(input)),
message: (input) => require(cell, (runtime) => runtime.session.message(input)),
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
generate: (input) => require(cell, (runtime) => runtime.session.generate(input)),
command: (input) => require(cell, (runtime) => runtime.session.command(input)),
-1
View File
@@ -165,7 +165,6 @@ describe("Agent", () => {
"compaction",
"explore",
"general",
"plan",
"summary",
"title",
])
+1
View File
@@ -102,6 +102,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
hook: overrides.session?.hook ?? (() => Effect.die("unused session.hook")),
create: overrides.session?.create ?? (() => Effect.die("unused session.create")),
get: overrides.session?.get ?? (() => Effect.die("unused session.get")),
message: overrides.session?.message ?? (() => Effect.die("unused session.message")),
prompt: overrides.session?.prompt ?? (() => Effect.die("unused session.prompt")),
generate: overrides.session?.generate ?? (() => Effect.die("unused session.generate")),
command: overrides.session?.command ?? (() => Effect.die("unused session.command")),
+6 -1
View File
@@ -3,7 +3,8 @@ import type { Message, SystemPart } from "@opencode-ai/ai"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
import type { JsonSchema } from "effect"
import type { SessionMessage } from "@opencode-ai/schema/session-message"
import type { Effect, JsonSchema } from "effect"
import type { Hooks } from "./registration.js"
export interface SessionContext {
@@ -40,5 +41,9 @@ export type SessionDomain = Pick<
SessionApi<unknown>,
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
> & {
readonly message: (input: {
readonly sessionID: Session.ID
readonly messageID: SessionMessage.ID
}) => Effect.Effect<SessionMessage.Info | undefined, unknown>
readonly hook: Hooks<SessionHooks>
}