Todo、提问与 Plan 模式
TodoWrite 的输入结构、存储与待办提醒;AskUserQuestion 的问卷上限,及其在 TUI、桌面与无头模式下怎样收场;Plan 模式的进入与退出、ExitPlanMode 的审批与计划文件、规划期间的工具面;ListModels 与模型引用解析。
有一类内置工具不读写文件,也不跑命令:它们改的是会话状态,或者把决定权交还给用户。本篇讲其中四组:待办(TodoRead、TodoWrite)、提问(AskUserQuestion)、规划(EnterPlanMode、ExitPlanMode)和两个“发现面”工具(ListModels、ReadSessionContext)。它们的处理器都在 apps/zcode-cli/packages/core/src/tool/handlers/,契约在 apps/zcode-cli/packages/contracts/src/tools/;Plan 模式的状态落在运行时的执行状态里。权限层面的细节(模式、规则、审批持久化)留给下一篇权限模式与规则,TUI 里的问卷与审批面板怎样渲染见终端界面。
| 工具 | 作用 | 声明为只读 | 总要用户交互 | 出处 |
|---|---|---|---|---|
TodoRead | 读当前会话的待办 | 是 | 否 | apps/zcode-cli/packages/core/src/tool/handlers/todo.ts:74 |
TodoWrite | 整表替换待办 | 是(副作用范围 session) | 否 | handlers/todo.ts:130 |
AskUserQuestion | 向用户发选择题问卷 | 是 | 是 | apps/zcode-cli/packages/core/src/tool/handlers/ask-user-question.ts:71 |
EnterPlanMode | 进入 Plan 模式 | 否 | 否,直接放行 | apps/zcode-cli/packages/core/src/tool/handlers/plan-mode.ts:106 |
ExitPlanMode | 提交计划,请求批准后退出 Plan | 否 | 是 | handlers/plan-mode.ts:153 |
ListModels | 列出本机配置的模型 | 是 | 否 | apps/zcode-cli/packages/core/src/tool/handlers/list-models.ts:130 |
ReadSessionContext | 读另一个会话的上下文 | 是 | 否 | apps/zcode-cli/packages/core/src/tool/handlers/read-session-context.ts:146 |
定时与闲时任务的六个工具(CronCreate、CronList、CronUpdate、CronDelete、OffPeakCreate、OffPeakList)要有对应的端口才注册(apps/zcode-cli/packages/core/src/tool/handlers/index.ts:242、index.ts:251):协议会话总是注入定时任务端口,闲时端口要宿主显式开放;TUI 与 -p 两个都不注入(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/server-operations.ts:3370),见定时任务与闲时任务。
TodoWrite 与待办提醒
每条待办只有三个字段:content(非空)、status(pending、in_progress、completed)、priority(high、medium、low),没有 ID(apps/zcode-cli/packages/contracts/src/tools/todo.ts:26)。工具描述要求每次发送完整列表、整表替换,同一时间只保持一项 in_progress(apps/zcode-cli/packages/core/src/tool/handlers/todo.ts:135)。但这条“只能一项”在 schema 里只是描述,校验被整段注释掉了(contracts/src/tools/todo.ts:53):
export const TodoWriteInputSchema = z
.object({
todos: z
.array(TodoItemSchema)
.describe("The complete updated todo list. At most one item may be in_progress at a time."),
})
.strict();
// 多 subagent / 并行任务下需要允许多个 in_progress,旧的 schema 硬拒绝会让
// TodoWrite 失败并触发后续调度组被跳过;先整段注释保留,便于回滚或对比。
// .superRefine((input, context) => {
// const inProgressCount = input.todos.filter((todo) => todo.status === "in_progress").length;
// if (inProgressCount <= 1) return;
// context.addIssue({
// code: z.ZodIssueCode.custom,
// message: "At most one todo can be in_progress",
// path: ["todos"],
// });
// });处理器先读旧列表、再写新列表,返回 oldTodos、todos 和一个按状态计数的 summary(apps/zcode-cli/packages/core/src/tool/handlers/todo.ts:47)。存储在会话库的 todo 表,主键是会话 ID 加位置,随会话级联删除(apps/zcode-cli/packages/adapters/src/storage/session-store/migrations.ts:64)。写入是一个 begin immediate 事务:删掉该会话的全部行,按数组顺序重插,顺带刷新会话的更新时间(apps/zcode-cli/packages/adapters/src/storage/session-store/repositories/todos.ts:24)。库本身见SQLite 会话库。TUI 在侧边栏显示待办和“已完成数/总数”的进度(apps/zcode-cli/packages/tui/src/app-sidebar.tsx:277)。
TodoWrite 声明为只读、副作用范围 session、不需审批(handlers/todo.ts:140),所以 build 模式直接放行,Plan 模式下也照写不误。
待办提醒。如果本回合的工具清单里有 TodoWrite,且距离上一次调用 TodoWrite 和上一次提醒都已经过了至少 10 条助手消息,回合循环就注入一条 todo_reminder(apps/zcode-cli/packages/core/src/runtime/helpers/runtime-reminders.ts:81、apps/zcode-cli/packages/core/src/runtime/methods/turn-loop.ts:138)。提醒以 “The TodoWrite tool hasn't been used recently.” 开头,语气很轻,末尾附上现有列表,格式是 1. [状态] 内容(runtime-reminders.ts:171);这条提醒同时作为合成的用户通知落库(turn-loop.ts:148)。输出 token 续写期间不注入。计数只看助手消息,不看用户消息(runtime-reminders.ts:126)。
AskUserQuestion:问卷的结构
描述的第一句就在收窄使用场景(handlers/ask-user-question.ts:22):
Use this tool only when you are blocked on a decision that is genuinely the user's to make: one you cannot resolve from the request, the code, or sensible defaults.
描述还要求:推荐项放第一位并在标签末尾加 (Recommended);进入 Plan 要用 EnterPlanMode,而在 Plan 里不要用它问“计划可以吗”,因为用户在 ExitPlanMode 之前看不到计划(handlers/ask-user-question.ts:29)。问卷的约束在 apps/zcode-cli/packages/contracts/src/tools/ask-user-question.ts:
| 约束 | 值 | 出处 |
|---|---|---|
| 问题数 | 1 到 4 个,问题文本不能重复 | contracts/src/tools/ask-user-question.ts:116、:138 |
| 每题选项 | 2 到 4 个,标签不能重复,不许自带 “Other”(客户端会自动提供) | contracts/src/tools/ask-user-question.ts:57、:72 |
header | 描述说最多 12 个字符,显示成标签;代码只定义了常量 ASK_USER_QUESTION_TOOL_CHIP_WIDTH,并不校验 | contracts/src/tools/ask-user-question.ts:10、:52 |
multiSelect | 默认 false,但在给模型的 schema 里被强制列为必填 | contracts/src/tools/ask-user-question.ts:64、:194 |
preview | 选项的可选预览,按 Markdown 渲染在等宽框里;若含 HTML,只能是片段,不能有 html、body、doctype,不能有 script、style;描述说只用于单选题 | contracts/src/tools/ask-user-question.ts:222、tools/ask-user-question.ts:40 |
| 答案 | answers 以问题文本为键;annotations 可带所选预览与用户备注 | contracts/src/tools/ask-user-question.ts:123 |
答案不是模型填的。它由审批环节写进工具输入:处理器使用的 schema 要求 answers 字段必须存在,缺失就报 “AskUserQuestion requires user answers before execution”;空对象合法,表示用户一题都没答;某题答了空白则拒绝(contracts/src/tools/ask-user-question.ts:151、tools/ask-user-question.ts:43)。回给模型的文本分三种(tools/ask-user-question.ts:131):全部作答时是 “User has answered your questions: …”;部分作答时说明跳过了几题,其余自行判断;一题都没答时,明确告诉模型“不要当成拒绝,也不要编造用户偏好”。
问卷在不同宿主里怎样收场
AskUserQuestion 与 ExitPlanMode 都声明了 requiresUserInteraction。权限服务对这类工具的判断排在所有模式分支之前:除非在配置的 permission.disallowedTools 里被显式禁用,否则一律“询问”,yolo 也不例外(apps/zcode-cli/packages/core/src/permission/service.ts:112)。宿主回一个 modify 决定,把填好答案的输入交回执行器,执行器按 schema 重新校验后才调用处理器(apps/zcode-cli/packages/core/src/tool/executor/permission-flow.ts:415)。各宿主的差别在于“询问”由谁来接:
| 宿主 | 呈现 | 没人回答时 |
|---|---|---|
| TUI | 审批队列里的问卷面板:方向键或 j/k 移动,数字 1 到 9 直选,空格切换多选,o 输入 Other,s 跳过本题,Enter 进入确认页、再按 Enter 提交,Esc 拒绝(apps/zcode-cli/packages/tui/src/app-question-state.ts:62、app-question-state.ts:121) | 一直等待 |
| 桌面与 Web | 协议层把它转成 interactionRequestUserInput 反向请求(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/interaction-broker.ts:48、interaction-broker.ts:195),答案同样以 modify 回到执行器(interaction-broker.ts:389) | 5 分钟后自动继续,见下文 |
无头 -p | 没有审批面,退到拒绝型 broker,直接返回 “No permission client configured for AskUserQuestion”(apps/zcode-cli/packages/core/src/permission/broker.ts:24、apps/zcode-cli/packages/cli/src/prompt-command.ts:215) | 立即拒绝 |
TUI 的审批器是进程内的,不经过 ZCode Protocol,所以没有自动继续(apps/zcode-cli/packages/cli/src/tui-prompt-handler.ts:78)。桌面与 Web 的自动继续在协议层的交互登记表里(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/interaction-registry.ts:30):先静默等 60 秒,再显示倒计时,300 秒到期。计时从问题排到会话交互队列的队首时开始(interaction-registry.ts:256):
private activateHead(sessionId: string): void {
const queue = this.queuesBySession.get(sessionId);
const interactionId = queue?.[0];
if (!interactionId) return;
const entry = this.pending.get(interactionId);
if (
!entry?.options ||
entry.options.kind !== "askUserQuestion" ||
!entry.autoResolutionEligible ||
entry.autoResolution
) {
return;
}
const startedAt = this.now();
const visibleAt = startedAt + this.hiddenGraceMs;
const deadlineAt = startedAt + this.autoResolutionMs;
entry.autoResolution = {
state: "hiddenGrace",
startedAt,
visibleAt,
deadlineAt,
};
this.notifyAutoResolution(entry);到期时以“接受、答案为空对象”应答(interaction-registry.ts:339),协议层特意把它与“旧客户端批准但没带答案”区分开(interaction-broker.ts:497),模型收到的就是上面“一题都没答”的那段文本,界面显示“未回答,已自动继续”(packages/ui/src/i18n/locales/zh-CN.ts:5318)。用户对这道题做任何一次有效操作,倒计时就永久暂停(interaction-registry.ts:194)。计时状态每次变化都写成会话事件,再落成会话条目,让桌面的连续会话和 Web 的可重放会话在重连后恢复同一组绝对时间(apps/zcode-cli/packages/core/src/runtime/methods/interaction-auto-resolution.ts:8、apps/zcode-cli/packages/core/src/runtime/methods/events.ts:280、interaction-broker.ts:545)。设置页有一个“提问自动继续”开关,默认开启,说明文案写的正是这个行为(packages/ui/src/i18n/locales/zh-CN.ts:1820、packages/ui/src/SettingsPage.tsx:683);关闭时正在倒计时的问题转为暂停,重新开启只对之后的新问题生效(interaction-registry.ts:212)。协议层的交互登记见 ZCode Protocol V4。
Plan 模式:是一个开关,不是一种权限模式
Plan 在执行状态里是与权限模式并列的一个布尔值:权限模式只有 build、edit、yolo、auto 四种,plan 只在读取旧格式时被接受,并换算成 planEnabled: true(packages/shared/src/execution-state.ts:3、src/execution-state.ts:12)。所以退出 Plan 就回到进入前的权限模式,Plan 期间的 yolo 也不会放行写操作(service.ts:136)。
进入有五条路:TUI 里 Shift+Tab 在 plan、build、edit、yolo 之间轮换(apps/zcode-cli/packages/tui/src/app-mode.ts:5、apps/zcode-cli/packages/tui/src/app-keyboard-helpers.ts:73);斜杠命令 /mode plan(packages/shared/src/zcode-slash-command-help.ts:132);桌面与 Web 输入框独有的 /plan [task](apps/zcode-cli/packages/bootstrap/src/slash-command-surface.ts:10);命令行 --mode plan(apps/zcode-cli/packages/cli/src/cli-types.ts:117);以及模型自己调用 EnterPlanMode。
模型进入 Plan 不需要批准:权限层对 EnterPlanMode 无条件放行,对不在 Plan 里的 ExitPlanMode 无条件拒绝(apps/zcode-cli/packages/core/src/permission/plan-mode-policy.ts:20):
export function resolvePlanModeTransitionPermission(
context: PlanModeTransitionContext,
): PlanModeTransitionPermission | undefined {
if (context.toolName === ENTER_PLAN_MODE_TOOL_NAME) {
return {
behavior: "allow",
reason: "EnterPlanMode switches to plan mode without a permission prompt",
ruleId: "tool.plan.enter",
};
}
if (
context.toolName === EXIT_PLAN_MODE_TOOL_NAME &&
!(context.planEnabled ?? context.mode === "plan")
) {
return {
behavior: "deny",
reason: "ExitPlanMode can only be used while plan mode is active",
ruleId: "mode.plan.exitOnly",
};
}
return undefined;
}这与 EnterPlanMode 自己的描述相矛盾:描述里写着 “This tool REQUIRES user approval - they must consent to entering plan mode”(apps/zcode-cli/packages/core/src/tool/handlers/plan-mode-prompts.ts:94),代码却是 requiresUserInteraction: false、needsApproval: false(handlers/plan-mode.ts:108、handlers/plan-mode.ts:120)。描述的其余部分鼓励模型对非平凡的实现任务主动进入 Plan,列了七种适用情形(plan-mode-prompts.ts:12)。
切换由 applyRuntimeExecutionState 完成(apps/zcode-cli/packages/core/src/runtime/execution-state.ts:43):先把新状态作为会话条目落盘,成功后才改内存,然后发 SessionModeChanged 事件;目标模式正在进行时不许进入 Plan,报 “Plan and Goal cannot be active at the same time.”(runtime/execution-state.ts:59,目标模式见目标模式)。工具侧通过会话模式端口调用它(apps/zcode-cli/packages/core/src/runtime/session-mode-port.ts:11)。子 Agent 的工具面里强制剔除这两个工具,因为子 Agent 没有独立的审批恢复面,ExitPlanMode 会卡住父回合(apps/zcode-cli/packages/core/src/subagent/tool-policy.ts:4);但 Plan 期间派出的子 Agent 会把父会话的 Plan 状态带进自己的权限模式解析,结果为 Plan 时子运行时同样打开 planEnabled(apps/zcode-cli/packages/core/src/runtime/methods/subagent.ts:171、subagent.ts:244,见子 Agent)。
规划期间:提示词与工具面
工具清单不变。两个 Plan 工具一直注册(index.ts:94),代码里也没有按 planEnabled 过滤工具清单的逻辑;变的是权限判定。checkPlanMode(service.ts:404)只放行三类:只读且非破坏性的工具、非破坏性的 MCP 工具、显式声明 allowedInPlanMode 的会话级控制动作,其余一律拒绝(不是询问)。这个分支排在项目 deny、ask 规则之后,但在项目 allow 规则、WebFetch 预批、allowedTools 之前(service.ts:176)。落到常用工具上:Read、Grep、Glob、只读 Bash、TodoWrite 照常可用;Write、Edit、非只读 Bash 被拒;AskUserQuestion 照常提问。Bash 的只读判定见Bash 一篇。
提示词。Plan 期间每次准备模型请求时,回合循环检查是否要注入 runtime_mode 提醒:从未注入过,或距上次已有 5 条真实用户消息,就注入一次;第 1、6、11 次是完整版,其余是精简版(runtime-reminders.ts:18、runtime-reminders.ts:182)。完整版开头是(runtime-reminders.ts:66):
Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system.
后面跟一个四阶段工作流:先理解,只用 explore 子 Agent,最多 3 个并行(runtime-reminders.ts:23);再设计;再复核,用 AskUserQuestion 澄清;最后调用 ExitPlanMode。它还规定回合只能以 AskUserQuestion 或 ExitPlanMode 结束,任何形式的“计划可以吗”都必须走 ExitPlanMode(runtime-reminders.ts:61)。退出 Plan 后的下一轮,回合循环注入一次“已退出 Plan 模式,现在可以编辑、运行工具”的提醒(turn-loop.ts:120、runtime-reminders.ts:75)。EnterPlanMode 的结果本身也带一段六步指引,末句是 “DO NOT write or edit any files yet”(handlers/plan-mode.ts:257)。提醒怎样进上下文见系统提示词、上下文与提醒。
ExitPlanMode:审批与计划文件
计划不是由模型写进文件,而是作为 ExitPlanMode 的参数提交(plan-mode-prompts.ts:107):
| 参数 | 约束 | 出处 |
|---|---|---|
plan | 1 到 20000 个字符,不能全是空白;保存原始字符串,不做改写 | apps/zcode-cli/packages/contracts/src/tools/plan-mode.ts:13、contracts/src/tools/plan-mode.ts:44 |
allowedPrompts | 可选,元素是 { tool: "Bash", prompt },描述“实施计划需要哪类操作” | contracts/src/tools/plan-mode.ts:33 |
| 其他字段 | 允许,schema 用 catchall 放行 | contracts/src/tools/plan-mode.ts:63 |
allowedPrompts 只被处理器原样写回结果(apps/zcode-cli/packages/core/src/tool/handlers/plan-mode.ts:96),代码里没有任何地方据此放行命令。
ExitPlanMode 声明了 requiresUserInteraction,所以总要用户批准(handlers/plan-mode.ts:155)。在桌面与 Web 上,协议层把它做成一道只有一个选项的问卷:问题是 “Review this implementation plan.”,选项 “Approve”,另可输入文字意见(interaction-broker.ts:326、interaction-broker.ts:305);TUI 里是一条普通的审批请求(apps/zcode-cli/packages/tui/src/app-permission.ts:106)。三种结局:
- 批准:处理器先把计划写进
工作区根/.zcode/plans/plan-会话 ID.md(会话 ID 里的特殊字符替换为-,原子写入,自动建目录,apps/zcode-cli/packages/core/src/runtime/helpers/plan-file-continuity.ts:18),再关闭 Plan(handlers/plan-mode.ts:80)。写文件只有取消会中断退出,其他写入错误被吞掉,Plan 照常关闭(handlers/plan-mode.ts:226)。模型收到 “User has approved your plan. You can now start coding. Start with updating your todo list if applicable.” 以及## Approved Plan:后面的计划全文(handlers/plan-mode.ts:279)。 - 填写意见:协议层以带
plan_approval_feedback来源的拒绝返回(interaction-broker.ts:399),执行器把工具结果改成 “The plan was not approved by the user.”,同时把意见作为一条真实用户消息引导进当前回合,模型留在 Plan 里修订(apps/zcode-cli/packages/core/src/tool/executor/turn-control.ts:60、apps/zcode-cli/packages/core/src/runtime/methods/turn-tools.ts:514)。 - 直接拒绝:不当成普通工具错误让模型自己改写计划,而是在这个结果之后结束回合,等用户继续讨论(
turn-control.ts:74)。无头-p下拒绝型 broker 的拒绝走的也是这条路:回合结束,计划文件不会写。
后两种的分流代码在 turn-control.ts:52:
if (
!(input.planEnabled ?? input.mode === "plan") ||
input.toolName !== EXIT_PLAN_MODE_TOOL_NAME ||
result.success
) {
return result;
}
const feedback = readPlanExitDeniedFeedback(result);
if (feedback) {
return {
...result,
followUpUserInput: {
input: feedback,
reasonSource: "plan_approval_feedback",
},
// feedback 会通过 steer 成为真实 user message;tool_result 只能表达计划被拒绝,
// 不能承诺反馈一定跟随,否则 steer 被拒绝时 provider 会看到不存在的后续 user message。
modelContent: EXIT_PLAN_DENIED_BY_USER_MESSAGE,
};
}
// 拒绝退出计划代表用户要继续讨论,不能把它当普通工具错误继续喂给模型自我重写计划。
return {
...result,
turnControl: {
reason: "plan_exit_denied",
stopTurnAfterResult: true,
},计划文件的延续。上下文压缩之后,运行时读回这个文件(最多 81024 字节,即 20000 字符乘 4 再加 1024),作为 plan_file_reference 提醒放回上下文:“A plan file exists from plan mode at: …”,结尾一句是“如果这个计划与当前工作相关且尚未完成,就继续做”(plan-file-continuity.ts:16、plan-file-continuity.ts:92、apps/zcode-cli/packages/core/src/runtime/methods/compact-active.ts:486)。压缩本身见上下文压缩。
对照站内两个同类实现:MiniMax Code 让模型在 Plan 里写规范计划文件、再用工具守卫拦下其他写入;Grok Build 同样把计划写进 plan.md 并在工具层硬拦编辑。ZCode 则让模型在 Plan 里完全不能写文件,计划走参数,批准后由运行时落盘,拦截只在权限层。
ListModels 与模型引用
ListModels 的存在理由只有一个:主 Agent 给一次动态工作流的子 Agent 挑 subagent_model(list-models.ts:6)。描述特意强调它改不了会话自己的模型(list-models.ts:41):
This tool does NOT change the model you are running on. The session model is the user's choice and only the user changes it;
subagent_modelonly moves the workflow's subagents.
它没有参数,每次调用现读宿主的模型目录(list-models.ts:60),一行一个模型:providerId/modelId 形式的 ID、提供方名、可选的推理档位与默认档位、上下文窗口,当前会话用的那一行标 [current],不可用的标 [disabled: 原因](list-models.ts:97)。宿主没提供目录时返回错误码 31 的 model_catalog_unavailable,并明说这是能力缺失、不是没配模型(list-models.ts:51)。它与另外九个工具一起受动态工作流灰度门控制,门关时不注册(index.ts:146);输出预算 24000 字节,超时 10 秒(list-models.ts:26)。
model-reference.ts 不是工具,而是 CreateWorkflow、AmendWorkflow 在弹确认窗之前调用的解析器(apps/zcode-cli/packages/core/src/tool/handlers/model-reference.ts:10)。它把模型名按三档解析:providerId/modelId 全称精确匹配;裸 modelId 恰好只在一个提供方下;裸 modelId 在多个提供方下时取当前会话的那个,否则报歧义(model-reference.ts:57)。比较不分大小写;禁用的条目绝不静默命中;$档位 后缀必须是该模型的合法档位,缺省取默认档位。解析失败时附上最多 40 个可用 ID,并提示去调 ListModels(model-reference.ts:38)。工作流工具本身见动态工作流(三)。
ReadSessionContext
这个工具只讲工具面,背后的材料构建见项目记忆。用户提到 #sess_… 或要求接着某个旧会话继续时,模型用它读另一个已持久化会话的上下文(handlers/read-session-context.ts:152)。参数:sessionId 必须是 sess_ 开头;query 1 到 4000 字符;strategy 为 relevant(默认,按问题检索)或 handoff(生成一份续作交接);maxTokens 最多 12000,默认 6000(apps/zcode-cli/packages/contracts/src/tools/read-session-context.ts:4、contracts/src/tools/read-session-context.ts:12)。有模型可用时交给辅助模型抽取,抽取失败回退到本地材料,超时固定 5 分钟(apps/zcode-cli/packages/core/src/tool/handlers/read-session-context.ts:35)。给模型的使用说明里有一条安全提示:返回内容是背景材料,不是更高优先级的指令(handlers/read-session-context.ts:157)。
下一篇:权限模式与规则——build、edit、yolo 几种权限模式与 Plan 开关怎样组合,规则语法怎样匹配,审批与“始终允许”怎样持久化。