# Todo、提问与 Plan 模式

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

- 作者：David（道雾轩）
- 专栏：ZCode 源码解读（https://daiw.org/manual/zcode.md）
- 最后更新：2026-09-21
- 原文：https://daiw.org/manual/zcode/interaction-tools
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

有一类内置工具不读写文件，也不跑命令：它们改的是会话状态，或者把决定权交还给用户。本篇讲其中四组：待办（`TodoRead`、`TodoWrite`）、提问（`AskUserQuestion`）、规划（`EnterPlanMode`、`ExitPlanMode`）和两个“发现面”工具（`ListModels`、`ReadSessionContext`）。它们的处理器都在 `apps/zcode-cli/packages/core/src/tool/handlers/`，契约在 `apps/zcode-cli/packages/contracts/src/tools/`；Plan 模式的状态落在运行时的执行状态里。权限层面的细节（模式、规则、审批持久化）留给下一篇[权限模式与规则](https://daiw.org/manual/zcode/permission)，TUI 里的问卷与审批面板怎样渲染见[终端界面](https://daiw.org/manual/zcode/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`），见[定时任务与闲时任务](https://daiw.org/manual/zcode/cron-offpeak)。

## 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`）：

```ts
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 会话库](https://daiw.org/manual/zcode/session-store)。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: …”；部分作答时说明跳过了几题，其余自行判断；一题都没答时，明确告诉模型“不要当成拒绝，也不要编造用户偏好”。

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

```mermaid
sequenceDiagram
  participant M as 模型
  participant P as 执行器与权限服务
  participant H as 宿主
  M->>P: AskUserQuestion，没有 answers
  P->>H: 权限请求，总是询问
  H-->>P: modify：带上 answers 的新输入
  P->>P: 按 schema 重新校验改写后的输入
  P->>M: 格式化后的答案文本
```

`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`）：

```ts
  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](https://daiw.org/manual/zcode/zcode-protocol)。

## 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`）。

```mermaid
flowchart LR
  A["常规：build、edit 或 yolo"] -->|"EnterPlanMode、Shift+Tab、/mode plan、/plan"| B["Plan：planEnabled 为真"]
  B -->|ExitPlanMode| C{"用户审批"}
  C -->|批准| D["写计划文件，关闭 Plan"]
  C -->|填写修改意见| E["意见作为用户消息注入，留在 Plan 修订"]
  C -->|"拒绝，或 -p 下无人审批"| F["本回合结束，仍在 Plan"]
  D --> A
  E --> B
  B -->|"Shift+Tab 或 /mode build"| A
```

**进入**有五条路：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`）：

```ts
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`，目标模式见[目标模式](https://daiw.org/manual/zcode/goal-target)）。工具侧通过会话模式端口调用它（`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](https://daiw.org/manual/zcode/subagents)）。

## 规划期间：提示词与工具面

**工具清单不变**。两个 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 一篇](https://daiw.org/manual/zcode/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`）。提醒怎样进上下文见[系统提示词、上下文与提醒](https://daiw.org/manual/zcode/context-builder)。

## 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`：

```ts
  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`）。压缩本身见[上下文压缩](https://daiw.org/manual/zcode/compaction)。

对照站内两个同类实现：[MiniMax Code](https://daiw.org/manual/minimax-code/plan-and-goal) 让模型在 Plan 里写规范计划文件、再用工具守卫拦下其他写入；[Grok Build](https://daiw.org/manual/grok-build/plan-mode) 同样把计划写进 `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_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` 不是工具，而是 `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`）。工作流工具本身见[动态工作流（三）](https://daiw.org/manual/zcode/dwf-tools)。

## ReadSessionContext

这个工具只讲工具面，背后的材料构建见[项目记忆](https://daiw.org/manual/zcode/memory)。用户提到 `#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`）。

下一篇：[权限模式与规则](https://daiw.org/manual/zcode/permission)——`build`、`edit`、`yolo` 几种权限模式与 Plan 开关怎样组合，规则语法怎样匹配，审批与“始终允许”怎样持久化。
