summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorDax <[email protected]>2026-01-13 15:55:48 -0500
committerGitHub <[email protected]>2026-01-13 15:55:48 -0500
commit0a3c72d6787aa3cf39b9517e32f0ad5d8dbb6184 (patch)
tree3f000f847c8e72a875682cf715a8778080b85d4c /packages
parent66b7a4991ee5903d0239c0d7b98c95b9c5f9e43c (diff)
downloadopencode-0a3c72d6787aa3cf39b9517e32f0ad5d8dbb6184.tar.gz
opencode-0a3c72d6787aa3cf39b9517e32f0ad5d8dbb6184.zip
feat: add plan mode with enter/exit tools (#8281)
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/src/agent/agent.ts6
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/index.tsx17
-rw-r--r--packages/opencode/src/cli/cmd/tui/routes/session/question.tsx61
-rw-r--r--packages/opencode/src/flag/flag.ts1
-rw-r--r--packages/opencode/src/question/index.ts1
-rw-r--r--packages/opencode/src/session/index.ts9
-rw-r--r--packages/opencode/src/session/prompt.ts143
-rw-r--r--packages/opencode/src/session/prompt/build-switch.txt2
-rw-r--r--packages/opencode/src/tool/plan-enter.txt14
-rw-r--r--packages/opencode/src/tool/plan-exit.txt13
-rw-r--r--packages/opencode/src/tool/plan.ts130
-rw-r--r--packages/opencode/src/tool/registry.ts2
-rw-r--r--packages/opencode/test/util/lock.test.ts72
-rw-r--r--packages/sdk/js/src/v2/gen/types.gen.ts5
-rw-r--r--packages/util/src/slug.ts74
15 files changed, 504 insertions, 46 deletions
diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts
index ea9d3e3ba..6847d29ab 100644
--- a/packages/opencode/src/agent/agent.ts
+++ b/packages/opencode/src/agent/agent.ts
@@ -53,6 +53,8 @@ export namespace Agent {
[Truncate.GLOB]: "allow",
},
question: "deny",
+ plan_enter: "deny",
+ plan_exit: "deny",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",
@@ -71,6 +73,7 @@ export namespace Agent {
defaults,
PermissionNext.fromConfig({
question: "allow",
+ plan_enter: "allow",
}),
user,
),
@@ -84,9 +87,10 @@ export namespace Agent {
defaults,
PermissionNext.fromConfig({
question: "allow",
+ plan_exit: "allow",
edit: {
"*": "deny",
- ".opencode/plan/*.md": "allow",
+ ".opencode/plans/*.md": "allow",
},
}),
user,
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index f87b811ae..b6916bc5a 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -196,6 +196,23 @@ export function Session() {
}
})
+ let lastSwitch: string | undefined = undefined
+ sdk.event.on("message.part.updated", (evt) => {
+ const part = evt.properties.part
+ if (part.type !== "tool") return
+ if (part.sessionID !== route.sessionID) return
+ if (part.state.status !== "completed") return
+ if (part.id === lastSwitch) return
+
+ if (part.tool === "plan_exit") {
+ local.agent.set("build")
+ lastSwitch = part.id
+ } else if (part.tool === "plan_enter") {
+ local.agent.set("plan")
+ lastSwitch = part.id
+ }
+ })
+
let scroll: ScrollBoxRenderable
let prompt: PromptRef
const keybind = useKeybind()
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx
index ccc0e9b12..5e8ce2380 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx
@@ -32,7 +32,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
const question = createMemo(() => questions()[store.tab])
const confirm = createMemo(() => !single() && store.tab === questions().length)
const options = createMemo(() => question()?.options ?? [])
- const other = createMemo(() => store.selected === options().length)
+ const custom = createMemo(() => question()?.custom !== false)
+ const other = createMemo(() => custom() && store.selected === options().length)
const input = createMemo(() => store.custom[store.tab] ?? "")
const multi = createMemo(() => question()?.multiple === true)
const customPicked = createMemo(() => {
@@ -203,7 +204,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
}
} else {
const opts = options()
- const total = opts.length + 1 // options + "Other"
+ const total = opts.length + (custom() ? 1 : 0)
if (evt.name === "up" || evt.name === "k") {
evt.preventDefault()
@@ -298,35 +299,37 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
)
}}
</For>
- <box onMouseOver={() => moveTo(options().length)} onMouseUp={() => selectOption()}>
- <box flexDirection="row" gap={1}>
- <box backgroundColor={other() ? theme.backgroundElement : undefined}>
- <text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
- {options().length + 1}. Type your own answer
- </text>
+ <Show when={custom()}>
+ <box onMouseOver={() => moveTo(options().length)} onMouseUp={() => selectOption()}>
+ <box flexDirection="row" gap={1}>
+ <box backgroundColor={other() ? theme.backgroundElement : undefined}>
+ <text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
+ {options().length + 1}. Type your own answer
+ </text>
+ </box>
+ <text fg={theme.success}>{customPicked() ? "✓" : ""}</text>
</box>
- <text fg={theme.success}>{customPicked() ? "✓" : ""}</text>
+ <Show when={store.editing}>
+ <box paddingLeft={3}>
+ <textarea
+ ref={(val: TextareaRenderable) => (textarea = val)}
+ focused
+ initialValue={input()}
+ placeholder="Type your own answer"
+ textColor={theme.text}
+ focusedTextColor={theme.text}
+ cursorColor={theme.primary}
+ keyBindings={bindings()}
+ />
+ </box>
+ </Show>
+ <Show when={!store.editing && input()}>
+ <box paddingLeft={3}>
+ <text fg={theme.textMuted}>{input()}</text>
+ </box>
+ </Show>
</box>
- <Show when={store.editing}>
- <box paddingLeft={3}>
- <textarea
- ref={(val: TextareaRenderable) => (textarea = val)}
- focused
- initialValue={input()}
- placeholder="Type your own answer"
- textColor={theme.text}
- focusedTextColor={theme.text}
- cursorColor={theme.primary}
- keyBindings={bindings()}
- />
- </box>
- </Show>
- <Show when={!store.editing && input()}>
- <box paddingLeft={3}>
- <text fg={theme.textMuted}>{input()}</text>
- </box>
- </Show>
- </box>
+ </Show>
</box>
</box>
</Show>
diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts
index ad6052dec..4cdb54909 100644
--- a/packages/opencode/src/flag/flag.ts
+++ b/packages/opencode/src/flag/flag.ts
@@ -38,6 +38,7 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_OXFMT = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT")
export const OPENCODE_EXPERIMENTAL_LSP_TY = truthy("OPENCODE_EXPERIMENTAL_LSP_TY")
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
+ export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts
index 23656b53a..d18098a9c 100644
--- a/packages/opencode/src/question/index.ts
+++ b/packages/opencode/src/question/index.ts
@@ -24,6 +24,7 @@ export namespace Question {
header: z.string().max(12).describe("Very short label (max 12 chars)"),
options: z.array(Option).describe("Available choices"),
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
+ custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"),
})
.meta({
ref: "QuestionInfo",
diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts
index a204913f7..2bb9fc5f8 100644
--- a/packages/opencode/src/session/index.ts
+++ b/packages/opencode/src/session/index.ts
@@ -1,3 +1,5 @@
+import { Slug } from "@opencode-ai/util/slug"
+import pat from "path"
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Decimal } from "decimal.js"
@@ -19,6 +21,7 @@ import { Snapshot } from "@/snapshot"
import type { Provider } from "@/provider/provider"
import { PermissionNext } from "@/permission/next"
+import path from "path"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -39,6 +42,7 @@ export namespace Session {
export const Info = z
.object({
id: Identifier.schema("session"),
+ slug: z.string(),
projectID: z.string(),
directory: z.string(),
parentID: Identifier.schema("session").optional(),
@@ -194,6 +198,7 @@ export namespace Session {
}) {
const result: Info = {
id: Identifier.descending("session", input.id),
+ slug: Slug.create(),
version: Installation.VERSION,
projectID: Instance.project.id,
directory: input.directory,
@@ -227,6 +232,10 @@ export namespace Session {
return result
}
+ export function plan(input: { slug: string; time: { created: number } }) {
+ return path.join(Instance.worktree, ".opencode", "plans", [input.time.created, input.slug].join("-") + ".md")
+ }
+
export const get = fn(Identifier.schema("session"), async (id) => {
const read = await Storage.read<Info>(["session", Instance.project.id, id])
return read as Info
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 34596e629..5036d3a69 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -510,9 +510,10 @@ export namespace SessionPrompt {
const agent = await Agent.get(lastUser.agent)
const maxSteps = agent.steps ?? Infinity
const isLastStep = step >= maxSteps
- msgs = insertReminders({
+ msgs = await insertReminders({
messages: msgs,
agent,
+ session,
})
const processor = SessionProcessor.create({
@@ -1185,30 +1186,140 @@ export namespace SessionPrompt {
}
}
- function insertReminders(input: { messages: MessageV2.WithParts[]; agent: Agent.Info }) {
+ async function insertReminders(input: { messages: MessageV2.WithParts[]; agent: Agent.Info; session: Session.Info }) {
const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
if (!userMessage) return input.messages
- if (input.agent.name === "plan") {
- userMessage.parts.push({
- id: Identifier.ascending("part"),
- messageID: userMessage.info.id,
- sessionID: userMessage.info.sessionID,
- type: "text",
- // TODO (for mr dax): update to use the anthropic full fledged one (see plan-reminder-anthropic.txt)
- text: PROMPT_PLAN,
- synthetic: true,
- })
+
+ // Original logic when experimental plan mode is disabled
+ if (!Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE) {
+ if (input.agent.name === "plan") {
+ userMessage.parts.push({
+ id: Identifier.ascending("part"),
+ messageID: userMessage.info.id,
+ sessionID: userMessage.info.sessionID,
+ type: "text",
+ text: PROMPT_PLAN,
+ synthetic: true,
+ })
+ }
+ const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
+ if (wasPlan && input.agent.name === "build") {
+ userMessage.parts.push({
+ id: Identifier.ascending("part"),
+ messageID: userMessage.info.id,
+ sessionID: userMessage.info.sessionID,
+ type: "text",
+ text: BUILD_SWITCH,
+ synthetic: true,
+ })
+ }
+ return input.messages
}
- const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
- if (wasPlan && input.agent.name === "build") {
- userMessage.parts.push({
+
+ // New plan mode logic when flag is enabled
+ const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant")
+
+ // Switching from plan mode to build mode
+ if (input.agent.name !== "plan" && assistantMessage?.info.agent === "plan") {
+ const plan = Session.plan(input.session)
+ const exists = await Bun.file(plan).exists()
+ if (exists) {
+ const part = await Session.updatePart({
+ id: Identifier.ascending("part"),
+ messageID: userMessage.info.id,
+ sessionID: userMessage.info.sessionID,
+ type: "text",
+ text: BUILD_SWITCH.replace("{{plan}}", plan),
+ synthetic: true,
+ })
+ userMessage.parts.push(part)
+ }
+ }
+
+ // Entering plan mode
+ if (input.agent.name === "plan" && assistantMessage?.info.agent !== "plan") {
+ const plan = Session.plan(input.session)
+ const exists = await Bun.file(plan).exists()
+ if (!exists) await fs.mkdir(path.dirname(plan), { recursive: true })
+ const part = await Session.updatePart({
id: Identifier.ascending("part"),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
- text: BUILD_SWITCH,
+ text: `<system-reminder>
+Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits (with the exception of the plan file mentioned below), run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received.
+
+## Plan File Info:
+${exists ? `A plan file already exists at ${plan}. You can read it and make incremental edits using the edit tool.` : `No plan file exists yet. You should create your plan at ${plan} using the write tool.`}
+You should build your plan incrementally by writing to or editing this file. NOTE that this is the only file you are allowed to edit - other than this you are only allowed to take READ-ONLY actions.
+
+## Plan Workflow
+
+### Phase 1: Initial Understanding
+Goal: Gain a comprehensive understanding of the user's request by reading through code and asking them questions. Critical: In this phase you should only use the explore subagent type.
+
+1. Focus on understanding the user's request and the code associated with their request
+
+2. **Launch up to 3 explore agents IN PARALLEL** (single message, multiple tool calls) to efficiently explore the codebase.
+ - Use 1 agent when the task is isolated to known files, the user provided specific file paths, or you're making a small targeted change.
+ - Use multiple agents when: the scope is uncertain, multiple areas of the codebase are involved, or you need to understand existing patterns before planning.
+ - Quality over quantity - 3 agents maximum, but you should try to use the minimum number of agents necessary (usually just 1)
+ - If using multiple agents: Provide each agent with a specific search focus or area to explore. Example: One agent searches for existing implementations, another explores related components, a third investigates testing patterns
+
+3. After exploring the code, use the question tool to clarify ambiguities in the user request up front.
+
+### Phase 2: Design
+Goal: Design an implementation approach.
+
+Launch general agent(s) to design the implementation based on the user's intent and your exploration results from Phase 1.
+
+You can launch up to 1 agent(s) in parallel.
+
+**Guidelines:**
+- **Default**: Launch at least 1 Plan agent for most tasks - it helps validate your understanding and consider alternatives
+- **Skip agents**: Only for truly trivial tasks (typo fixes, single-line changes, simple renames)
+
+Examples of when to use multiple agents:
+- The task touches multiple parts of the codebase
+- It's a large refactor or architectural change
+- There are many edge cases to consider
+- You'd benefit from exploring different approaches
+
+Example perspectives by task type:
+- New feature: simplicity vs performance vs maintainability
+- Bug fix: root cause vs workaround vs prevention
+- Refactoring: minimal change vs clean architecture
+
+In the agent prompt:
+- Provide comprehensive background context from Phase 1 exploration including filenames and code path traces
+- Describe requirements and constraints
+- Request a detailed implementation plan
+
+### Phase 3: Review
+Goal: Review the plan(s) from Phase 2 and ensure alignment with the user's intentions.
+1. Read the critical files identified by agents to deepen your understanding
+2. Ensure that the plans align with the user's original request
+3. Use question tool to clarify any remaining questions with the user
+
+### Phase 4: Final Plan
+Goal: Write your final plan to the plan file (the only file you can edit).
+- Include only your recommended approach, not all alternatives
+- Ensure that the plan file is concise enough to scan quickly, but detailed enough to execute effectively
+- Include the paths of critical files to be modified
+- Include a verification section describing how to test the changes end-to-end (run the code, use MCP tools, run tests)
+
+### Phase 5: Call plan_exit tool
+At the very end of your turn, once you have asked the user questions and are happy with your final plan file - you should always call plan_exit to indicate to the user that you are done planning.
+This is critical - your turn should only end with either asking the user a question or calling plan_exit. Do not stop unless it's for these 2 reasons.
+
+**Important:** Use question tool to clarify requirements/approach, use plan_exit to request plan approval. Do NOT use question tool to ask "Is this plan okay?" - that's what plan_exit does.
+
+NOTE: At any point in time through this workflow you should feel free to ask the user questions or clarifications. Don't make large assumptions about user intent. The goal is to present a well researched plan to the user, and tie any loose ends before implementation begins.
+</system-reminder>`,
synthetic: true,
})
+ userMessage.parts.push(part)
+ return input.messages
}
return input.messages
}
diff --git a/packages/opencode/src/session/prompt/build-switch.txt b/packages/opencode/src/session/prompt/build-switch.txt
index 3737b74d8..40b39b95b 100644
--- a/packages/opencode/src/session/prompt/build-switch.txt
+++ b/packages/opencode/src/session/prompt/build-switch.txt
@@ -2,4 +2,6 @@
Your operational mode has changed from plan to build.
You are no longer in read-only mode.
You are permitted to make file changes, run shell commands, and utilize your arsenal of tools as needed.
+
+A plan file exists at {{plan}}. You should read this file and execute on the plan defined within it.
</system-reminder>
diff --git a/packages/opencode/src/tool/plan-enter.txt b/packages/opencode/src/tool/plan-enter.txt
new file mode 100644
index 000000000..2e6a69f1f
--- /dev/null
+++ b/packages/opencode/src/tool/plan-enter.txt
@@ -0,0 +1,14 @@
+Use this tool to suggest switching to plan agent when the user's request would benefit from planning before implementation.
+
+If they explicitly mention wanting to create a plan ALWAYS call this tool first.
+
+This tool will ask the user if they want to switch to plan agent.
+
+Call this tool when:
+- The user's request is complex and would benefit from planning first
+- You want to research and design before making changes
+- The task involves multiple files or significant architectural decisions
+
+Do NOT call this tool:
+- For simple, straightforward tasks
+- When the user explicitly wants immediate implementation
diff --git a/packages/opencode/src/tool/plan-exit.txt b/packages/opencode/src/tool/plan-exit.txt
new file mode 100644
index 000000000..988821de3
--- /dev/null
+++ b/packages/opencode/src/tool/plan-exit.txt
@@ -0,0 +1,13 @@
+Use this tool when you have completed the planning phase and are ready to exit plan agent.
+
+This tool will ask the user if they want to switch to build agent to start implementing the plan.
+
+Call this tool:
+- After you have written a complete plan to the plan file
+- After you have clarified any questions with the user
+- When you are confident the plan is ready for implementation
+
+Do NOT call this tool:
+- Before you have created or finalized the plan
+- If you still have unanswered questions about the implementation
+- If the user has indicated they want to continue planning
diff --git a/packages/opencode/src/tool/plan.ts b/packages/opencode/src/tool/plan.ts
new file mode 100644
index 000000000..6cb7a691c
--- /dev/null
+++ b/packages/opencode/src/tool/plan.ts
@@ -0,0 +1,130 @@
+import z from "zod"
+import path from "path"
+import { Tool } from "./tool"
+import { Question } from "../question"
+import { Session } from "../session"
+import { MessageV2 } from "../session/message-v2"
+import { Identifier } from "../id/id"
+import { Provider } from "../provider/provider"
+import { Instance } from "../project/instance"
+import EXIT_DESCRIPTION from "./plan-exit.txt"
+import ENTER_DESCRIPTION from "./plan-enter.txt"
+
+async function getLastModel(sessionID: string) {
+ for await (const item of MessageV2.stream(sessionID)) {
+ if (item.info.role === "user" && item.info.model) return item.info.model
+ }
+ return Provider.defaultModel()
+}
+
+export const PlanExitTool = Tool.define("plan_exit", {
+ description: EXIT_DESCRIPTION,
+ parameters: z.object({}),
+ async execute(_params, ctx) {
+ const session = await Session.get(ctx.sessionID)
+ const plan = path.relative(Instance.worktree, Session.plan(session))
+ const answers = await Question.ask({
+ sessionID: ctx.sessionID,
+ questions: [
+ {
+ question: `Plan at ${plan} is complete. Would you like to switch to the build agent and start implementing?`,
+ header: "Build Agent",
+ custom: false,
+ options: [
+ { label: "Yes", description: "Switch to build agent and start implementing the plan" },
+ { label: "No", description: "Stay with plan agent to continue refining the plan" },
+ ],
+ },
+ ],
+ tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
+ })
+
+ const answer = answers[0]?.[0]
+ if (answer === "No") throw new Question.RejectedError()
+
+ const model = await getLastModel(ctx.sessionID)
+
+ const userMsg: MessageV2.User = {
+ id: Identifier.ascending("message"),
+ sessionID: ctx.sessionID,
+ role: "user",
+ time: {
+ created: Date.now(),
+ },
+ agent: "build",
+ model,
+ }
+ await Session.updateMessage(userMsg)
+ await Session.updatePart({
+ id: Identifier.ascending("part"),
+ messageID: userMsg.id,
+ sessionID: ctx.sessionID,
+ type: "text",
+ text: `The plan at ${plan} has been approved, you can now edit files. Execute the plan`,
+ synthetic: true,
+ } satisfies MessageV2.TextPart)
+
+ return {
+ title: "Switching to build agent",
+ output: "User approved switching to build agent. Wait for further instructions.",
+ metadata: {},
+ }
+ },
+})
+
+export const PlanEnterTool = Tool.define("plan_enter", {
+ description: ENTER_DESCRIPTION,
+ parameters: z.object({}),
+ async execute(_params, ctx) {
+ const session = await Session.get(ctx.sessionID)
+ const plan = path.relative(Instance.worktree, Session.plan(session))
+
+ const answers = await Question.ask({
+ sessionID: ctx.sessionID,
+ questions: [
+ {
+ question: `Would you like to switch to the plan agent and create a plan saved to ${plan}?`,
+ header: "Plan Mode",
+ custom: false,
+ options: [
+ { label: "Yes", description: "Switch to plan agent for research and planning" },
+ { label: "No", description: "Stay with build agent to continue making changes" },
+ ],
+ },
+ ],
+ tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
+ })
+
+ const answer = answers[0]?.[0]
+
+ if (answer === "No") throw new Question.RejectedError()
+
+ const model = await getLastModel(ctx.sessionID)
+
+ const userMsg: MessageV2.User = {
+ id: Identifier.ascending("message"),
+ sessionID: ctx.sessionID,
+ role: "user",
+ time: {
+ created: Date.now(),
+ },
+ agent: "plan",
+ model,
+ }
+ await Session.updateMessage(userMsg)
+ await Session.updatePart({
+ id: Identifier.ascending("part"),
+ messageID: userMsg.id,
+ sessionID: ctx.sessionID,
+ type: "text",
+ text: "User has requested to enter plan mode. Switch to plan mode and begin planning.",
+ synthetic: true,
+ } satisfies MessageV2.TextPart)
+
+ return {
+ title: "Switching to plan agent",
+ output: `User confirmed to switch to plan mode. A new message has been created to switch you to plan mode. The plan file will be at ${plan}. Begin planning.`,
+ metadata: {},
+ }
+ },
+})
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index 24faed7f0..35e378f08 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -25,6 +25,7 @@ import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { LspTool } from "./lsp"
import { Truncate } from "./truncation"
+import { PlanExitTool, PlanEnterTool } from "./plan"
export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
@@ -109,6 +110,7 @@ export namespace ToolRegistry {
SkillTool,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
+ ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []),
...custom,
]
}
diff --git a/packages/opencode/test/util/lock.test.ts b/packages/opencode/test/util/lock.test.ts
new file mode 100644
index 000000000..b877311e3
--- /dev/null
+++ b/packages/opencode/test/util/lock.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, test } from "bun:test"
+import { Lock } from "../../src/util/lock"
+
+function tick() {
+ return new Promise<void>((r) => queueMicrotask(r))
+}
+
+async function flush(n = 5) {
+ for (let i = 0; i < n; i++) await tick()
+}
+
+describe("util.lock", () => {
+ test("writer exclusivity: blocks reads and other writes while held", async () => {
+ const key = "lock:" + Math.random().toString(36).slice(2)
+
+ const state = {
+ writer2: false,
+ reader: false,
+ writers: 0,
+ }
+
+ // Acquire writer1
+ using writer1 = await Lock.write(key)
+ state.writers++
+ expect(state.writers).toBe(1)
+
+ // Start writer2 candidate (should block)
+ const writer2Task = (async () => {
+ const w = await Lock.write(key)
+ state.writers++
+ expect(state.writers).toBe(1)
+ state.writer2 = true
+ // Hold for a tick so reader cannot slip in
+ await tick()
+ return w
+ })()
+
+ // Start reader candidate (should block)
+ const readerTask = (async () => {
+ const r = await Lock.read(key)
+ state.reader = true
+ return r
+ })()
+
+ // Flush microtasks and assert neither acquired
+ await flush()
+ expect(state.writer2).toBe(false)
+ expect(state.reader).toBe(false)
+
+ // Release writer1
+ writer1[Symbol.dispose]()
+ state.writers--
+
+ // writer2 should acquire next
+ const writer2 = await writer2Task
+ expect(state.writer2).toBe(true)
+
+ // Reader still blocked while writer2 held
+ await flush()
+ expect(state.reader).toBe(false)
+
+ // Release writer2
+ writer2[Symbol.dispose]()
+ state.writers--
+
+ // Reader should now acquire
+ const reader = await readerTask
+ expect(state.reader).toBe(true)
+
+ reader[Symbol.dispose]()
+ })
+})
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index e423fecea..acc29d9b4 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -545,6 +545,10 @@ export type QuestionInfo = {
* Allow selecting multiple choices
*/
multiple?: boolean
+ /**
+ * Allow typing a custom answer (default: true)
+ */
+ custom?: boolean
}
export type QuestionRequest = {
@@ -706,6 +710,7 @@ export type PermissionRuleset = Array<PermissionRule>
export type Session = {
id: string
+ slug: string
projectID: string
directory: string
parentID?: string
diff --git a/packages/util/src/slug.ts b/packages/util/src/slug.ts
new file mode 100644
index 000000000..62cf0e57b
--- /dev/null
+++ b/packages/util/src/slug.ts
@@ -0,0 +1,74 @@
+export namespace Slug {
+ const ADJECTIVES = [
+ "brave",
+ "calm",
+ "clever",
+ "cosmic",
+ "crisp",
+ "curious",
+ "eager",
+ "gentle",
+ "glowing",
+ "happy",
+ "hidden",
+ "jolly",
+ "kind",
+ "lucky",
+ "mighty",
+ "misty",
+ "neon",
+ "nimble",
+ "playful",
+ "proud",
+ "quick",
+ "quiet",
+ "shiny",
+ "silent",
+ "stellar",
+ "sunny",
+ "swift",
+ "tidy",
+ "witty",
+ ] as const
+
+ const NOUNS = [
+ "cabin",
+ "cactus",
+ "canyon",
+ "circuit",
+ "comet",
+ "eagle",
+ "engine",
+ "falcon",
+ "forest",
+ "garden",
+ "harbor",
+ "island",
+ "knight",
+ "lagoon",
+ "meadow",
+ "moon",
+ "mountain",
+ "nebula",
+ "orchid",
+ "otter",
+ "panda",
+ "pixel",
+ "planet",
+ "river",
+ "rocket",
+ "sailor",
+ "squid",
+ "star",
+ "tiger",
+ "wizard",
+ "wolf",
+ ] as const
+
+ export function create() {
+ return [
+ ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)],
+ NOUNS[Math.floor(Math.random() * NOUNS.length)],
+ ].join("-")
+ }
+}