Todo、提问与 Plan 模式

TodoWrite 的输入结构、存储与待办提醒;AskUserQuestion 的问卷上限,及其在 TUI、桌面与无头模式下怎样收场;Plan 模式的进入与退出、ExitPlanMode 的审批与计划文件、规划期间的工具面;ListModels 与模型引用解析。

作者 David更新于 20 篇(共 47 篇)

有一类内置工具不读写文件,也不跑命令:它们改的是会话状态,或者把决定权交还给用户。本篇讲其中四组:待办(TodoReadTodoWrite)、提问(AskUserQuestion)、规划(EnterPlanModeExitPlanMode)和两个“发现面”工具(ListModelsReadSessionContext)。它们的处理器都在 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整表替换待办是(副作用范围 sessionhandlers/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提交计划,请求批准后退出 Planhandlers/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

定时与闲时任务的六个工具(CronCreateCronListCronUpdateCronDeleteOffPeakCreateOffPeakList)要有对应的端口才注册(apps/zcode-cli/packages/core/src/tool/handlers/index.ts:242index.ts:251):协议会话总是注入定时任务端口,闲时端口要宿主显式开放;TUI 与 -p 两个都不注入(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/server-operations.ts:3370),见定时任务与闲时任务

TodoWrite 与待办提醒

每条待办只有三个字段:content(非空)、statuspendingin_progresscompleted)、priorityhighmediumlow),没有 ID(apps/zcode-cli/packages/contracts/src/tools/todo.ts:26)。工具描述要求每次发送完整列表、整表替换,同一时间只保持一项 in_progressapps/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"],
//   });
// });

处理器先读旧列表、再写新列表,返回 oldTodostodos 和一个按状态计数的 summaryapps/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_reminderapps/zcode-cli/packages/core/src/runtime/helpers/runtime-reminders.ts:81apps/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,只能是片段,不能有 htmlbodydoctype,不能有 scriptstyle;描述说只用于单选题contracts/src/tools/ask-user-question.ts:222tools/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:151tools/ask-user-question.ts:43)。回给模型的文本分三种(tools/ask-user-question.ts:131):全部作答时是 “User has answered your questions: …”;部分作答时说明跳过了几题,其余自行判断;一题都没答时,明确告诉模型“不要当成拒绝,也不要编造用户偏好”。

问卷在不同宿主里怎样收场

图表加载中…

AskUserQuestionExitPlanMode 都声明了 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:62app-question-state.ts:121一直等待
桌面与 Web协议层把它转成 interactionRequestUserInput 反向请求(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/interaction-broker.ts:48interaction-broker.ts:195),答案同样以 modify 回到执行器(interaction-broker.ts:3895 分钟后自动继续,见下文
无头 -p没有审批面,退到拒绝型 broker,直接返回 “No permission client configured for AskUserQuestion”(apps/zcode-cli/packages/core/src/permission/broker.ts:24apps/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:8apps/zcode-cli/packages/core/src/runtime/methods/events.ts:280interaction-broker.ts:545)。设置页有一个“提问自动继续”开关,默认开启,说明文案写的正是这个行为(packages/ui/src/i18n/locales/zh-CN.ts:1820packages/ui/src/SettingsPage.tsx:683);关闭时正在倒计时的问题转为暂停,重新开启只对之后的新问题生效(interaction-registry.ts:212)。协议层的交互登记见 ZCode Protocol V4

Plan 模式:是一个开关,不是一种权限模式

Plan 在执行状态里是与权限模式并列的一个布尔值:权限模式只有 buildedityoloauto 四种,plan 只在读取旧格式时被接受,并换算成 planEnabled: truepackages/shared/src/execution-state.ts:3src/execution-state.ts:12)。所以退出 Plan 就回到进入前的权限模式,Plan 期间的 yolo 也不会放行写操作(service.ts:136)。

图表加载中…

进入有五条路:TUI 里 Shift+Tabplanbuildedityolo 之间轮换(apps/zcode-cli/packages/tui/src/app-mode.ts:5apps/zcode-cli/packages/tui/src/app-keyboard-helpers.ts:73);斜杠命令 /mode planpackages/shared/src/zcode-slash-command-help.ts:132);桌面与 Web 输入框独有的 /plan [task]apps/zcode-cli/packages/bootstrap/src/slash-command-surface.ts:10);命令行 --mode planapps/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: falseneedsApproval: falsehandlers/plan-mode.ts:108handlers/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 时子运行时同样打开 planEnabledapps/zcode-cli/packages/core/src/runtime/methods/subagent.ts:171subagent.ts:244,见子 Agent)。

规划期间:提示词与工具面

工具清单不变。两个 Plan 工具一直注册(index.ts:94),代码里也没有按 planEnabled 过滤工具清单的逻辑;变的是权限判定。checkPlanModeservice.ts:404)只放行三类:只读且非破坏性的工具、非破坏性的 MCP 工具、显式声明 allowedInPlanMode 的会话级控制动作,其余一律拒绝(不是询问)。这个分支排在项目 denyask 规则之后,但在项目 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:18runtime-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。它还规定回合只能以 AskUserQuestionExitPlanMode 结束,任何形式的“计划可以吗”都必须走 ExitPlanModeruntime-reminders.ts:61)。退出 Plan 后的下一轮,回合循环注入一次“已退出 Plan 模式,现在可以编辑、运行工具”的提醒(turn-loop.ts:120runtime-reminders.ts:75)。EnterPlanMode 的结果本身也带一段六步指引,末句是 “DO NOT write or edit any files yet”(handlers/plan-mode.ts:257)。提醒怎样进上下文见系统提示词、上下文与提醒

ExitPlanMode:审批与计划文件

计划不是由模型写进文件,而是作为 ExitPlanMode 的参数提交(plan-mode-prompts.ts:107):

参数约束出处
plan1 到 20000 个字符,不能全是空白;保存原始字符串,不做改写apps/zcode-cli/packages/contracts/src/tools/plan-mode.ts:13contracts/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:326interaction-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:60apps/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:16plan-file-continuity.ts:92apps/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_modellist-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_model only 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 不是工具,而是 CreateWorkflowAmendWorkflow 在弹确认窗之前调用的解析器(apps/zcode-cli/packages/core/src/tool/handlers/model-reference.ts:10)。它把模型名按三档解析:providerId/modelId 全称精确匹配;裸 modelId 恰好只在一个提供方下;裸 modelId 在多个提供方下时取当前会话的那个,否则报歧义(model-reference.ts:57)。比较不分大小写;禁用的条目绝不静默命中;$档位 后缀必须是该模型的合法档位,缺省取默认档位。解析失败时附上最多 40 个可用 ID,并提示去调 ListModelsmodel-reference.ts:38)。工作流工具本身见动态工作流(三)

ReadSessionContext

这个工具只讲工具面,背后的材料构建见项目记忆。用户提到 #sess_… 或要求接着某个旧会话继续时,模型用它读另一个已持久化会话的上下文(handlers/read-session-context.ts:152)。参数:sessionId 必须是 sess_ 开头;query 1 到 4000 字符;strategyrelevant(默认,按问题检索)或 handoff(生成一份续作交接);maxTokens 最多 12000,默认 6000(apps/zcode-cli/packages/contracts/src/tools/read-session-context.ts:4contracts/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)。

下一篇:权限模式与规则——buildedityolo 几种权限模式与 Plan 开关怎样组合,规则语法怎样匹配,审批与“始终允许”怎样持久化。

本页目录