Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
320 changes: 320 additions & 0 deletions .opencode/plans/1768330644696-gentle-harbor.md
Original file line number Diff line number Diff line change
@@ -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
6 changes: 5 additions & 1 deletion packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -71,6 +73,7 @@ export namespace Agent {
defaults,
PermissionNext.fromConfig({
question: "allow",
plan_enter: "allow",
}),
user,
),
Expand All @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading