diff --git a/README-en.md b/README-en.md
index 453dd64c..e001940d 100644
--- a/README-en.md
+++ b/README-en.md
@@ -98,6 +98,14 @@ Skills are discovered from these locations, in priority order:
- `deepseek-v4-flash`
- Any other OpenAI-compatible model
+## Architecture and Benchmarks
+
+In ["Better Models: Worse Tools"](https://lucumr.pocoo.org/2026/7/4/better-models-worse-tools/), Armin Ronacher argues that tool schemas are not "neutral": models (LLMs) inherit tool-use habits formed during training and reinforcement learning, so they may perform well in one mainstream harness but become unstable with a different tool shape. This is the architectural starting point for Deep Code: it is tuned specifically for DeepSeek, so the harness itself can stay aligned with DeepSeek's behavior.
+
+Deep Code's gains come from the combined effect of tool constraints, context management, Agent Skills, permission policy, and other architectural decisions. The [deepcode-qrcode-benchmark](https://github.com/qorzj/deepcode-qrcode-benchmark) project shows that on a real and challenging Python requirement, Deep Code + DeepSeek + `/plan` mode has an effectiveness advantage over Claude Code + DeepSeek.
+
+> See also: [Deep Code Architecture](docs/architecture_en.md)
+
## FAQ
### Does Deep Code have a VSCode extension?
diff --git a/README-zh_CN.md b/README-zh_CN.md
index cde02314..00ccda3b 100644
--- a/README-zh_CN.md
+++ b/README-zh_CN.md
@@ -97,6 +97,13 @@ Skills 会按以下优先级扫描:
- `deepseek-v4-flash`
- 任何其他 OpenAI 兼容模型
+## 架构和基准测试
+
+Armin Ronacher 在[《Better Models: Worse Tools》](https://lucumr.pocoo.org/2026/7/4/better-models-worse-tools/)中指出,工具 schema 不是「中立的」:模型(LLM)会继承训练和强化学习中形成的工具使用习惯,因此可能在某个主流 harness 中表现很好,却在另一套工具形态下变得不稳定。这正是 Deep Code 的架构出发点:只为 DeepSeek 量身调优,从而让 harness 本身持续贴合 DeepSeek 的行为特点。
+
+Deep Code 的收益来自于工具约束、上下文管理、Agent Skills 和权限策略等多项设计叠加后的结果。[deepcode-qrcode-benchmark](https://github.com/qorzj/deepcode-qrcode-benchmark) 项目展示了在一个真实且有难度的 Python 需求上,Deep Code + DeepSeek + `/plan` 模式相较 Claude Code + DeepSeek 的组合具有效果优势。
+
+> 详见:[Deep Code 架构](docs/architecture.md)
## 常见问题
diff --git a/README.md b/README.md
index 39cd12bd..834a73e7 100644
--- a/README.md
+++ b/README.md
@@ -97,6 +97,13 @@ Skills 会按以下优先级扫描:
- `deepseek-v4-flash`
- 任何其他 OpenAI 兼容模型
+## 架构和基准测试
+
+Armin Ronacher 在[《Better Models: Worse Tools》](https://lucumr.pocoo.org/2026/7/4/better-models-worse-tools/)中指出,工具 schema 不是「中立的」:模型(LLM)会继承训练和强化学习中形成的工具使用习惯,因此可能在某个主流 harness 中表现很好,却在另一套工具形态下变得不稳定。这正是 Deep Code 的架构出发点:只为 DeepSeek 量身调优,从而让 harness 本身持续贴合 DeepSeek 的行为特点。
+
+Deep Code 的收益来自于工具约束、上下文管理、Agent Skills 和权限策略等多项设计叠加后的结果。[deepcode-qrcode-benchmark](https://github.com/qorzj/deepcode-qrcode-benchmark) 项目展示了在一个真实且有难度的 Python 需求上,Deep Code + DeepSeek + `/plan` 模式相较 Claude Code + DeepSeek 的组合具有效果优势。
+
+> 详见:[Deep Code 架构](docs/architecture.md)
## 常见问题
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 00000000..d1e0f59d
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,49 @@
+# Deep Code 架构:围绕DeepSeek模型构建的harness
+
+Coding Agent的质量不是由模型单独决定的,而是模型(LLM)与其执行框架(harness)共同构成的系统决定的。
+
+Deep Code的终极目标:在智能编码任务上,Deep Code 应当以比“Claude Code + DeepSeek”这套组合更低的成本,取得更好的效果。实现路径不是幻想用一个通用框架让所有模型都表现最佳,而是不断将框架适配最新的 DeepSeek 模型,让模型看到的工具形态、上下文布局、安全规则和恢复路径,都契合它实际的行为方式。
+
+## 这个目标为什么可行
+
+Armin Ronacher 的文章[《Better Models: Worse Tools》](https://lucumr.pocoo.org/2026/7/4/better-models-worse-tools/)指出了一个值得重视的事实:工具 schema 并非「中立」。模型不会把工具模式当作纯粹的抽象约定来遵守,而是带着训练和强化学习过程中形成的使用习惯去接触它。如果某个厂商主要针对一种主流框架训练模型,那么模型可能会非常擅长那个框架的工具生态,却在面对形态不同的工具时意外地不可靠。能力更强的模型可能形成更强的习惯,而更强的习惯会让它更排斥陌生的工具。
+
+这一观察正是 Deep Code 设计的核心依据。Claude Code 是一个为 Anthropic 模型优化的闭源框架,没有理由把 DeepSeek 当作头等对象来对待。而 Deep Code 的选择则是成为 DeepSeek 生态的一部分,它应该为 DeepSeek 量身调优,而不仅仅是“兼容”。
+
+## 核心设计一:通过 snippet 修复工具调用
+
+传统编辑工具往往要求模型提供文件路径以及大段的 `old_string` 和 `new_string`。这看起来直接,但在真实应用场景中,会有多种典型失败:模型可能编辑过时了的文件视图,匹配到错误的重复块,错误地带上行号,丢失缩进,过度替换,或者生成转义错误的 JSON。结果要么是工具调用失败,要么更糟,甚至产生一个看上去合理、实际错误的文件改动。
+
+Deep Code 给出的方案是片段(snippet)系统。`read` 工具除了返回文件内容,还会在文本文件上维护一个会话本地的文件状态,并在元数据中返回 `snippet_id`。`edit` 工具随后将这个 `snippet_id` 作为必填参数。片段携带了文件路径、行范围、预览、版本和范围类型信息。
+
+这重塑了编辑的约定:文件必须先被读过才能编辑,片段必须在当前会话中,文件自读取后未被改动,替换只在片段范围内搜索,非唯一匹配会返回候选片段而不是直接猜测,批量替换可以要求声明预期出现次数。
+
+这是一种理解模型行为、而非放任模型的修复策略。它不强求模型在压力下保持完美,而是让正确操作更容易表达,同时让框架掌握足以发现歧义的局部信息。在接口校验上它保持严格,但在编码智能体常见的、可恢复的文本错误上又给予宽容,使智能体在意图清晰时可以继续向前推进。
+
+内置工具有意保持少而精:`bash`、`read`、`write`、`edit`、`AskUserQuestion`、`UpdatePlan` 和 `WebSearch`,外部 MCP 工具则动态挂载。这是一个刻意的设计决定,它降低了模式的不确定性,让权限分析变得可行,也给了模型一套可预期、可重复的操作语言。
+
+## 核心设计二:缓存感知的上下文管理
+
+第二个核心设计是用上下文缓存控制成本。DeepSeek 的上下文缓存默认启用,当后续请求完全复用了已缓存的前缀单元,就能命中缓存,并在返回值中给出缓存命中与未命中的token数。这是一套尽力而为的系统,但它确实奖励那些稳定的重复前缀。
+
+Deep Code 的会话架构正是围绕这一特性设计的,而且不需要用户刻意配合。系统提示、工具文档、默认技能、运行时上下文和项目说明,这些稳定内容都被放在易变的用户内容之前。会话消息以 JSONL 持久化,并能被一致地重放。工具调用与工具结果的配对在转换时会被修复,包括中断的工具调用,以保证发回给模型的对话始终保持结构有效。
+
+## 核心设计三:以Agent Skills为核心的上下文工程
+
+一个编码智能体不该把所有的知识都塞进上下文里,那样会污染上下文、推高成本,还会削弱指令的优先级。与此同时,很多任务又确实需要可复用的知识:代码审查流程、领域约定、框架特定模式等等。Agent Skills就是在这些知识真正需要时,按需加载的机制。
+
+自动匹配同样借助了模型本身:系统将候选技能的名称和描述发给模型,由模型返回应匹配的技能。已加载的技能不会重复加载,技能也可以声明不参与隐式调用。这一设计让基础框架保持精干,同时又允许丰富的任务特定行为,也使得技能得以跨工具移植。
+
+更深层的架构含义是,技能并非传统意义上的插件,而是被结构化的上下文。它让框架来决定何时把指令、示例、模板、脚本和参考文件引入对话。对于泛化能力强但并非完美的模型来说,这恰恰是最合适的抽象:让默认环境保持干净,在真正能产生帮助的时刻,再注入精准的先验知识。
+
+## 核心设计四:基于副作用分类的权限系统
+
+智能编码必然伴随真实的副作用:读写文件、运行 Shell 命令、访问网络、调用外部工具。一个只提供“全自动模式”的框架不安全,事事弹窗询问又太慢。Deep Code 的创新是引入基于副作用分类的作用域策略。
+
+权限系统定义了具体的作用域,例如目录内外的读、写、删除,Git 日志的查询与修改,网络,MCP 等。`bash` 工具要求模型声明本次操作的副作用,文件工具则根据路径直接分类。
+
+这不仅仅是安全功能,它本身就是智能体质量的一部分。权限给模型提供了一个可预测的操作边界,低风险工作可以快速推进,高风险动作则会停下来。它也让命令行为变得可审查:一条命令不只是待执行的文本,而是文本、所声明的副作用以及策略决策的组合。
+
+## 基准测试
+
+Deep Code 的优势并非来自某个单点妙招,而是源自一系列决策的叠加效应。[deepcode-qrcode-benchmark](https://github.com/qorzj/deepcode-qrcode-benchmark)项目展示了在一个真实且有难度的Python需求上,Deep Code+DeepSeek+`/plan`模式的组合总是能够胜过Claude Code+DeepSeek的组合。
diff --git a/docs/architecture_en.md b/docs/architecture_en.md
new file mode 100644
index 00000000..9af59db2
--- /dev/null
+++ b/docs/architecture_en.md
@@ -0,0 +1,49 @@
+# Deep Code Architecture: A Harness Built Around the DeepSeek Model
+
+The quality of a Coding Agent is not determined by the model alone. It is determined by the system formed by the model (LLM) and its execution framework (harness).
+
+Deep Code's ultimate goal is this: on intelligent coding tasks, Deep Code should achieve better results than the "Claude Code + DeepSeek" combination, while costing less. The path to that goal is not to imagine that one generic framework can make every model perform at its best. Instead, it is to continuously adapt the framework to the latest DeepSeek models, so that the tool shapes, context layout, safety rules, and recovery paths seen by the model all match how it actually behaves.
+
+## Why This Goal Is Feasible
+
+Armin Ronacher's article ["Better Models: Worse Tools"](https://lucumr.pocoo.org/2026/7/4/better-models-worse-tools/) points out an important fact: tool schemas are not "neutral." Models do not treat tool schemas as pure abstract contracts to follow. They approach them with usage habits formed during training and reinforcement learning. If a vendor primarily trains a model against one mainstream framework, the model may become very good at that framework's tool ecosystem, yet unexpectedly unreliable when facing tools with a different shape. A more capable model may form stronger habits, and stronger habits can make it more resistant to unfamiliar tools.
+
+This observation is the core basis for Deep Code's design. Claude Code is a closed-source framework optimized for Anthropic models, and it has no reason to treat DeepSeek as a first-class target. Deep Code makes a different choice: it becomes part of the DeepSeek ecosystem. It should be tuned specifically for DeepSeek, not merely be "compatible" with it.
+
+## Core Design 1: Repairing Tool Calls Through Snippets
+
+Traditional editing tools often require the model to provide a file path plus large `old_string` and `new_string` payloads. That looks straightforward, but in real-world scenarios it has many typical failure modes: the model may edit a stale view of a file, match the wrong repeated block, accidentally include line numbers, lose indentation, over-replace text, or generate JSON with incorrect escaping. The result is either a failed tool call or, worse, a plausible-looking but actually incorrect file change.
+
+Deep Code's solution is the snippet system. In addition to returning file contents, the `read` tool maintains session-local file state for text files and returns a `snippet_id` in metadata. The `edit` tool then requires this `snippet_id` as a mandatory parameter. A snippet carries the file path, line range, preview, version, and scope type.
+
+This reshapes the editing contract: a file must be read before it can be edited; the snippet must exist in the current session; the file must not have changed since it was read; replacement is searched only within the snippet's scope; non-unique matches return candidate snippets instead of guessing; and bulk replacement can require declaring the expected number of occurrences.
+
+This is a repair strategy that understands model behavior rather than indulging it. It does not require the model to stay perfect under pressure. Instead, it makes the correct operation easier to express while giving the framework enough local information to detect ambiguity. It remains strict at the interface validation layer, but is tolerant of common, recoverable text mistakes made by coding agents, allowing the agent to keep moving forward when the intent is clear.
+
+The built-in tools are intentionally small and focused: `bash`, `read`, `write`, `edit`, `AskUserQuestion`, `UpdatePlan`, and `WebSearch`, while external MCP tools are mounted dynamically. This is a deliberate design decision. It reduces schema uncertainty, makes permission analysis feasible, and gives the model a predictable and repeatable action language.
+
+## Core Design 2: Cache-Aware Context Management
+
+The second core design is using context caching to control cost. DeepSeek's context cache is enabled by default. When a later request fully reuses a cached prefix unit, it can hit the cache, and the response reports the number of cache-hit and cache-miss tokens. This is a best-effort system, but it does reward stable repeated prefixes.
+
+Deep Code's session architecture is designed around this property, without requiring users to cooperate manually. Stable content such as the system prompt, tool documentation, default skills, runtime context, and project instructions is placed before volatile user content. Session messages are persisted as JSONL and can be replayed consistently. Tool-call and tool-result pairings are repaired during conversion, including interrupted tool calls, so the conversation sent back to the model always remains structurally valid.
+
+## Core Design 3: Context Engineering Centered on Agent Skills
+
+A coding agent should not stuff all knowledge into its context. Doing so pollutes the context, raises cost, and weakens instruction priority. At the same time, many tasks do require reusable knowledge: code review workflows, domain conventions, framework-specific patterns, and so on. Agent Skills are the mechanism for loading this knowledge on demand, exactly when it is needed.
+
+Automatic matching also uses the model itself: the system sends candidate skill names and descriptions to the model, and the model returns the skills that should match. Already-loaded skills are not loaded again, and skills can declare that they should not participate in implicit invocation. This design keeps the base framework lean while allowing rich task-specific behavior, and it also makes skills portable across tools.
+
+The deeper architectural meaning is that skills are not plugins in the traditional sense. They are structured context. They let the framework decide when to introduce instructions, examples, templates, scripts, and reference files into the conversation. For models with strong but imperfect generalization, this is exactly the right abstraction: keep the default environment clean, and inject precise prior knowledge only at the moment when it is truly helpful.
+
+## Core Design 4: A Permission System Based on Side-Effect Classification
+
+Intelligent coding inevitably involves real side effects: reading and writing files, running shell commands, accessing the network, and calling external tools. A framework that only provides a "fully automatic mode" is unsafe, while prompting for everything is too slow. Deep Code's innovation is to introduce a scope policy based on side-effect classification.
+
+The permission system defines concrete scopes, such as reading, writing, and deleting inside or outside the working directory; querying and mutating Git history; network access; MCP; and so on. The `bash` tool requires the model to declare the side effects of the current operation, while file tools are classified directly based on their paths.
+
+This is not merely a safety feature. It is itself part of agent quality. Permissions give the model a predictable operating boundary: low-risk work can proceed quickly, while high-risk actions stop for confirmation. It also makes command behavior auditable: a command is not just text waiting to be executed, but a combination of text, declared side effects, and a policy decision.
+
+## Benchmark
+
+Deep Code's advantage does not come from a single clever trick. It comes from the compounding effect of a series of decisions. The [deepcode-qrcode-benchmark](https://github.com/qorzj/deepcode-qrcode-benchmark) project shows that on a real and challenging Python requirement, the combination of Deep Code + DeepSeek + `/plan` mode can consistently outperform the combination of Claude Code + DeepSeek.
diff --git a/packages/cli/src/cli-args.ts b/packages/cli/src/cli-args.ts
index b86eda41..5a1d6f44 100644
--- a/packages/cli/src/cli-args.ts
+++ b/packages/cli/src/cli-args.ts
@@ -47,6 +47,7 @@ const EPILOG = [
"Inside the TUI:",
" enter Send the prompt",
" shift+enter Insert a newline",
+ " shift+tab Cycle Plan Mode for the next submitted prompt",
" home/end Move within the current line",
" alt+left/right Move by word",
" ctrl+w Delete the previous word",
@@ -56,6 +57,7 @@ const EPILOG = [
" / Open the skills/commands menu",
" /skills List available skills",
" /model Select model, thinking mode and effort control",
+ " /plan Switch the input to Plan Mode",
" /new Start a fresh conversation",
" /init Initialize an AGENTS.md file with instructions for LLM",
" /resume Pick a previous conversation to continue",
diff --git a/packages/cli/src/tests/prompt-input-keys.test.ts b/packages/cli/src/tests/prompt-input-keys.test.ts
index 0c5773cf..2b3e3791 100644
--- a/packages/cli/src/tests/prompt-input-keys.test.ts
+++ b/packages/cli/src/tests/prompt-input-keys.test.ts
@@ -22,6 +22,9 @@ import {
renderBufferWithCursor,
buildInitPromptSubmission,
buildPromptDraftFromSessionMessage,
+ extractProposedPlan,
+ getImplementationPrompt,
+ getPlanImplementationChoice,
disableTerminalExtendedKeys,
enableTerminalExtendedKeys,
EMPTY_BUFFER,
@@ -140,6 +143,28 @@ test("parseTerminalInput recognizes shifted return sequences", () => {
assert.equal(key.meta, false);
});
+test("parseTerminalInput recognizes shift+tab for Plan Mode cycling", () => {
+ const { input, key } = parseTerminalInput("\u001B[Z");
+ assert.equal(input, "");
+ assert.equal(key.tab, true);
+ assert.equal(key.shift, true);
+});
+
+test("extractProposedPlan only returns a complete non-empty plan", () => {
+ assert.equal(extractProposedPlan("\nPlan\n"), "Plan");
+ assert.equal(extractProposedPlan("Plan"), null);
+ assert.equal(extractProposedPlan("\n"), null);
+});
+
+test("getImplementationPrompt uses Chinese only above five full-width punctuation marks", () => {
+ assert.equal(getImplementationPrompt(",、;。;"), "Implement the plan.");
+ assert.equal(getImplementationPrompt(",、;。;。"), "实现此方案。");
+});
+
+test("getPlanImplementationChoice treats escape as staying in Plan Mode", () => {
+ assert.equal(getPlanImplementationChoice("", { escape: true, return: false }, 0), "stay");
+});
+
test("prompt return key action submits on plain enter", () => {
const { key } = parseTerminalInput("\r");
assert.equal(getPromptReturnKeyAction(key), "submit");
diff --git a/packages/cli/src/tests/slash-commands.test.ts b/packages/cli/src/tests/slash-commands.test.ts
index 420e5a48..94ce95a4 100644
--- a/packages/cli/src/tests/slash-commands.test.ts
+++ b/packages/cli/src/tests/slash-commands.test.ts
@@ -22,6 +22,7 @@ test("buildSlashCommands prefixes skills before built-ins", () => {
assert.deepEqual(builtinNames, [
"skills",
"model",
+ "plan",
"new",
"init",
"resume",
@@ -98,6 +99,13 @@ test("findExactSlashCommand returns built-in /model", () => {
assert.equal(item?.kind, "model");
});
+test("findExactSlashCommand returns built-in /plan", () => {
+ const items = buildSlashCommands(skills);
+ const item = findExactSlashCommand(items, "/plan");
+ assert.ok(item);
+ assert.equal(item?.kind, "plan");
+});
+
test("findExactSlashCommand returns built-in /raw", () => {
const items = buildSlashCommands(skills);
const item = findExactSlashCommand(items, "/raw");
diff --git a/packages/cli/src/ui/core/slash-commands.ts b/packages/cli/src/ui/core/slash-commands.ts
index ba5ae6ec..38646611 100644
--- a/packages/cli/src/ui/core/slash-commands.ts
+++ b/packages/cli/src/ui/core/slash-commands.ts
@@ -4,6 +4,7 @@ export type SlashCommandKind =
| "skill"
| "skills"
| "model"
+ | "plan"
| "new"
| "init"
| "resume"
@@ -35,6 +36,12 @@ export const BUILTIN_SLASH_COMMANDS: SlashCommandItem[] = [
label: "/model",
description: "Select model, thinking mode and effort control",
},
+ {
+ kind: "plan",
+ name: "plan",
+ label: "/plan",
+ description: "Switch the input to Plan Mode",
+ },
{
kind: "new",
name: "new",
diff --git a/packages/cli/src/ui/index.ts b/packages/cli/src/ui/index.ts
index 65415464..53f281af 100644
--- a/packages/cli/src/ui/index.ts
+++ b/packages/cli/src/ui/index.ts
@@ -15,6 +15,12 @@ export {
} from "./hooks/cursor";
export { default as AppContainer } from "./views/AppContainer";
export { AskUserQuestionPrompt } from "./views/AskUserQuestionPrompt";
+export {
+ PlanImplementationPrompt,
+ extractProposedPlan,
+ getImplementationPrompt,
+ getPlanImplementationChoice,
+} from "./views/PlanImplementationPrompt";
export { MessageView } from "./components";
export { parseDiffPreview } from "./components/MessageView/utils";
export {
diff --git a/packages/cli/src/ui/views/App.tsx b/packages/cli/src/ui/views/App.tsx
index 3b2886cd..eb30dd96 100644
--- a/packages/cli/src/ui/views/App.tsx
+++ b/packages/cli/src/ui/views/App.tsx
@@ -20,6 +20,7 @@ import {
formatAskUserQuestionAnswers,
} from "../core/ask-user-question";
import { PermissionPrompt, type PermissionPromptResult } from "./PermissionPrompt";
+import { PlanImplementationPrompt, extractProposedPlan, getImplementationPrompt } from "./PlanImplementationPrompt";
import { buildExitSummaryText, buildResumeHintText } from "../exit-summary";
import { RawMode, useRawModeContext } from "../contexts";
import { renderMessageToStdout } from "../components/MessageView/utils";
@@ -133,6 +134,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp
const [nowTick, setNowTick] = useState(0);
const [mcpStatuses, setMcpStatuses] = useState>([]);
const [showProcessStdout, setShowProcessStdout] = useState(false);
+ const [planMode, setPlanMode] = useState(false);
+ const [pendingPlanImplementation, setPendingPlanImplementation] = useState(null);
rawModeRef.current = mode;
messagesRef.current = messages;
@@ -253,6 +256,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp
setActiveStatus(null);
setActiveAskPermissions(undefined);
setPendingPermissionReply(null);
+ setPlanMode(false);
+ setPendingPlanImplementation(null);
setDismissedQuestionIds(new Set());
await resetStaticView([]);
await refreshSkills();
@@ -369,6 +374,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp
submission.selectedSkills && submission.selectedSkills.length > 0 ? submission.selectedSkills : undefined,
permissions: submission.permissions,
alwaysAllows: submission.alwaysAllows,
+ planMode: submission.planMode ?? planMode,
};
const activeSessionId = sessionManager.getActiveSessionId();
const permissionReply =
@@ -404,6 +410,12 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp
}
await refreshSkills();
refreshSessionsList();
+ const completedSession = sessionManager.getSession(sessionManager.getActiveSessionId() ?? "");
+ const proposedPlan =
+ prompt.planMode && completedSession?.status === "completed"
+ ? extractProposedPlan(completedSession.assistantReply)
+ : null;
+ setPendingPlanImplementation(proposedPlan);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrorLine(message);
@@ -425,6 +437,7 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp
refreshSessionsList,
navigateToSubView,
resetToWelcome,
+ planMode,
]
);
@@ -496,6 +509,25 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp
[handlePrompt]
);
+ const handlePlanImplementationChoice = useCallback(
+ (choice: "implement" | "stay" | "default") => {
+ const proposedPlan = pendingPlanImplementation;
+ setPendingPlanImplementation(null);
+ if (choice === "stay") {
+ return;
+ }
+ setPlanMode(false);
+ if (choice === "implement" && proposedPlan) {
+ handleSubmit({
+ text: getImplementationPrompt(proposedPlan),
+ imageUrls: [],
+ planMode: false,
+ });
+ }
+ },
+ [handleSubmit, pendingPlanImplementation]
+ );
+
const handleExitShortcut = useCallback(() => {
handleExit({ showCommand: false, showSummary: false });
}, [handleExit]);
@@ -517,6 +549,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp
setRunningProcesses(session?.processes ?? null);
setActiveStatus(session?.status ?? null);
setActiveAskPermissions(session?.askPermissions);
+ setPlanMode(session?.planMode === true);
+ setPendingPlanImplementation(null);
if (pendingPermissionReply && pendingPermissionReply.sessionId !== sessionId) {
setPendingPermissionReply(null);
}
@@ -965,6 +999,8 @@ function App({ projectRoot, initialPrompt, resumeSessionId, onRestart }: AppProp
onSubmit={handlePermissionResult}
onCancel={handlePermissionCancel}
/>
+ ) : pendingPlanImplementation && !busy ? (
+
) : isExiting ? null : (
)}
diff --git a/packages/cli/src/ui/views/PlanImplementationPrompt.tsx b/packages/cli/src/ui/views/PlanImplementationPrompt.tsx
new file mode 100644
index 00000000..e97b361a
--- /dev/null
+++ b/packages/cli/src/ui/views/PlanImplementationPrompt.tsx
@@ -0,0 +1,88 @@
+import React, { useEffect, useState } from "react";
+import { Box, Text } from "ink";
+import { useTerminalInput } from "../hooks";
+import type { InputKey } from "../hooks";
+
+type PlanImplementationChoice = "implement" | "stay" | "default";
+
+type Props = {
+ onSelect: (choice: PlanImplementationChoice) => void;
+};
+
+const CHOICES: Array<{ value: PlanImplementationChoice; label: string }> = [
+ { value: "implement", label: "implement this plan" },
+ { value: "stay", label: "stay in Plan mode" },
+ { value: "default", label: "switch to Default mode" },
+];
+
+/** Return only a complete proposed plan, so historical or partial tags cannot trigger the chooser. */
+export function extractProposedPlan(reply: string | null): string | null {
+ if (!reply) {
+ return null;
+ }
+ const match = reply.match(/\s*([\s\S]*?\S[\s\S]*?)\s*<\/proposed_plan>/);
+ return match?.[1] ?? null;
+}
+
+export function getImplementationPrompt(plan: string): string {
+ const fullWidthPunctuationCount = (plan.match(/[,、;。]/g) ?? []).length;
+ return fullWidthPunctuationCount > 5 ? "实现此方案。" : "Implement the plan.";
+}
+
+export function getPlanImplementationChoice(
+ input: string,
+ key: Pick,
+ cursor: number
+): PlanImplementationChoice | null {
+ if (key.escape) {
+ return "stay";
+ }
+ if (input && /^[1-3]$/.test(input)) {
+ return CHOICES[Number(input) - 1]!.value;
+ }
+ return key.return ? CHOICES[cursor]!.value : null;
+}
+
+export function PlanImplementationPrompt({ onSelect }: Props): React.ReactElement {
+ const [cursor, setCursor] = useState(0);
+
+ useEffect(() => {
+ setCursor(0);
+ }, []);
+
+ useTerminalInput((input, key) => {
+ const choice = getPlanImplementationChoice(input, key, cursor);
+ if (choice) {
+ onSelect(choice);
+ return;
+ }
+ if (key.upArrow) {
+ setCursor((value) => Math.max(0, value - 1));
+ return;
+ }
+ if (key.downArrow) {
+ setCursor((value) => Math.min(CHOICES.length - 1, value + 1));
+ return;
+ }
+ });
+
+ return (
+
+
+ Plan ready
+
+ Choose what to do next:
+
+ {CHOICES.map((choice, index) => (
+
+ {index === cursor ? "> " : " "}
+ {index + 1}. {choice.label}
+
+ ))}
+
+
+ 1-3 select · ↑/↓ move · Enter select
+
+
+ );
+}
diff --git a/packages/cli/src/ui/views/PromptInput.tsx b/packages/cli/src/ui/views/PromptInput.tsx
index 3f548def..799dec7a 100644
--- a/packages/cli/src/ui/views/PromptInput.tsx
+++ b/packages/cli/src/ui/views/PromptInput.tsx
@@ -72,6 +72,7 @@ export type PromptSubmission = {
selectedSkills?: SkillInfo[];
permissions?: UserToolPermission[];
alwaysAllows?: PermissionScope[];
+ planMode?: boolean;
command?: "new" | "resume" | "continue" | "undo" | "mcp" | "exit";
};
@@ -96,9 +97,11 @@ type Props = {
promptDraft?: PromptDraft | null;
statusLineSegments?: StatusSegment[];
statusLineSeparator?: string;
+ planMode: boolean;
onSubmit: (submission: PromptSubmission) => void;
onModelConfigChange: (selection: ModelConfigSelection) => string | Promise;
onRawModeChange?: (mode: string) => void;
+ onPlanModeChange: (enabled: boolean) => void;
onInterrupt: () => void;
onToggleProcessStdout?: () => void;
onExitShortcut?: () => void;
@@ -129,12 +132,14 @@ export const PromptInput = React.memo(function PromptInput({
promptDraft,
statusLineSegments,
statusLineSeparator,
+ planMode,
onSubmit,
onModelConfigChange,
onInterrupt,
onToggleProcessStdout,
onExitShortcut,
onRawModeChange,
+ onPlanModeChange,
}: Props): React.ReactElement {
const { stdout } = useStdout();
const inputTextRef = useRef(null);
@@ -226,9 +231,10 @@ export const PromptInput = React.memo(function PromptInput({
screenWidth,
cursorLayoutKey ?? "default",
imageUrls.length,
+ planMode ? "plan-mode" : "default-mode",
selectedSkills.map((skill) => skill.name).join("\u001F"),
].join("\u001E"),
- [cursorLayoutKey, imageUrls.length, screenWidth, selectedSkills]
+ [cursorLayoutKey, imageUrls.length, planMode, screenWidth, selectedSkills]
);
useTerminalFocusReporting(stdout, !disabled);
useTerminalExtendedKeys(stdout, !disabled);
@@ -429,6 +435,11 @@ export const PromptInput = React.memo(function PromptInput({
const returnAction = getPromptReturnKeyAction(key);
const isPlainReturn = returnAction === "submit";
+ if (key.shift && key.tab) {
+ onPlanModeChange(!planMode);
+ return;
+ }
+
if (showFileMentionMenu) {
if (key.upArrow || key.downArrow || key.tab || returnAction === "submit") {
return;
@@ -678,6 +689,11 @@ export const PromptInput = React.memo(function PromptInput({
setShowModelDropdown(true);
return;
}
+ if (item.kind === "plan") {
+ clearSlashToken();
+ onPlanModeChange(true);
+ return;
+ }
if (item.kind === "raw") {
clearSlashToken();
setOpenRawModelDropdown(true);
@@ -744,6 +760,7 @@ export const PromptInput = React.memo(function PromptInput({
text: expandPasteMarkers(buffer.text, pastesRef.current),
imageUrls,
selectedSkills,
+ planMode,
});
resetPromptInput();
}
@@ -781,6 +798,12 @@ export const PromptInput = React.memo(function PromptInput({
(use /skills to edit)
) : null}
+ {planMode ? (
+
+ 💡 Plan mode
+ (shift+tab to cycle)
+
+ ) : null}
{/* Input */}
;
+ forceAskScopes?: readonly PermissionScope[];
readPermissionExemptPaths?: string[];
resolveSnippetPath?: (sessionId: string, snippetId: string) => string | null | undefined;
};
@@ -163,10 +164,18 @@ export function computeToolCallPermissions(options: ComputeToolCallPermissionsOp
readPermissionExemptPaths: options.readPermissionExemptPaths,
resolveSnippetPath: options.resolveSnippetPath,
});
- const permission = evaluatePermissionScopes(request.scopes, options.settings);
+ const evaluatedPermission = evaluatePermissionScopes(request.scopes, options.settings);
+ const forcedAskScopes =
+ evaluatedPermission === "deny"
+ ? []
+ : getAllowedForcedAskScopes(request.scopes, options.settings, options.forceAskScopes);
+ const permission = forcedAskScopes.length > 0 ? "ask" : evaluatedPermission;
permissions.push({ toolCallId: toolCall.id, permission });
if (permission === "ask") {
- const askScopes = getPermissionScopesRequiringAsk(request.scopes, options.settings);
+ const askScopes = mergeAskScopes(
+ getPermissionScopesRequiringAsk(request.scopes, options.settings),
+ forcedAskScopes
+ );
askPermissions.push({
toolCallId: toolCall.id,
scopes: askScopes.length > 0 ? askScopes : request.scopes,
@@ -180,6 +189,25 @@ export function computeToolCallPermissions(options: ComputeToolCallPermissionsOp
return { permissions, askPermissions };
}
+function getAllowedForcedAskScopes(
+ scopes: AskPermissionScope[],
+ settings: Required | undefined,
+ forceAskScopes: readonly PermissionScope[] | undefined
+): PermissionScope[] {
+ if (!forceAskScopes?.length) {
+ return [];
+ }
+
+ return scopes.filter(
+ (scope): scope is PermissionScope =>
+ scope !== "unknown" && forceAskScopes.includes(scope) && evaluatePermissionScopes([scope], settings) === "allow"
+ );
+}
+
+function mergeAskScopes(existing: AskPermissionScope[], forced: PermissionScope[]): AskPermissionScope[] {
+ return [...existing, ...forced.filter((scope) => !existing.includes(scope))];
+}
+
export function describeToolPermissionRequest(options: {
sessionId: string;
projectRoot: string;
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 832d2444..33adf6f2 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -55,6 +55,7 @@ export {
getCompactPrompt,
getRuntimeContext,
getDefaultSkillPrompt,
+ getPlanModePrompt,
getExtensionRoot,
getTools,
buildSkillDocumentsPrompt,
diff --git a/packages/core/src/prompt.ts b/packages/core/src/prompt.ts
index dce34940..62171457 100644
--- a/packages/core/src/prompt.ts
+++ b/packages/core/src/prompt.ts
@@ -188,6 +188,16 @@ export function getDefaultSkillPrompt(options: DefaultSkillPromptOptions = {}):
return buildSkillDocumentsPrompt(skillDocs);
}
+/** Read the dedicated prompt used when a submitted turn enters Plan Mode. */
+export function getPlanModePrompt(): string {
+ const templatePath = path.join(getExtensionRoot(), "templates", "prompts", "plan.md");
+ try {
+ return fs.readFileSync(templatePath, "utf8").trim();
+ } catch {
+ return "";
+ }
+}
+
export function buildSkillDocumentsPrompt(skills: SkillPromptDocument[]): string {
const blocks = skills.map((skill) => renderSkillDocumentBlock(skill));
return `Use the skill documents below to assist the user:\n${blocks.join("\n\n")}`;
@@ -579,7 +589,7 @@ export function getTools(_options: PromptToolOptions = {}, externalTools: ToolDe
type: "function",
function: {
name: "read",
- description: "Read files from the filesystem (text, images, PDFs, notebooks).",
+ description: "Read files from the filesystem (text, images, notebooks).",
parameters: {
type: "object",
properties: {
@@ -595,10 +605,6 @@ export function getTools(_options: PromptToolOptions = {}, externalTools: ToolDe
type: "number",
description: "Number of lines to read",
},
- pages: {
- type: "string",
- description: 'Page range for PDF files (e.g., "1-5", "3", "10-20"). Only applicable to PDF files.',
- },
},
required: ["file_path"],
additionalProperties: false,
diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts
index 4483c67f..2101b8b6 100644
--- a/packages/core/src/session.ts
+++ b/packages/core/src/session.ts
@@ -14,6 +14,7 @@ import {
getCompactPrompt,
getDefaultSkillPrompt,
getExtensionRoot,
+ getPlanModePrompt,
getRuntimeContext,
getSystemPrompt,
getTools,
@@ -66,7 +67,15 @@ const PROJECT_CODE_HASH_LENGTH = 16;
const BACKGROUND_FAILURE_LOG_TAIL_CHARS = 4000;
const DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 128 * 1024;
const DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD = 512 * 1024;
-const PLAN_MODE_STATUS_MESSAGE = "/plan\n └ Set Plan Mode on. Awaiting .";
+const PLAN_MODE_ON_STATUS_MESSAGE = " └ Set Plan Mode on. Awaiting .";
+const PLAN_MODE_OFF_STATUS_MESSAGE = " └ Set Plan Mode off.";
+const PLAN_MODE_FORCE_ASK_SCOPES = [
+ "write-in-cwd",
+ "write-out-cwd",
+ "delete-in-cwd",
+ "delete-out-cwd",
+ "mutate-git-log",
+] as const satisfies readonly PermissionScope[];
type ChatCompletionDebugOptions = {
enabled?: boolean;
@@ -232,6 +241,7 @@ export type SessionEntry = {
updateTime: string;
processes: Map | null; // {pid: process info}
askPermissions?: AskPermissionRequest[];
+ planMode?: boolean;
};
export type SessionsIndex = {
@@ -282,6 +292,7 @@ export type UserPromptContent = {
skills?: SkillInfo[];
permissions?: UserToolPermission[];
alwaysAllows?: PermissionScope[];
+ planMode?: boolean;
};
export type SkillInfo = {
@@ -1046,9 +1057,6 @@ ${agentInstructions}
}
for (const skill of skills) {
- if (skill.name === "plan") {
- this.appendSessionMessage(sessionId, this.buildSystemMessage(sessionId, PLAN_MODE_STATUS_MESSAGE));
- }
if (skill.isLoaded) {
continue;
}
@@ -1118,6 +1126,7 @@ ${agentInstructions}
createTime: now,
updateTime: now,
processes: null,
+ planMode: Boolean(userPrompt.planMode),
};
index.entries.push(entry);
const sortedEntries = index.entries.slice().sort((a, b) => {
@@ -1163,6 +1172,8 @@ ${agentInstructions}
this.appendSessionMessage(sessionId, instructionsMessage);
}
+ this.appendPlanModeTransitionMessages(sessionId, false, Boolean(userPrompt.planMode));
+
this.recordUserPromptCheckpoint(sessionId);
const userMessage = this.buildUserMessage(sessionId, userPrompt);
this.appendSessionMessage(sessionId, userMessage);
@@ -1196,11 +1207,14 @@ ${agentInstructions}
inheritedPermissions: this.getResolvedSettings().permissions,
});
const now = new Date().toISOString();
+ const previousPlanMode = Boolean(this.getSession(sessionId)?.planMode);
+ const nextPlanMode = Boolean(userPrompt.planMode);
const updated = this.updateSessionEntry(sessionId, (entry) => ({
...entry,
status: "pending",
failReason: null,
askPermissions: undefined,
+ planMode: nextPlanMode,
updateTime: now,
}));
@@ -1209,6 +1223,8 @@ ${agentInstructions}
return;
}
+ this.appendPlanModeTransitionMessages(sessionId, previousPlanMode, nextPlanMode);
+
if (hasUserPermissionReplies(userPrompt) && this.hasTrailingPendingToolCalls(sessionId)) {
this.activeSessionId = sessionId;
await this.activateSession(sessionId, controller, userPrompt);
@@ -1407,6 +1423,7 @@ ${agentInstructions}
projectRoot: this.projectRoot,
toolCalls,
settings: this.getResolvedSettings().permissions,
+ forceAskScopes: this.getSession(sessionId)?.planMode ? PLAN_MODE_FORCE_ASK_SCOPES : undefined,
readPermissionExemptPaths: this.getSkillScanRoots().map((entry) => entry.root),
resolveSnippetPath: (id, snippetId) => getSnippet(id, snippetId)?.filePath,
})
@@ -2090,6 +2107,23 @@ ${agentInstructions}
};
}
+ private appendPlanModeTransitionMessages(sessionId: string, wasEnabled: boolean, isEnabled: boolean): void {
+ if (wasEnabled === isEnabled) {
+ return;
+ }
+
+ if (isEnabled) {
+ const prompt = getPlanModePrompt();
+ if (prompt) {
+ this.appendSessionMessage(sessionId, this.buildSystemMessage(sessionId, prompt));
+ }
+ this.appendSessionMessage(sessionId, this.buildSystemMessage(sessionId, PLAN_MODE_ON_STATUS_MESSAGE));
+ return;
+ }
+
+ this.appendSessionMessage(sessionId, this.buildSystemMessage(sessionId, PLAN_MODE_OFF_STATUS_MESSAGE));
+ }
+
private renderInitCommandPrompt(): string {
const templatePath = path.join(getExtensionRoot(), "templates", "prompts", "init_command.md.ejs");
const template = fs.readFileSync(templatePath, "utf8");
@@ -2346,6 +2380,7 @@ ${agentInstructions}
skills: prompt.skills ? prompt.skills.map((skill) => ({ ...skill })) : undefined,
permissions: prompt.permissions ? prompt.permissions.map((permission) => ({ ...permission })) : undefined,
alwaysAllows: prompt.alwaysAllows ? [...prompt.alwaysAllows] : undefined,
+ planMode: prompt.planMode,
};
}
@@ -2752,6 +2787,7 @@ ${agentInstructions}
updateTime: typeof value.updateTime === "string" ? value.updateTime : new Date().toISOString(),
processes: this.deserializeProcesses(value.processes),
askPermissions: normalizeAskPermissions(value.askPermissions),
+ planMode: value.planMode === true,
};
}
diff --git a/packages/core/src/tests/permissions.test.ts b/packages/core/src/tests/permissions.test.ts
index fd3b676a..1a3e2697 100644
--- a/packages/core/src/tests/permissions.test.ts
+++ b/packages/core/src/tests/permissions.test.ts
@@ -170,6 +170,103 @@ test("computeToolCallPermissions only asks for scopes not already allowed", () =
);
});
+test("computeToolCallPermissions temporarily upgrades allowed forced scopes to ask", () => {
+ const projectRoot = createTempDir("deepcode-permissions-force-ask-workspace-");
+ const forcedScopes: PermissionScope[] = [
+ "write-in-cwd",
+ "write-out-cwd",
+ "delete-in-cwd",
+ "delete-out-cwd",
+ "mutate-git-log",
+ ];
+ const plan = computeToolCallPermissions({
+ sessionId: "session-1",
+ projectRoot,
+ forceAskScopes: forcedScopes,
+ settings: {
+ allow: ["write-in-cwd", "write-out-cwd", "delete-out-cwd", "mutate-git-log"] as PermissionScope[],
+ deny: ["delete-in-cwd"] as PermissionScope[],
+ ask: [] as PermissionScope[],
+ defaultMode: "allowAll" as const,
+ },
+ toolCalls: [
+ {
+ id: "call-write-in",
+ type: "function",
+ function: { name: "write", arguments: JSON.stringify({ file_path: path.join(projectRoot, "file.txt") }) },
+ },
+ {
+ id: "call-write-out",
+ type: "function",
+ function: { name: "write", arguments: JSON.stringify({ file_path: "/tmp/file.txt" }) },
+ },
+ {
+ id: "call-delete-out",
+ type: "function",
+ function: {
+ name: "bash",
+ arguments: JSON.stringify({
+ command: "rm /tmp/file.txt",
+ sideEffects: ["delete-out-cwd"],
+ }),
+ },
+ },
+ {
+ id: "call-mutate-git",
+ type: "function",
+ function: {
+ name: "bash",
+ arguments: JSON.stringify({ command: "git commit --allow-empty -m test", sideEffects: ["mutate-git-log"] }),
+ },
+ },
+ {
+ id: "call-delete-in",
+ type: "function",
+ function: {
+ name: "bash",
+ arguments: JSON.stringify({ command: "rm file.txt", sideEffects: ["delete-in-cwd"] }),
+ },
+ },
+ ],
+ });
+
+ assert.deepEqual(plan.permissions, [
+ { toolCallId: "call-write-in", permission: "ask" },
+ { toolCallId: "call-write-out", permission: "ask" },
+ { toolCallId: "call-delete-out", permission: "ask" },
+ { toolCallId: "call-mutate-git", permission: "ask" },
+ { toolCallId: "call-delete-in", permission: "deny" },
+ ]);
+ assert.deepEqual(
+ plan.askPermissions.map((item) => ({ id: item.toolCallId, scopes: item.scopes })),
+ [
+ { id: "call-write-in", scopes: ["write-in-cwd"] },
+ { id: "call-write-out", scopes: ["write-out-cwd"] },
+ { id: "call-delete-out", scopes: ["delete-out-cwd"] },
+ { id: "call-mutate-git", scopes: ["mutate-git-log"] },
+ ]
+ );
+
+ const defaultPlan = computeToolCallPermissions({
+ sessionId: "session-1",
+ projectRoot,
+ settings: {
+ allow: ["write-in-cwd"] as PermissionScope[],
+ deny: [] as PermissionScope[],
+ ask: [] as PermissionScope[],
+ defaultMode: "allowAll" as const,
+ },
+ toolCalls: [
+ {
+ id: "call-default",
+ type: "function",
+ function: { name: "write", arguments: JSON.stringify({ file_path: path.join(projectRoot, "file.txt") }) },
+ },
+ ],
+ });
+ assert.deepEqual(defaultPlan.permissions, [{ toolCallId: "call-default", permission: "allow" }]);
+});
+
test("computeToolCallPermissions allows read tool calls under skill scan paths", () => {
const projectRoot = createTempDir("deepcode-permissions-skill-read-workspace-");
const home = createTempDir("deepcode-permissions-skill-read-home-");
diff --git a/packages/core/src/tests/prompt.test.ts b/packages/core/src/tests/prompt.test.ts
index 6b474c1e..173230cb 100644
--- a/packages/core/src/tests/prompt.test.ts
+++ b/packages/core/src/tests/prompt.test.ts
@@ -7,6 +7,7 @@ import { fileURLToPath } from "url";
import {
buildSkillDocumentsPrompt,
getDefaultSkillPrompt,
+ getPlanModePrompt,
getRuntimeContext,
getSystemPrompt,
getTools,
@@ -54,6 +55,12 @@ test("getTools requires bash sideEffects permission scopes", () => {
assert.equal(runInBackground.type, "boolean");
});
+test("getTools does not expose the unused PDF pages parameter", () => {
+ const tool = getTools().find((candidate) => candidate.function.name === "read");
+ assert.ok(tool);
+ assert.equal("pages" in tool.function.parameters.properties, false);
+});
+
test("getSystemPrompt always includes WebSearch docs", () => {
const prompt = getSystemPrompt("/tmp/project");
assert.equal(prompt.includes("## WebSearch"), true);
@@ -94,6 +101,12 @@ test("getDefaultSkillPrompt skips disabled default skills", () => {
assert.equal(prompt, "");
});
+test("getPlanModePrompt loads the dedicated Plan Mode template", () => {
+ const prompt = getPlanModePrompt();
+ assert.equal(prompt.includes("# Plan Mode (Conversational)"), true);
+ assert.equal(prompt.includes(""), true);
+});
+
test("buildSkillDocumentsPrompt excludes SKILL.md frontmatter metadata", () => {
const prompt = buildSkillDocumentsPrompt([
{
diff --git a/packages/core/src/tests/session.test.ts b/packages/core/src/tests/session.test.ts
index 57b981bb..d7d0b6fa 100644
--- a/packages/core/src/tests/session.test.ts
+++ b/packages/core/src/tests/session.test.ts
@@ -6,14 +6,15 @@ import * as os from "os";
import * as path from "path";
import { GitFileHistory } from "../common/file-history";
import { clearSessionState } from "../common/state";
-import { getProjectCode, SessionManager, type SessionMessage, type SkillInfo } from "../session";
+import { getProjectCode, SessionManager, type SessionMessage } from "../session";
const originalFetch = globalThis.fetch;
const originalConsoleWarn = console.warn;
const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
const tempDirs: string[] = [];
-const PLAN_MODE_STATUS_MESSAGE = "/plan\n └ Set Plan Mode on. Awaiting .";
+const PLAN_MODE_ON_STATUS_MESSAGE = " └ Set Plan Mode on. Awaiting .";
+const PLAN_MODE_OFF_STATUS_MESSAGE = " └ Set Plan Mode off.";
/** Set homedir in a cross-platform way (HOME on Unix, USERPROFILE on Windows). */
function setHomeDir(dir: string): void {
@@ -523,68 +524,69 @@ test("SessionManager resolves bundled skill prompts", () => {
assert.match(prompt, /# Skill Writer/);
});
-test("SessionManager appends plan mode status whenever the plan skill is selected", async () => {
- const workspace = createTempDir("deepcode-plan-skill-workspace-");
- const home = createTempDir("deepcode-plan-skill-home-");
+test("SessionManager persists Plan Mode and appends prompts only on mode transitions", async () => {
+ const workspace = createTempDir("deepcode-plan-matched-workspace-");
+ const home = createTempDir("deepcode-plan-matched-home-");
setHomeDir(home);
- const manager = createSessionManager(workspace, "machine-id-plan-skill");
- const planSkill = await getPlanSkill(manager);
-
- const sessionId = await manager.createSession({ text: "", skills: [planSkill] });
+ const manager = createMockedClientSessionManager(workspace, [
+ createChatResponse("planned", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }),
+ createChatResponse("still planning", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }),
+ createChatResponse("implementing", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }),
+ ]);
+ const sessionId = await manager.createSession({ text: "Plan this change", planMode: true });
let messages = manager.listSessionMessages(sessionId);
- assert.equal(countPlanModeStatusMessages(messages), 1);
- assert.equal(countLoadedSkillMessages(messages, "plan"), 1);
+ assert.equal(manager.getSession(sessionId)?.planMode, true);
+ assert.equal(messages.filter((message) => message.content === PLAN_MODE_ON_STATUS_MESSAGE).length, 1);
+ assert.equal(
+ messages.some((message) => message.content?.includes("# Plan Mode (Conversational)")),
+ true
+ );
+ assert.equal(messages.find((message) => message.role === "user")?.meta?.userPrompt?.planMode, true);
+
+ await manager.replySession(sessionId, { text: "Refine it", planMode: true });
+ messages = manager.listSessionMessages(sessionId);
+ assert.equal(messages.filter((message) => message.content === PLAN_MODE_ON_STATUS_MESSAGE).length, 1);
- await manager.replySession(sessionId, { text: "", skills: [planSkill] });
+ await manager.replySession(sessionId, { text: "Implement it", planMode: false });
messages = manager.listSessionMessages(sessionId);
- assert.equal(countPlanModeStatusMessages(messages), 2);
- assert.equal(countLoadedSkillMessages(messages, "plan"), 1);
+ assert.equal(manager.getSession(sessionId)?.planMode, false);
+ assert.equal(messages.filter((message) => message.content === PLAN_MODE_OFF_STATUS_MESSAGE).length, 1);
});
-test("SessionManager appends plan mode status when the plan skill is auto-matched", async () => {
- const workspace = createTempDir("deepcode-plan-matched-workspace-");
- const home = createTempDir("deepcode-plan-matched-home-");
+test("SessionManager excludes the former bundled plan skill and defaults legacy sessions to Default mode", async () => {
+ const workspace = createTempDir("deepcode-plan-legacy-workspace-");
+ const home = createTempDir("deepcode-plan-legacy-home-");
setHomeDir(home);
- const client = {
+ const manager = createSessionManager(workspace, "machine-id-plan-legacy");
+ assert.equal(
+ (await manager.listSkills()).some((skill) => skill.name === "plan"),
+ false
+ );
+
+ const sessionId = await manager.createSession({ text: "Default mode" });
+ const index = (manager as any).loadSessionsIndex();
+ delete index.entries.find((entry: { id: string }) => entry.id === sessionId).planMode;
+ (manager as any).saveSessionsIndex(index);
+ assert.equal(manager.getSession(sessionId)?.planMode, false);
+
+ const autoMatchManager = createMockedClientSessionManagerWithClient(workspace, {
chat: {
completions: {
- create: async (request: any) => {
- if (isSkillMatchingRequest(request)) {
- return createSkillMatchingResponse(["plan"]);
- }
- return createChatResponse("planned", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 });
- },
+ create: async (request: any) =>
+ isSkillMatchingRequest(request)
+ ? createSkillMatchingResponse(["plan"])
+ : createChatResponse("default reply", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }),
},
},
- };
- const manager = createMockedClientSessionManagerWithClient(workspace, client);
-
- const sessionId = await manager.createSession({ text: "Plan Mode for this change" });
- const messages = manager.listSessionMessages(sessionId);
- assert.equal(countPlanModeStatusMessages(messages), 1);
- assert.equal(countLoadedSkillMessages(messages, "plan"), 1);
-});
-
-test("SessionManager appends plan mode status for deferred permission prompts", async () => {
- const workspace = createTempDir("deepcode-plan-deferred-workspace-");
- const home = createTempDir("deepcode-plan-deferred-home-");
- setHomeDir(home);
-
- const manager = createSessionManager(workspace, "machine-id-plan-deferred");
- const sessionId = await manager.createSession({ text: "" });
- const planSkill = await getPlanSkill(manager);
-
- await (manager as any).appendDeferredPermissionPrompt(
- sessionId,
- { text: "", skills: [planSkill] },
- new AbortController()
+ });
+ const autoMatchSessionId = await autoMatchManager.createSession({ text: "Plan this feature" });
+ const autoMatchMessages = autoMatchManager.listSessionMessages(autoMatchSessionId);
+ assert.equal(
+ autoMatchMessages.some((message) => message.meta?.skill?.name === "plan"),
+ false
);
-
- const messages = manager.listSessionMessages(sessionId);
- assert.equal(countPlanModeStatusMessages(messages), 1);
- assert.equal(countLoadedSkillMessages(messages, "plan"), 1);
});
test("SessionManager excludes disabled skills by resolved skill name", async () => {
@@ -2292,6 +2294,54 @@ test("activateSession pauses for permission when a tool call requires ask", asyn
);
});
+test("activateSession temporarily asks before allowed writes in Plan Mode", async () => {
+ const workspace = createTempDir("deepcode-plan-permission-workspace-");
+ const home = createTempDir("deepcode-plan-permission-home-");
+ setHomeDir(home);
+
+ const manager = createPermissionSessionManager(
+ workspace,
+ [
+ {
+ choices: [
+ {
+ message: {
+ content: "",
+ tool_calls: [
+ {
+ id: "call-write",
+ type: "function",
+ function: {
+ name: "write",
+ arguments: JSON.stringify({ file_path: path.join(workspace, "plan.txt"), content: "planned" }),
+ },
+ },
+ ],
+ },
+ },
+ ],
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
+ },
+ ],
+ {
+ allow: ["write-in-cwd"],
+ deny: [],
+ ask: [],
+ defaultMode: "allowAll",
+ }
+ );
+
+ const sessionId = await manager.createSession({ text: "Plan this change", planMode: true });
+ const session = manager.getSession(sessionId);
+ const assistant = manager
+ .listSessionMessages(sessionId)
+ .find((message) => message.role === "assistant" && (message.messageParams as any)?.tool_calls);
+
+ assert.equal(session?.status, "ask_permission");
+ assert.deepEqual(session?.askPermissions?.[0]?.scopes, ["write-in-cwd"]);
+ assert.deepEqual(assistant?.meta?.permissions, [{ toolCallId: "call-write", permission: "ask" }]);
+});
+
test("SessionManager preserves permission_denied status when sessions are reloaded", async () => {
const workspace = createTempDir("deepcode-permission-denied-workspace-");
const home = createTempDir("deepcode-permission-denied-home-");
@@ -3572,16 +3622,6 @@ function createSessionManager(projectRoot: string, machineId: string): SessionMa
});
}
-async function getPlanSkill(manager: SessionManager): Promise {
- const planSkill = (await manager.listSkills()).find((skill) => skill.name === "plan");
- assert.ok(planSkill);
- return planSkill;
-}
-
-function countPlanModeStatusMessages(messages: SessionMessage[]): number {
- return messages.filter((message) => message.role === "system" && message.content === PLAN_MODE_STATUS_MESSAGE).length;
-}
-
function countLoadedSkillMessages(messages: SessionMessage[], skillName: string): number {
return messages.filter((message) => message.role === "system" && message.meta?.skill?.name === skillName).length;
}
diff --git a/packages/core/src/tests/tool-handlers.test.ts b/packages/core/src/tests/tool-handlers.test.ts
index 735c0274..d6483f7a 100644
--- a/packages/core/src/tests/tool-handlers.test.ts
+++ b/packages/core/src/tests/tool-handlers.test.ts
@@ -998,6 +998,24 @@ test("Read returns an acknowledgement for images and attaches the image as a fol
);
});
+test("Read reports PDFs as binary without attaching their contents", async () => {
+ const workspace = createTempWorkspace();
+ const filePath = path.join(workspace, "document.pdf");
+ fs.writeFileSync(filePath, "%PDF-1.7\n" + "x".repeat(1024 * 1024));
+
+ const readResult = await handleReadTool({ file_path: filePath }, createContext("pdf-read", workspace));
+
+ assert.equal(readResult.ok, true);
+ assert.equal(readResult.output, "WARNING: File is binary.");
+ assert.deepEqual(readResult.metadata, {
+ mime: "application/pdf",
+ encoding: "base64",
+ bytes: 1024 * 1024 + "%PDF-1.7\n".length,
+ pageCount: 0,
+ });
+ assert.equal(readResult.followUpMessages, undefined);
+});
+
function createContext(
sessionId: string,
projectRoot: string,
diff --git a/packages/core/src/tools/read-handler.ts b/packages/core/src/tools/read-handler.ts
index 3771a7e0..94be54b4 100644
--- a/packages/core/src/tools/read-handler.ts
+++ b/packages/core/src/tools/read-handler.ts
@@ -13,8 +13,6 @@ import {
const DEFAULT_LINE_LIMIT = 2000;
const MAX_LINE_LENGTH = 2000;
-const PDF_LARGE_PAGE_THRESHOLD = 10;
-const PDF_MAX_PAGE_RANGE = 20;
const LINE_NUMBER_WIDTH = 6;
const DEFAULT_GITIGNORE = [
"node_modules/",
@@ -41,12 +39,6 @@ const DEFAULT_GITIGNORE = [
"target/",
];
-type PageRange = {
- start: number;
- end: number;
- count: number;
-};
-
type TextReadResult = {
content: string;
output: string;
@@ -159,36 +151,8 @@ export async function handleReadTool(
}
if (ext === ".pdf") {
- const pagesParam = typeof args.pages === "string" ? args.pages.trim() : "";
const buffer = fs.readFileSync(filePath);
const pageCount = countPdfPages(buffer);
- const pageRange = pagesParam ? parsePageRange(pagesParam) : null;
-
- if (!pageRange && pageCount !== null && pageCount > PDF_LARGE_PAGE_THRESHOLD) {
- return {
- ok: false,
- name: "read",
- error: `PDF has ${pageCount} pages; provide "pages" to read a range.`,
- };
- }
-
- if (pageRange && pageRange.count > PDF_MAX_PAGE_RANGE) {
- return {
- ok: false,
- name: "read",
- error: `PDF page range exceeds ${PDF_MAX_PAGE_RANGE} pages.`,
- };
- }
-
- if (pageRange && pageCount !== null && pageRange.end > pageCount) {
- return {
- ok: false,
- name: "read",
- error: `PDF page range exceeds total page count (${pageCount}).`,
- };
- }
-
- const base64 = buffer.toString("base64");
markFileRead(context.sessionId, filePath, {
content: "",
timestamp: Math.floor(stat.mtimeMs),
@@ -197,13 +161,12 @@ export async function handleReadTool(
return {
ok: true,
name: "read",
- output: `data:application/pdf;base64,${base64}`,
+ output: "WARNING: File is binary.",
metadata: {
mime: "application/pdf",
encoding: "base64",
bytes: buffer.length,
pageCount,
- pages: pageRange ? `${pageRange.start}-${pageRange.end}` : null,
},
};
}
@@ -520,45 +483,6 @@ function countPdfPages(buffer: Buffer): number | null {
}
}
-function parsePageRange(input: string): PageRange {
- const trimmed = input.trim();
- if (!trimmed) {
- throw new Error("pages must be a non-empty string.");
- }
- if (trimmed.includes(",")) {
- throw new Error('pages must be a single range like "1-5" or "3".');
- }
-
- const parts = trimmed.split("-").map((part) => part.trim());
- if (parts.length === 1) {
- const value = parsePositiveInt(parts[0], "pages");
- return { start: value, end: value, count: 1 };
- }
-
- if (parts.length === 2) {
- const start = parsePositiveInt(parts[0], "pages");
- const end = parsePositiveInt(parts[1], "pages");
- if (end < start) {
- throw new Error("pages range end must be >= start.");
- }
- return { start, end, count: end - start + 1 };
- }
-
- throw new Error('pages must be a single range like "1-5" or "3".');
-}
-
-function parsePositiveInt(value: string, label: string): number {
- const numeric = Number(value);
- if (!Number.isFinite(numeric)) {
- throw new Error(`${label} must be a number.`);
- }
- const integer = Math.trunc(numeric);
- if (integer < 1) {
- throw new Error(`${label} must be >= 1.`);
- }
- return integer;
-}
-
function readNotebook(filePath: string): string {
const raw = fs.readFileSync(filePath, "utf8");
if (!raw) {
diff --git a/packages/core/templates/skills/bundled/plan/SKILL.md b/packages/core/templates/prompts/plan.md
similarity index 97%
rename from packages/core/templates/skills/bundled/plan/SKILL.md
rename to packages/core/templates/prompts/plan.md
index 41c43013..a4db8748 100644
--- a/packages/core/templates/skills/bundled/plan/SKILL.md
+++ b/packages/core/templates/prompts/plan.md
@@ -1,8 +1,3 @@
----
-name: plan
-description: Plan tasks through a strict non-mutating collaboration workflow before implementation. Use ONLY when the user asks for Plan Mode, planning only or non-mutating exploration.
----
-
# Plan Mode (Conversational)
You work in 3 phases, and you should _chat your way_ to a great plan before finalizing it. A great plan is very detailed—intent- and implementation-wise—so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions.
diff --git a/packages/core/templates/tools/read.md.ejs b/packages/core/templates/tools/read.md.ejs
index 9f79ac9b..71eaab2e 100644
--- a/packages/core/templates/tools/read.md.ejs
+++ b/packages/core/templates/tools/read.md.ejs
@@ -15,7 +15,6 @@ Usage:
<%_ } else { _%>
- This tool can inspect image files, but the current model is not multimodal, so image reads are not presented visually to the model.
<%_ } _%>
-- This tool can read PDF files (.pdf). For large PDFs (more than 10 pages), you MUST provide the pages parameter to read specific page ranges (e.g., pages: "1-5"). Reading a large PDF without the pages parameter will fail. Maximum 20 pages per request.
- This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations.
- This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool.
- You can call multiple tools in a single response. It is always better to speculatively read multiple potentially useful files in parallel.
@@ -38,10 +37,6 @@ Usage:
"limit": {
"description": "The number of lines to read. Only provide if the file is too large to read at once.",
"type": "number"
- },
- "pages": {
- "description": "Page range for PDF files (e.g., \"1-5\", \"3\", \"10-20\"). Only applicable to PDF files. Maximum 20 pages per request.",
- "type": "string"
}
},
"required": [