opencode 的 plan 模式在拦什么:模式切换本质是改工具可用面
本文基于 opencode 仓库 commit 7fe9938(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/anomalyco/opencode 最新代码与文档为准。
**开源终端编码 Agent 项目 opencode,它的 plan 模式最值得抄的地方,不是那段”你现在只读、不许改文件”的提示词,而是它在把请求发给模型之前,就按权限规则把一部分工具从工具列表里删掉了。**提示词是劝,工具列表是拦。你让模型”先想一想”,模型完全可以想完了顺手就改;你把写文件的工具从这一轮请求里拿掉,它连改的手都伸不出去。这两件事在 opencode 里是分开实现的,读代码时能看得很清楚。
opencode 是一个跑在终端里的开源编码 Agent 项目,采用 MIT 许可证(见仓库根目录 LICENSE)。这篇只谈它的 plan 模式这一条线:计划代理的权限集长什么样、模式提示词从哪注入、退出计划为什么要专门做成一个工具。站内另外几篇分工不同——Claude Code 的 plan mode 怎么用讲的是另一个工具的日常用法,superpowers 怎么写计划谈的是计划文档本身该写成什么样,Agent 该做状态机还是放自由是架构取向的讨论,本篇只干一件事:把 opencode 这套实现从代码层面拆开,看它每一环各自拦住了什么。
一、先看它拦的是什么:工具进不进请求,是两套判定
在 opencode 里,“某个工具能不能用”有两个完全不同的判定时机,混在一起看就会觉得逻辑重复。
第一个时机在组装请求的时候。packages/opencode/src/session/llm/request.ts 里有个 resolveTools,它按当前代理的权限规则算出一份要摘掉的集合,再从工具表里滤掉:
function resolveTools(input: Pick<PrepareInput, "tools" | "agent" | "permission" | "user">) {
const disabled = Permission.disabled(
Object.keys(input.tools),
Permission.merge(input.agent.permission, input.permission ?? []),
)
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
}
被滤掉的工具压根不会出现在这一轮的工具定义里。模型看不见它,也就不存在”忍住不调用”这回事。
第二个时机在工具真的被调用的那一刻。packages/opencode/src/permission/index.ts 里的 evaluate 会拿权限名和具体 pattern 去规则表里 findLast 一条匹配的规则,命中 deny 直接报错,命中 allow 放行,没命中或者是 ask 就弹给你确认。
关键在于这两个判定用的不是同一个条件。Permission.disabled 只在最后一条匹配规则的 pattern 恰好是 "*" 且动作是 deny 时才把工具藏起来:
export function disabled(tools: string[], ruleset: PermissionV1.Ruleset): Set<string> {
const edits = ["edit", "write", "apply_patch"]
const reads = ["list_mcp_resources", "list_mcp_resource_templates", "read_mcp_resource"]
return new Set(
tools.filter((tool) => {
const permission = edits.includes(tool) ? "edit" : reads.includes(tool) ? "read" : tool
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
return rule?.pattern === "*" && rule.action === "deny"
}),
)
}
也就是说:**全量 deny 的工具会被藏起来,带白名单例外的 deny 不会被藏,只会在每次调用时逐条评估。**这个区分很重要,下一节讲计划代理的时候你会看到它直接决定了 plan 模式的手感。
顺带一提,edit、write、apply_patch 三个工具在这里被映射到同一个 edit 权限名上,所以你写一条 edit 规则,管的是这三个工具。
二、计划代理的权限集:deny 一片,再抠出一个洞
packages/opencode/src/agent/agent.ts 里定义了几个内置代理。默认代理叫 build,计划代理叫 plan,描述写得很直白:Plan mode. Disallows all edit tools.
plan 代理的权限是在一份 defaults 之上合并出来的,核心的几条:
Permission.fromConfig({
question: "allow",
plan_exit: "allow",
task: {
general: "deny",
},
external_directory: {
[path.join(Global.Path.data, "plans", "*")]: "allow",
},
edit: {
"*": "deny",
[path.join(".opencode", "plans", "*.md")]: "allow",
[path.relative(ctx.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
},
}),
把它和上一节的判定规则合起来读,plan 模式的实际效果就出来了:
edit 这一组的最后一条匹配规则不是 "*",而是计划目录的白名单,所以 edit / write / apply_patch 不会从模型的工具列表里消失。模型仍然看得见它们,仍然会尝试调用,只是每次调用都要过 evaluate 这一关——路径落在计划文件白名单里就放行,落在你的源码上就被拒。这是个刻意的取舍:计划阶段模型需要把计划写进一个 markdown 文件,写文件的能力不能一刀切掉,只能按路径切。
对比一下 explore 子代理的写法就更清楚了,它是真的把面收窄:先 "*": "deny",再逐个 allow 出 grep、glob、list、bash、webfetch、websearch、read。这种 "*" 结尾是 deny 的写法才会触发工具隐藏。
还有一条容易被忽略:plan 代理里 task 权限对 general 是 deny。这条不影响 task 工具本身的可见性(因为最后一条匹配规则的 pattern 是 general 而不是 "*"),但它会让计划代理在派发子代理时被挡在 general 这一类上。这一点和提示词里的写法存在张力,下一节会点到,你自己开仓库核一遍最稳妥。
三、进入与退出:两段提示词、一个真工具、一个悬着的名字
选题里说”进入与退出各有一段提示词”,仓库里确实各有一份 txt,但两者的成色不一样,这是这篇最值得你亲手核对的地方。
退出这一侧是完整闭环。packages/opencode/src/tool/plan.ts 定义了 plan_exit 工具,描述文本就是 plan-exit.txt,里面写清了什么时候该调用(计划已经完整写进计划文件、疑问都澄清了、你有把握可以开工了)和什么时候不该调用(计划还没定稿、还有没答的问题、用户说了还要继续改计划)。工具的执行体做了四件事:算出计划文件相对工作树的路径,用问答工具向你确认要不要切到 build 代理,你选 No 就抛出一个拒绝错误留在计划代理里,你选 Yes 就往会话里塞一条 agent: "build" 的合成用户消息,附带一段合成文本:
text: `The plan at ${plan} has been approved, you can now edit files. Execute the plan`,
终端界面那边配合着做了收尾。packages/tui/src/routes/session/index.tsx 监听消息片段更新,看到一个已完成的、名为 plan_exit 的工具结果,就把本地代理切成 build。
进入这一侧就不一样了。packages/opencode/src/tool/plan-enter.txt 这份文本是存在的,内容也写得很规整——什么时候建议切到计划代理(请求复杂、想先调研再动手、任务牵扯多个文件或重要架构决策),什么时候别建议(任务简单、用户明确要求立刻实现)。但在当前这份代码里,用 grep 搜 plan-enter 搜不到任何 .ts 文件导入它;工具注册表 packages/opencode/src/tool/registry.ts 里注册进去的只有 PlanExitTool。与此同时,plan_enter 这个名字作为权限动作是活着的:defaults 里它是 deny,build 代理把它改成 allow,非交互的 run 命令又会把它压回 deny;终端界面里也留着一个分支,看到名为 plan_enter 的工具结果就把代理切成 plan。
所以更准确的说法是:退出计划这条路已经接通到工具层,进入计划这条路的提示词和权限位都备好了,工具实现这一环在这个 commit 上还没接上。你要是照着别人的博客以为有个 plan_enter 工具可以调,会白折腾半天。
四、模式提示词从哪注入:reminders 这一层
工具面收窄了,模型还需要知道”我现在在什么模式、该按什么流程走”。这部分不在工具里,在 packages/opencode/src/session/reminders.ts。它的做法是往最后一条用户消息上追加一个 synthetic: true 的文本片段——对你不可见,对模型可见。
走哪条分支,取决于一个实验开关 experimentalPlanMode(对应环境变量 OPENCODE_EXPERIMENTAL_PLAN_MODE,定义在 packages/opencode/src/effect/runtime-flags.ts):
开关没开时,plan 代理拿到的是 packages/opencode/src/session/prompt/plan.txt。这份提示词短而硬,通篇在强调只读:不许编辑、不许改配置、不许提交,连用 sed、tee、echo、cat 去改文件都点名禁了,并声明这条约束压过其它所有指令,包括你本人直接提出的修改要求。
开关开着时,plan 代理拿到的是 packages/opencode/src/session/prompt/plan-mode.txt。它比前者长得多,除了同样的只读声明之外,还带一份五阶段工作流:阶段一只用 explore 子代理做理解,最多并行三个,能用一个就别用三个;阶段二做设计;阶段三回读关键文件、和你确认对齐;阶段四把最终方案写进计划文件,只写推荐方案不写备选,并要求带上待改文件路径和一段端到端验证方式;阶段五调用 plan_exit。它还专门划了分工——澄清需求用问答工具,请求批准用 plan_exit,别拿问答工具去问”这个计划行吗”。
这份提示词里有个 ${planInfo} 占位符,由 reminders 在注入时替换:计划文件已存在就告诉模型可以读它并增量编辑,不存在就告诉模型该用写文件工具在指定路径新建。计划文件路径的算法在 packages/opencode/src/session/session.ts 的 plan 函数里,项目在版本控制下就落到工作树的 .opencode/plans 目录,否则落到全局数据目录的 plans 下,文件名由会话创建时间戳和 slug 拼成。
反向切换也有一份提示词。从 plan 切回别的代理时,reminders 会追加 build-switch.txt,明确告诉模型”你的运行模式已从 plan 变为 build,不再是只读”;如果计划文件存在,还会在后面补一句这个计划文件的路径以及”你应该执行其中定义的计划”。
上一节留的那个张力就在这里:plan-mode.txt 的阶段二让你派 general 代理去做设计,而 plan 代理的权限里 task 对 general 是 deny。提示词和权限规则谁说了算,答案在第一节——权限规则说了算,evaluate 命中 deny 就是直接报错。这类”文档层和执行层各说各话”的缝,是读任何 Agent 项目时最该优先找的东西。
五、一张表把这几块对上号
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| plan 代理的权限集 | 定义计划模式下哪些工具全禁、哪些按路径放行 | packages/opencode/src/agent/agent.ts | 想调整计划模式能碰什么文件时 |
| 请求期工具过滤 | 把全量 deny 的工具从模型工具列表里摘掉 | packages/opencode/src/session/llm/request.ts | 排查”模型为什么看得见这个工具”时 |
| 调用期权限评估 | 每次调用按 pattern 判 allow / ask / deny | packages/opencode/src/permission/index.ts | 排查”为什么这次调用被拒/被问”时 |
| 模式提示词注入 | 按代理和实验开关追加合成提示片段 | packages/opencode/src/session/reminders.ts | 想知道模型到底收到了哪段模式说明时 |
| 只读硬约束提示词 | 未开实验开关时的计划模式声明 | packages/opencode/src/session/prompt/plan.txt | 默认配置下跑计划模式时 |
| 计划工作流提示词 | 开启实验开关后的五阶段流程与计划文件说明 | packages/opencode/src/session/prompt/plan-mode.txt | 开了实验开关、想改流程时 |
| 切回构建的提示词 | 声明模式已变、可以动文件了 | packages/opencode/src/session/prompt/build-switch.txt | 从计划切回构建那一轮 |
| plan_exit 工具 | 向你确认、并把会话切到 build 代理 | packages/opencode/src/tool/plan.ts、plan-exit.txt | 计划写完准备开工时 |
| 进入计划的描述文本 | 建议切到计划代理的判据 | packages/opencode/src/tool/plan-enter.txt | 想找对应工具实现时(当前未见导入) |
| 计划文件路径规则 | 决定计划落到工作树还是全局目录 | packages/opencode/src/session/session.ts | 计划文件找不着时 |
| 终端界面模式联动 | 按工具结果切换本地代理显示 | packages/tui/src/routes/session/index.tsx | 界面模式没跟着变时 |
这份仓库的整体规模也顺带给你个坐标:packages/ 下 32 个包,packages/opencode/src/session/prompt/ 里 14 份提示词,packages/opencode/src/tool/ 里 25 个 .ts 和 15 个 .txt,英文文档在 packages/web/src/content/docs/ 有 36 份 mdx,根目录还有 21 份 README 翻译,全仓受版本控制的文件 6358 个。提示词和工具描述都是独立 txt 而不是内嵌字符串,这个安排让”改提示词”变成一次纯文本改动,也让你这样的外部读者可以逐字核对模型到底被喂了什么。
六、边界与代价:它放弃了什么、明确不管什么
这套设计不是万能的护栏,几条边界你必须先认下来。
**它管不住信息流出。**计划模式收的是写入面,不是读取面。读文件、搜代码、跑只读命令这些能力在计划阶段是被鼓励的——plan-mode.txt 的阶段一整段都在讲怎么派探索子代理去读代码。读到的内容会作为上下文发给模型服务商。私有代码、注释里的内网地址、测试夹具里的样例数据,该出去的一样出去。默认权限里对读 *.env 之类的文件设了 ask,*.env.example 才是 allow,但这只是一层薄防护,不是隔离。真要防泄漏,得从工作区隔离和最小权限的角度另做设计,可以对着最小权限的 Agent 设计这条线单独想。
**它管不住只读命令的破坏力。**计划代理的权限是在一份 "*": "allow" 的 defaults 上做减法的,减掉的主要是 edit 这一组和几个特定动作。执行 shell 命令这类能力在计划阶段并没有被全量禁掉,提示词里那句”不许用 sed、tee、echo、cat 改文件”是靠模型自觉遵守的——它是一句提示,不是一条规则。模型如果绕过去,请求期的工具过滤不会拦,因为工具本来就是可见的。你在别人的仓库上试这套东西之前,先确认工作区是干净的、有 git 兜底。
它不做代码正确性的判断。plan_exit 只问你一句要不要切到构建代理,两个选项。你点了 Yes,会话里就多一条合成消息说计划已批准、可以改文件了。它不校验计划文件写没写、写得对不对,也不校验待改文件路径是否真的存在。计划质量完全由你在那一次确认里把关。
它对非交互场景基本失效。plan_exit 工具只在实验开关打开、且客户端是 cli 时才注册进工具表;run 命令在非交互模式下还会把问答、进入计划、退出计划三个权限全压成 deny。这很合理——没有人在终端前面,问了也没人答——但意味着你在 CI、定时任务这类场景里用不上这套模式切换,得另想约束办法。
**它换来的代价是流程变重。**开着实验开关的那套五阶段工作流,要派探索子代理、要问澄清问题、要写计划文件、要走一次退出确认。一个改错别字的任务走完这一套,纯属浪费。plan-enter.txt 里那两条”不要调用”的判据就是在防这个,只是当前它还没被工具层用起来,判断得靠你自己下。
七、上手与避坑清单
别把提示词当护栏用。 会踩是因为读了 plan.txt 那段措辞强烈的只读声明,就以为文件安全了。避法是记住判定分两层:真正的硬拦截在工具过滤和权限评估里,提示词只覆盖那些没被规则挡住的行为。想知道某个工具到底会不会被拦,去看它对应的权限名的最后一条匹配规则的 pattern 是不是 "*"。
别以为计划模式下模型看不见写文件的工具。 会踩是因为”Disallows all edit tools”这句描述看着像全禁。实际上 plan 代理的 edit 规则带计划目录白名单,所以这几个工具照样出现在请求里,只是路径不对就被拒。避法是把它理解成”按路径放行”,别在此基础上再叠一层”反正它没有工具”的假设。
找不到计划文件先看项目在不在版本控制下。 会踩是因为路径不是固定的:项目有 vcs 就落在工作树的 .opencode/plans,没有就落在全局数据目录。避法是直接看 session.ts 里的 plan 函数,或者从 plan_exit 的确认弹窗里读那个相对路径——它会把路径写在问题文本里。
别去调用一个当前不存在的进入工具。 会踩是因为 plan-enter.txt 这份文本读着完全像一个已上线工具的描述,权限名和界面分支也都在。避法是在你用的那个版本里搜一遍这份 txt 有没有被导入、注册表里有没有对应条目,再决定要不要围绕它写自动化。
改自定义权限时注意规则是按顺序 findLast 的。 会踩是因为直觉上会以为 deny 优先级最高,实际是后写的规则覆盖先写的,而且用户配置是最后合并进去的。避法是改完拿一个具体的权限名加具体 pattern 在脑子里跑一遍 findLast,别只看自己新加的那条。
别指望它替你把关计划质量。 会踩是因为退出确认弹窗看着像个审核门。避法是把那次 Yes 当成你自己的签字:先打开计划文件读一遍,确认待改文件路径和验证方式都写了,再点。这跟工具没关系,是你的流程问题——权限放太松导致的返工,多数时候都不是模型的锅,权限给太大之后会发生什么那条线里的坑同理。
收束
这套 plan 模式给你的可迁移经验其实就一条:**模式切换要落到工具可用面上才算数。**提示词负责讲清楚”现在该干什么”,权限规则负责保证”不该干的干不成”,两者缺一不可,但只有后者是硬的。
你要接着往下读的话,建议按这个顺序:先 packages/opencode/src/agent/agent.ts 看内置代理各自的权限集,再 packages/opencode/src/permission/index.ts 看 evaluate 和 disabled 这两个函数的差别,最后回到 packages/opencode/src/session/reminders.ts 看提示词是在哪一刻、以什么形态塞进消息里的。这三处读完,你再看它的任何一个模式或子代理,都能自己算出它到底能碰什么。
最后留一份自检清单,用在你自己那套 Agent 上:模式切换有没有改变实际下发的工具列表;被禁的能力是靠规则拦的还是靠提示词劝的;只读模式下信息流出面收了没有;切回可写模式时模型有没有被明确告知;以及非交互场景下这套约束还成不成立。五条里有两条答不上来,你那套模式切换大概率还停留在嘴上。
本篇属于一个把开源AI 编程 Agent 项目 opencode逐层拆开讲的系列,整体地图见 opencode 是什么:终端里的开源 AI 编程 Agent 全景地图;沿着这条线往下,还可以看 opencode 怎么把活派给子 agent:task 工具与探索型子代理 和 opencode 的 code mode 支线:让模型写程序串工具的代价。