Skip to content

Commit f37ffa4

Browse files
committed
feat: add external editor fallback for plan review
1 parent 8e51f55 commit f37ffa4

4 files changed

Lines changed: 189 additions & 6 deletions

File tree

README.md

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# opencode-planner
22

33
`opencode-planner` is an OpenCode plugin that adds a dedicated `plan` agent for read-only planning before implementation. It's based on the experimental plan agent. That is, it likes to use sub-agents and a structured approach to planning.
4-
It asks clarifying questions, and produces a markdown file. When Plannotator is installed, it can submit the finished plan for richer review. Without Plannotator, it falls back to a normal chat-based review handoff.
4+
It asks clarifying questions, and produces a markdown file. When Plannotator is installed, it can submit the finished plan for richer review. Without Plannotator, it can open the plan in your configured external editor for review.
55

66
After review, the agent can hand back to implementation mode by calling `plan_exit` only when the host runtime exposes that tool. In current OpenCode builds, that means experimental plan mode must be enabled and the client must be `cli`. If it's not enabled, you need to prompt the build agent to start work.
77

@@ -36,9 +36,10 @@ If you want reproducible installs instead of automatic plugin refreshes, pin an
3636
- injects a system reminder that keeps the planning workflow explicit
3737
- lets users replace the plugin's base `plan` prompt with their own `agent.plan.prompt`
3838
- lets users override agent settings such as `agent.plan.model` and provider-specific options like `agent.plan.reasoningEffort`
39-
- denies `submit_plan` and `plan_exit` to the built-in `general` and `explore` subagents so review and implementation handoff stay on the primary `plan` agent
39+
- denies `submit_plan`, `edit_plan`, and `plan_exit` to the built-in `general` and `explore` subagents so review and implementation handoff stay on the primary `plan` agent
4040
- exposes a `plan_prompt` tool so the `plan` agent can reveal the plugin's prompt basis for customization
41-
- uses `submit_plan` for review when available, otherwise falls back to manual chat review
41+
- exposes an `edit_plan` tool so the `plan` agent can open the current plan in the configured external editor
42+
- uses `submit_plan` for review when available, otherwise falls back to external-editor review
4243
- can leave planner mode with `plan_exit` after approval when experimental plan mode is enabled in the CLI runtime
4344

4445
## Customize the plan agent
@@ -75,6 +76,37 @@ The tool returns:
7576
- the injected planner reminder, which is plugin-controlled runtime guidance and is not customized via `agent.plan.prompt`
7677
- a short note explaining that the final runtime prompt can still differ because of user config, other plugins, or runtime tool availability like `plan_exit`
7778

79+
## Review Without Plannotator
80+
81+
If `submit_plan` is not registered by the runtime, the plugin's `edit_plan` tool gives the `plan` agent a fallback way to open the current plan in your configured external editor.
82+
83+
Example:
84+
85+
```text
86+
If submit_plan is unavailable, call edit_plan so I can review the plan in my editor.
87+
```
88+
89+
`edit_plan` uses `VISUAL` first, then `EDITOR`. The command must launch a separate process and block until editing is complete.
90+
91+
Compatible examples:
92+
93+
- `VISUAL="gvim -f"`
94+
- `EDITOR="gedit --wait"`
95+
- `EDITOR="kate --block"`
96+
- `EDITOR="code --wait"`
97+
98+
These work because they open a separate editor process and do not try to take over the OpenCode TUI terminal.
99+
100+
Bare terminal editors like `vim` or `nvim` are not sufficient on their own because the plugin does not hand the current TUI terminal over to the editor. If you want to use them, wrap them in a terminal-emulator command that opens a new window and waits for it to exit.
101+
102+
Examples:
103+
104+
- `EDITOR="gnome-terminal --wait -- nvim"`
105+
- `EDITOR="kitty --wait nvim"`
106+
- a small wrapper script for your terminal emulator that launches `vim` or `nvim` in a separate window and blocks until it exits
107+
108+
If `edit_plan` fails, the `plan` agent should fall back to telling you the plan file path and asking for review in chat.
109+
78110
## Auto-updates
79111

