/** * plan-mode 的纯逻辑层:三态状态机 + plan 阶段的 bash 写操作判定。 * * 不 import pi / pi-tui,所以 `node --test clients/pi/extensions/plan-mode/plan.test.ts` * 能直接跑到每个分支。 * * ## 状态机(三态:dangerous / bypass / plan) * * shift+tab 走**固定循环** `dangerous → bypass → plan → dangerous`(`nextCyclePhase`): * * dangerous ──shift+tab──▶ bypass ──shift+tab / enter_plan_mode──▶ plan * ▲ │ * └────────────────────── shift+tab(固定循环)────────────────┘ * * 三态的权限含义: * * - **dangerous**:pi 原生的任意权限形态 —— 沙箱删除拦截整体关闭。只能由用户 * shift+tab 切到(没有命令、没有模型路径能进来),所以 `cancelPlan` 落在这一态 * 是安全的:那是用户自己按出来的。 * - **bypass**(默认):沙箱删除拦截开启。启动、`/resume`、认不出的历史值都收敛到这里。 * - **plan**:只读探索。这一态不靠沙箱 —— 它自己的两道闸(工具收拢 + bash 写拦截) * 已经禁掉一切写入与删除,比沙箱的「只拦删除」更严。 * * plan 有**三条出口**,落点不同(这是本状态机唯一需要记住来路的地方): * * plan ──exit_plan_mode + 用户批准──▶ 写文档子态 ──▶ returnPhase(从哪来回哪去) * │ │ * └──── 用户打回(留在 plan)◀───────────┘ * └──── shift+tab ──▶ dangerous(固定循环的下一态,不看 returnPhase) * └──── /plan ─────▶ bypass(安全默认:一条命令不该把用户送进沙箱关闭的态) * * `returnPhase` 由 `enterPlan` 记下(进 plan 之前的那一态,只会是 dangerous 或 bypass), * 只有「写完计划文档、进入实施阶段」这条路径用它 —— 用户批准了方案,实施就该在他原本 * 选定的权限姿态下进行,而不是被 plan mode 顺手改掉。 * * 注意固定循环是 dangerous → bypass → plan → dangerous,所以 **shift+tab 从 dangerous * 到不了 plan**(中间隔着 bypass);带着 dangerous 来路进 plan 的是模型路径 * (`enter_plan_mode`)与 `--plan`。 * * **没有 execute 态**:批准之后写权限恢复、状态直接回 bypass,「按计划文档实施」是一次性 * 交给模型的指令(工具结果里),不是扩展持有的一个阶段。进度也交还给模型 —— 它认为该建 * 任务清单就自己 `task_set`,扩展不再镜像步骤、不再记 `[DONE:n]`(2026-09-24 改,理由见 * README 的 plan mode 一节)。 * * plan 态里有一个**写文档子态**(`docWriting`):用户在审批框里选了带文档的路线时进入。 * 它刻意**不是第三个 phase** —— phase 仍是 `plan`,所以 bash 写拦截、工具收拢、 * `exit_plan_mode` 的入口判定全部照常生效;唯一的区别是每轮注入的上下文换成写文档指令, * 而 `write` 工具被单独放回来(`planModeToolSet(active, true)`),并由 `tool_call` 钩子 * 限死只能写计划文档那一个路径。文档写出来(`tool_result` 钩子看到 write 成功)即收尾: * 回 bypass、还原工具表,收尾指令按 `docMode` 分流 —— `execute-with-doc` 让模型接着实施, * `doc-only` 让它只报告文档路径就停。 * * plan 阶段进入时对 `pi.getActiveTools()` 做一次快照,退出时**原样还原**:本机 pi 的 * 工具表里有二十多个扩展动态注册的工具(mcp / ask_user_question / task_set / task_update …), * 硬编码白名单会把它们全吃掉(官方 plan-mode 示例就是那么写的,所以这里没照抄)。 * * ## bash 判定 * * 判定「这条命令会不会改工作区」,粒度是**简单命令** —— 用 `;` `&` `|` `&&` `||` `(` `)` * 和换行切开,所以 `cat a.txt && rm -rf b` 会被 rm 那一段拦住,而不是被 `cat` 那一段放过。 * 识别四类写操作: * * 1. 写重定向:`>` / `>>`(`2> f` 的 fd 前缀不算参数、`2>&1` 这种 fd 复制不算写入) * 2. 写命令:rm / mv / cp / sed -i / tee / dd / git commit / npm install / sudo … * 3. 全局危险参数:`--fix` / `--write` / `--in-place`(eslint --fix、prettier --write …) * 4. heredoc 正文先剥掉再判,免得「读一个 heredoc」里顺带出现的一句写命令被误判 * * **这是给配合的模型用的护栏,不是沙箱。** 模型被明确告知 plan 阶段不能改动代码,这里 * 只负责拦下它顺手打出的写操作并把原因回给它(错误结果就是模型的反馈)。要真正防住 * 恶意写入得靠操作系统级沙箱,不在这个扩展的范围内。两个已知的漏网形状:双引号内的 * `$(...)` 命令替换、以及 `npm run