Compare commits

...

5 Commits

Author SHA1 Message Date
Dax Raad
6560f40073 tui: fix plan mode switching to prevent duplicate agent switches 2026-01-13 15:34:57 -05:00
Dax Raad
69795efdcd tui: improve plan mode switching and question handling
- Add automatic agent switching when plan tools complete
- Add custom option control to question interface
- Update plan tool descriptions to clarify agent switching
- Show plan file path in exit/exit prompts
- Remove custom answer option when not needed
2026-01-13 15:32:37 -05:00
Dax Raad
a5da5aabf5 sync 2026-01-13 14:42:44 -05:00
Dax Raad
758e3465b9 core: add plan mode enter/exit tools and permissions
- Add PlanEnterTool and PlanExitTool to tool registry for CLI clients
- Configure plan_enter and plan_exit permissions in agent defaults
- Add plan mode switching logic in session prompt
- Update build switch message to reference existing plan file
- Rename exit_plan to plan_exit for consistency
2026-01-13 14:11:59 -05:00
Dax Raad
caa0a28821 Sync 2026-01-13 13:56:40 -05:00
16 changed files with 824 additions and 46 deletions

View File

@@ -0,0 +1,320 @@
# Plan: Implement enter_plan and exit_plan Tools
## Summary
The plan mode workflow in `prompt.ts` references `exit_plan` tool that doesn't exist. We need to implement two tools:
1. **`exit_plan`** - Called when the AI finishes planning; uses the Question module to ask the user if they want to switch to build mode (yes/no). **Only available in plan mode.** If user says yes, creates a synthetic user message with the "build" agent to trigger the mode switch in the loop.
2. **`enter_plan`** - Called to enter plan mode. **Only available in build mode.** If user says yes, creates a synthetic user message with the "plan" agent.
## Key Insight: How Mode Switching Works
Looking at `prompt.ts:455-478`, the session loop determines the current agent from the last user message's `agent` field (line 510: `const agent = await Agent.get(lastUser.agent)`).
To switch modes, we need to:
1. Ask the user for confirmation
2. If confirmed, create a synthetic user message with the **new agent** specified
3. The loop will pick up this new user message and use the new agent
## Files to Modify
| File | Action |
| ------------------------------------------ | --------------------------------------------------------------- |
| `packages/opencode/src/tool/plan.ts` | **CREATE** - New file with both tools |
| `packages/opencode/src/tool/exitplan.txt` | **CREATE** - Description for exit_plan tool |
| `packages/opencode/src/tool/enterplan.txt` | **CREATE** - Description for enter_plan tool |
| `packages/opencode/src/tool/registry.ts` | **MODIFY** - Register the new tools |
| `packages/opencode/src/agent/agent.ts` | **MODIFY** - Add permission rules to restrict tool availability |
## Implementation Details
### 1. Create `packages/opencode/src/tool/plan.ts`
```typescript
import z from "zod"
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 EXIT_DESCRIPTION from "./exitplan.txt"
import ENTER_DESCRIPTION from "./enterplan.txt"
export const ExitPlanTool = Tool.define("exit_plan", {
description: EXIT_DESCRIPTION,
parameters: z.object({}),
async execute(_params, ctx) {
const answers = await Question.ask({
sessionID: ctx.sessionID,
questions: [
{
question: "Planning is complete. Would you like to switch to build mode and start implementing?",
header: "Build Mode",
options: [
{ label: "Yes", description: "Switch to build mode and start implementing the plan" },
{ label: "No", description: "Stay in plan mode to continue refining the plan" },
],
},
],
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
const answer = answers[0]?.[0]
const shouldSwitch = answer === "Yes"
// If user wants to switch, create a synthetic user message with the new agent
if (shouldSwitch) {
// Get model from the last user message in the session
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", // Switch to build agent
model,
}
await Session.updateMessage(userMsg)
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: userMsg.id,
sessionID: ctx.sessionID,
type: "text",
text: "User has approved the plan. Switch to build mode and begin implementing the plan.",
synthetic: true,
} satisfies MessageV2.TextPart)
}
return {
title: shouldSwitch ? "Switching to build mode" : "Staying in plan mode",
output: shouldSwitch
? "User confirmed to switch to build mode. A new message has been created to switch you to build mode. Begin implementing the plan."
: "User chose to stay in plan mode. Continue refining the plan or address any concerns.",
metadata: {
switchToBuild: shouldSwitch,
answer,
},
}
},
})
export const EnterPlanTool = Tool.define("enter_plan", {
description: ENTER_DESCRIPTION,
parameters: z.object({}),
async execute(_params, ctx) {
const answers = await Question.ask({
sessionID: ctx.sessionID,
questions: [
{
question:
"Would you like to switch to plan mode? In plan mode, the AI will only research and create a plan without making changes.",
header: "Plan Mode",
options: [
{ label: "Yes", description: "Switch to plan mode for research and planning" },
{ label: "No", description: "Stay in build mode to continue making changes" },
],
},
],
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
})
const answer = answers[0]?.[0]
const shouldSwitch = answer === "Yes"
// If user wants to switch, create a synthetic user message with the new agent
if (shouldSwitch) {
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", // Switch to plan agent
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: shouldSwitch ? "Switching to plan mode" : "Staying in build mode",
output: shouldSwitch
? "User confirmed to switch to plan mode. A new message has been created to switch you to plan mode. Begin planning."
: "User chose to stay in build mode. Continue with the current task.",
metadata: {
switchToPlan: shouldSwitch,
answer,
},
}
},
})
// Helper to get the model from the last user message
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()
}
```
### 2. Create `packages/opencode/src/tool/exitplan.txt`
```
Use this tool when you have completed the planning phase and are ready to exit plan mode.
This tool will ask the user if they want to switch to build mode 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
```
### 3. Create `packages/opencode/src/tool/enterplan.txt`
```
Use this tool to suggest entering plan mode when the user's request would benefit from planning before implementation.
This tool will ask the user if they want to switch to plan mode.
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
- When already in plan mode
```
### 4. Modify `packages/opencode/src/tool/registry.ts`
Add import and register tools:
```typescript
// Add import at top (around line 27)
import { ExitPlanTool, EnterPlanTool } from "./plan"
// Add to the all() function return array (around line 110-112)
return [
// ... existing tools
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
ExitPlanTool,
EnterPlanTool,
...custom,
]
```
### 5. Modify `packages/opencode/src/agent/agent.ts`
Add permission rules to control which agent can use which tool:
**In the `defaults` ruleset (around line 47-63):**
```typescript
const defaults = PermissionNext.fromConfig({
"*": "allow",
doom_loop: "ask",
// Add these new defaults - both denied by default
exit_plan: "deny",
enter_plan: "deny",
external_directory: {
// ... existing
},
// ... rest of existing defaults
})
```
**In the `build` agent (around line 67-79):**
```typescript
build: {
name: "build",
options: {},
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
question: "allow",
enter_plan: "allow", // Allow build agent to suggest plan mode
}),
user,
),
mode: "primary",
native: true,
},
```
**In the `plan` agent (around line 80-96):**
```typescript
plan: {
name: "plan",
options: {},
permission: PermissionNext.merge(
defaults,
PermissionNext.fromConfig({
question: "allow",
exit_plan: "allow", // Allow plan agent to exit plan mode
edit: {
"*": "deny",
".opencode/plans/*.md": "allow",
},
}),
user,
),
mode: "primary",
native: true,
},
```
## Design Decisions
1. **Synthetic user message for mode switching**: When the user confirms a mode switch, a synthetic user message is created with the new agent specified. The loop picks this up on the next iteration and switches to the new agent. This follows the existing pattern in `prompt.ts:455-478`.
2. **Permission-based tool availability**: Uses the existing permission system to control which tools are available to which agents. `exit_plan` is only available in plan mode, `enter_plan` only in build mode.
3. **Question-based confirmation**: Both tools use the Question module for consistent UX.
4. **Model preservation**: The synthetic user message preserves the model from the previous user message.
## Verification
1. Run `bun dev` in `packages/opencode`
2. Start a session in build mode
- Verify `exit_plan` is NOT available (denied by permission)
- Verify `enter_plan` IS available
3. Call `enter_plan` in build mode
- Verify the question prompt appears
- Select "Yes" and verify:
- A synthetic user message is created with `agent: "plan"`
- The next assistant response is from the plan agent
- The plan mode system reminder appears
4. In plan mode, call `exit_plan`
- Verify the question prompt appears
- Select "Yes" and verify:
- A synthetic user message is created with `agent: "build"`
- The next assistant response is from the build agent
5. Test "No" responses - verify no mode switch occurs

View File

@@ -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,

View File

@@ -195,6 +195,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()

View File

@@ -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>

View File

@@ -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()

View File

@@ -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",

View File

@@ -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

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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: {},
}
},
})

View File

@@ -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,
]
}

View File

@@ -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]()
})
})

View File

@@ -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

74
packages/util/src/slug.ts Normal file
View File

@@ -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("-")
}
}