80112
OpenCode installs and updates npm plugins automatically. During the beta phase of this plugin, `opencode-planner@beta` gives the smoothest update path for most users.
@@ -95,7 +127,7 @@ npm run debug:plan
95127
npm run opencode:no-plannotator -- debug config
96128
```
97129

98-
`npm run debug:plan` checks the active OpenCode runtime and reports whether the local repo plugin is loaded, whether `plan_prompt`, `submit_plan`, and `plan_exit` are allowed by the `plan` agent, and whether they are actually registered as runtime tools.
130+
`npm run debug:plan` checks the active OpenCode runtime and reports whether the local repo plugin is loaded, whether `plan_prompt`, `edit_plan`, `submit_plan`, and `plan_exit` are allowed by the `plan` agent, and whether they are actually registered as runtime tools.
99131

100132
This is the fastest way to distinguish:
101133

index.js

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
import { spawn } from "node:child_process"
2+
import { readFile } from "node:fs/promises"
13
import path from "path"
4+
import process from "node:process"
25

36
const agent = "plan"
47
const root = ".opencode/plans"
@@ -22,7 +25,7 @@ function file(id) {
2225
function reviewInstruction(target) {
2326
return [
2427
`When the plan is complete, if the submit_plan tool is available, use it to submit the plan for review.`,
25-
`Otherwise, tell the user the plan is ready at ${target} and ask for review in chat.`,
28+
`Otherwise, call edit_plan to open the markdown plan in the configured external editor for review. If edit_plan fails, tell the user the plan is ready at ${target} and ask for review in chat.`,
2629
].join(" ")
2730
}
2831

@@ -122,12 +125,76 @@ function restrictPlannerSubagent(input = {}) {
122125
return {
123126
...input,
124127
permission: merge(input?.permission, {
128+
edit_plan: "deny",
125129
plan_exit: "deny",
126130
submit_plan: "deny",
127131
}),
128132
}
129133
}
130134

135+
function editorCommand() {
136+
return process.env.VISUAL?.trim() || process.env.EDITOR?.trim() || ""
137+
}
138+
139+
function runEditor(target) {
140+
const editor = editorCommand()
141+
if (!editor) {
142+
throw new Error(
143+
"Neither `VISUAL` nor `EDITOR` is set, so edit_plan cannot open the plan. Configure a blocking editor command such as `code --wait`, or a terminal launcher that opens your editor in a separate window and waits.",
144+
)
145+
}
146+
147+
const shell = process.env.SHELL ?? "sh"
148+
149+
return new Promise((resolve, reject) => {
150+
const child = spawn(shell, ["-lc", `${editor} "$1"`, "opencode-editor", target], {
151+
stdio: ["ignore", "ignore", "pipe"],
152+
env: process.env,
153+
})
154+
155+
let stderr = ""
156+
child.stderr?.on("data", (chunk) => {
157+
stderr += chunk.toString()
158+
})
159+
160+
child.on("error", reject)
161+
child.on("exit", (code) => {
162+
if (code === 0) {
163+
resolve()
164+
return
165+
}
166+
167+
const detail = stderr.trim()
168+
const suffix = detail ? `: ${detail}` : ""
169+
reject(
170+
new Error(
171+
`The external editor command exited with status ${code}${suffix}. Configure VISUAL or EDITOR to launch a separate process that waits until editing is complete.`,
172+
),
173+
)
174+
})
175+
})
176+
}
177+
178+
async function editPlan(sessionID) {
179+
const target = file(sessionID ?? "<session-id>")
180+
await runEditor(target)
181+
182+
let content = ""
183+
184+
try {
185+
content = await readFile(target, "utf8")
186+
} catch (error) {
187+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
188+
throw new Error(`The plan file \`${target}\` does not exist yet. Finish writing the plan first.`)
189+
}
190+
191+
throw error
192+
}
193+
194+
const rendered = content.trim() ? content : `The plan file \`${target}\` is empty.`
195+
return [`The plan was edited in your external editor.`, `File: ${target}`, "", rendered].join("\n")
196+
}
197+
131198
function mode(input = {}) {
132199
const base = {
133200
mode: "primary",
@@ -154,6 +221,7 @@ function mode(input = {}) {
154221
websearch: "allow",
155222
codesearch: "allow",
156223
batch: "allow",
224+
edit_plan: "allow",
157225
plan_prompt: "allow",
158226
submit_plan: "allow",
159227
...(hasPlanExit() ? { plan_exit: "allow" } : {}),
@@ -191,6 +259,13 @@ export default async function plannerPlugin() {
191259
return promptDisclosure(context.sessionID ? file(context.sessionID) : defaultPlanTarget)
192260
},
193261
},
262+
edit_plan: {
263+
description: "Open the current plan in the configured external editor",
264+
args: {},
265+
async execute(_, context) {
266+
return editPlan(context.sessionID)
267+
},
268+
},
194269
},
195270
async config(cfg) {
196271
cfg.agent ??= {}

scripts/debug-plan-runtime.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,11 @@ const tools = plan.tools ?? {}
4141
const prompt = plan.prompt ?? ""
4242

4343
const planPromptAllowed = hasAllowedPermission("plan_prompt", permissions)
44+
const editPlanAllowed = hasAllowedPermission("edit_plan", permissions)
4445
const planExitAllowed = hasAllowedPermission("plan_exit", permissions)
4546
const submitPlanAllowed = hasAllowedPermission("submit_plan", permissions)
4647
const planPromptTool = Boolean(tools.plan_prompt)
48+
const editPlanTool = Boolean(tools.edit_plan)
4749
const planExitTool = Boolean(tools.plan_exit)
4850
const submitPlanTool = Boolean(tools.submit_plan)
4951
const usingLocalPlugin = plugins.includes(localPlugin)
@@ -54,6 +56,8 @@ console.log("")
5456
line("Repo plugin loaded", usingLocalPlugin ? "yes" : "no")
5557
line("plan_prompt allowed", planPromptAllowed ? "yes" : "no")
5658
line("plan_prompt tool", planPromptTool ? "yes" : "no")
59+
line("edit_plan allowed", editPlanAllowed ? "yes" : "no")
60+
line("edit_plan tool", editPlanTool ? "yes" : "no")
5761
line("submit_plan allowed", submitPlanAllowed ? "yes" : "no")
5862
line("plan_exit allowed", planExitAllowed ? "yes" : "no")
5963
line("submit_plan tool", submitPlanTool ? "yes" : "no")
@@ -77,6 +81,9 @@ if (!usingLocalPlugin) {
7781
if (!planPromptTool) {
7882
console.log("- plan_prompt is not registered as a runtime tool.")
7983
}
84+
if (!editPlanTool) {
85+
console.log("- edit_plan is not registered as a runtime tool.")
86+
}
8087
if (!submitPlanTool) {
8188
console.log("- submit_plan is not registered as a runtime tool.")
8289
}

test/plugin.test.js

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import test from "node:test"
22
import assert from "node:assert/strict"
3+
import { mkdir, rm, writeFile } from "node:fs/promises"
34

45
import plannerPlugin from "../index.js"
56

@@ -25,6 +26,17 @@ async function withEnv(env, fn) {
2526
}
2627
}
2728

29+
async function withPlanFile(filePath, content, fn) {
30+
await mkdir(new URL("../.opencode/plans/", import.meta.url), { recursive: true })
31+
await writeFile(new URL(`../${filePath}`, import.meta.url), content)
32+
33+
try {
34+
await fn()
35+
} finally {
36+
await rm(new URL(`../${filePath}`, import.meta.url), { force: true })
37+
}
38+
}
39+
2840
test("config hook registers the plan agent without plan_exit by default", async () => {
2941
await withEnv(
3042
{
@@ -40,10 +52,12 @@ test("config hook registers the plan agent without plan_exit by default", async
4052

4153
assert.equal(cfg.agent.plan.mode, "primary")
4254
assert.equal(cfg.agent.plan.permission.bash, "deny")
55+
assert.equal(cfg.agent.plan.permission.edit_plan, "allow")
4356
assert.equal(cfg.agent.plan.permission.plan_prompt, "allow")
4457
assert.equal(cfg.agent.plan.permission.submit_plan, "allow")
4558
assert.equal(cfg.agent.plan.permission.plan_exit, undefined)
4659
assert.match(cfg.agent.plan.prompt, /if the submit_plan tool is available/i)
60+
assert.match(cfg.agent.plan.prompt, /call edit_plan to open the markdown plan/i)
4761
assert.match(cfg.agent.plan.prompt, /ask for review in chat/i)
4862
assert.doesNotMatch(cfg.agent.plan.prompt, /plan_exit/)
4963
},
@@ -100,7 +114,7 @@ test("config hook enables plan_exit when experimental plan mode is active", asyn
100114
)
101115
})
102116

103-
test("config hook denies submit_plan and plan_exit for planner subagents", async () => {
117+
test("config hook denies review handoff tools for planner subagents", async () => {
104118
await withEnv(
105119
{
106120
OPENCODE_EXPERIMENTAL: undefined,
@@ -113,13 +127,15 @@ test("config hook denies submit_plan and plan_exit for planner subagents", async
113127
agent: {
114128
general: {
115129
permission: {
130+
edit_plan: "allow",
116131
plan_exit: "allow",
117132
submit_plan: "allow",
118133
webfetch: "deny",
119134
},
120135
},
121136
explore: {
122137
permission: {
138+
edit_plan: "allow",
123139
plan_exit: "allow",
124140
submit_plan: "allow",
125141
},
@@ -129,15 +145,65 @@ test("config hook denies submit_plan and plan_exit for planner subagents", async
129145

130146
await plugin.config(cfg)
131147

148+
assert.equal(cfg.agent.general.permission.edit_plan, "deny")
132149
assert.equal(cfg.agent.general.permission.plan_exit, "deny")
133150
assert.equal(cfg.agent.general.permission.submit_plan, "deny")
134151
assert.equal(cfg.agent.general.permission.webfetch, "deny")
152+
assert.equal(cfg.agent.explore.permission.edit_plan, "deny")
135153
assert.equal(cfg.agent.explore.permission.plan_exit, "deny")
136154
assert.equal(cfg.agent.explore.permission.submit_plan, "deny")
137155
},
138156
)
139157
})
140158

159+
test("edit_plan opens the current session plan in the configured editor", async () => {
160+
await withEnv(
161+
{
162+
VISUAL: "true",
163+
EDITOR: "false",
164+
},
165+
async () => {
166+
await withPlanFile(
167+
".opencode/plans/ses_edit.md",
168+
"# Edited plan\n\n- reviewed\n",
169+
async () => {
170+
const plugin = await plannerPlugin()
171+
const output = await plugin.tool.edit_plan.execute(
172+
{},
173+
{
174+
sessionID: "ses_edit",
175+
},
176+
)
177+
178+
assert.match(output, /edited in your external editor/i)
179+
assert.match(output, /# Edited plan/)
180+
},
181+
)
182+
},
183+
)
184+
})
185+
186+
test("edit_plan reports when no blocking editor command is configured", async () => {
187+
await withEnv(
188+
{
189+
VISUAL: undefined,
190+
EDITOR: undefined,
191+
},
192+
async () => {
193+
const plugin = await plannerPlugin()
194+
await assert.rejects(
195+
plugin.tool.edit_plan.execute(
196+
{},
197+
{
198+
sessionID: "ses_edit",
199+
},
200+
),
201+
/Neither `VISUAL` nor `EDITOR` is set/i,
202+
)
203+
},
204+
)
205+
})
206+
141207
test("chat.message injects a planner reminder part", async () => {
142208
await withEnv(
143209
{
@@ -166,6 +232,7 @@ test("chat.message injects a planner reminder part", async () => {
166232
assert.match(output.parts[0].id, /^prt_/)
167233
assert.match(output.parts[0].text, /Planner mode is active\./)
168234
assert.match(output.parts[0].text, /if the submit_plan tool is available/i)
235+
assert.match(output.parts[0].text, /call edit_plan to open the markdown plan/i)
169236
assert.match(output.parts[0].text, /ask for review in chat/i)
170237
assert.match(output.parts[0].text, /plan_exit/)
171238
},
@@ -204,6 +271,7 @@ test("system transform only applies after planner messages", async () => {
204271

205272
assert.equal(system.system.length, 1)
206273
assert.match(system.system[0], /if the submit_plan tool is available/i)
274+
assert.match(system.system[0], /call edit_plan to open the markdown plan/i)
207275
assert.match(system.system[0], /ask for review in chat/i)
208276
assert.match(system.system[0], /plan_exit/)
209277
},
@@ -231,6 +299,7 @@ test("plan_prompt tool returns the plugin prompt basis without plan_exit by defa
231299
assert.match(output, /## Planner reminder/)
232300
assert.match(output, /injected by the plugin at runtime/i)
233301
assert.match(output, /not customized through `agent\.plan\.prompt`/i)
302+
assert.match(output, /call edit_plan to open the markdown plan/i)
234303
assert.match(output, /```json/)
235304
assert.match(output, /"agent": \{/)
236305
assert.match(output, /\.opencode\/plans\/ses_tool\.md/)

0 commit comments

Comments
 (0)