# Headless 与 ACP

> mcode exec 的参数、输出格式与退出码，无人值守时登录与权限的处理，以及 mcode acp 如何把 ACP 会话映射到运行时、把权限询问转给编辑器。

- 作者：David（道雾轩）
- 专栏：MiniMax Code 源码解读（https://daiw.net/manual/minimax-code.md）
- 最后更新：2026-09-19
- 原文：https://daiw.net/manual/minimax-code/headless-and-acp
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

除了交互式 TUI，`mcode` 还有两个给程序用的入口：`mcode exec` 跑一次任务就退出，适合脚本与 CI；`mcode acp` 把自己变成 Agent Client Protocol 服务端，让编辑器来驱动。两者的代码都在 `packages/tui` 里，分别是 `src/headless` 与 `src/acp`。官方分层图把它们与 TUI 并列放在最上层（`docs/architecture.md:3`）：

```text
`TUI / exec / ACP → CliService → local Applications → Session / Turn / Agent services → Pi / model providers / local tools`.
```

三个入口驱动的是同一个进程内运行时，区别只在创建运行时时传入的表面：`headless` 或 `acp`。这两种表面都以 `startupExecutionPolicy: 'quarantined'` 启动（`packages/tui/src/runtime/lifecycle.ts:455`），效果是冷启动时不去执行从磁盘恢复的后台作业，交互请求不受影响（`packages/local-runtime-v2/src/runtime.ts:831`）。

## mcode exec：怎么用

参数定义在 `packages/tui/src/cli/contract.ts:88`：

| 参数 | 说明 |
| --- | --- |
| `[prompt]` 或 `--input -` | 提示词来自参数或 stdin，二者只能取一；`--input-format json` 接受 JSON 字符串或带 `prompt` 字段的对象 |
| `--file <path>` | 附件，可重复，最多 10 个、合计 100 MB |
| `--cwd <path>` | 工作区，缺省为当前目录 |
| `--model <provider/model>` | 只对本次运行生效，可带 `#variant` |
| `--effort <level>` | 本次的推理强度，开跑前按模型声明的档位校验 |
| `--session <id>`、`--continue` | 接着已有会话跑；后者取该工作区最近的会话 |
| `--permission <policy>` | `smart`（缺省）、`full`、`off` |
| `--timeout <duration>`、`--max-steps <count>` | 如 `30s`、`2m`；步数按完成的 assistant 消息计 |
| `--output-format <format>` | `text`（缺省）、`json`、`stream-json` |
| `--output-schema <schema>` | JSON Schema 文件或内联对象，约束最终答案 |
| `-o, --output-last-message <path>` | 成功时把最终答案写进文件 |
| `--diagnostics-dir <path>` | 把有界的执行诊断写进一个空目录 |
| `--config <path>` | 本进程改用指定的运行时配置文件 |
| `--prompt-mode <mode>` | `tui`（缺省）、`coding`、`work` |

子命令 `mcode exec review` 审查暂存、未暂存与未跟踪的本地改动，不收提示词参数，用的是写死的一句（`packages/tui/src/headless/invocation.ts:233`）：

> Please review my uncommitted changes.

几种典型用法：

```bash
# 一次性任务，拿机器可读的结果
mcode exec "Run the unit tests and summarize failures" --permission full --timeout 10m --output-format json

# 从 stdin 读提示词，按 JSON Schema 约束最终答案并另存一份
cat task.txt | mcode exec --input - --output-schema schema.json -o answer.json

# 审查未提交的改动，逐事件输出 JSONL
mcode exec review --output-format stream-json
```

退出码是契约的一部分（`packages/tui/src/headless/exit-policy.ts:4`）：

```ts
export const MINIMAX_CODE_EXEC_EXIT_CODES = Object.freeze({
  success: 0,
  invocation: 2,
  config: 3,
  runtime: 4,
  timeout: 6,
  limit: 7,
  internal: 70,
  cancelled: 130,
  brokenPipe: 141,
});
```

`resolveTuiExecInvocation` 检出的参数错误是 2；配置有误或需要登录是 3；运行中失败是 4；`--timeout` 到点是 6，`--max-steps` 用尽是 7；内部错误是 70；收到 SIGINT、SIGTERM、SIGHUP 是 130；stdout 的管道被关（`EPIPE`）是 141，这时连 stderr 的诊断也不再写。

## 一次 exec 走过哪些步骤

入口 `packages/tui/src/cli/run-exec-command.ts` 解析参数、准备数据目录、以 `surface: 'headless'` 创建运行时，然后交给 `runTuiExec`（`packages/tui/src/headless/runner.ts:114`）：

1. **登录检查**：带着 `--model` 做一次只读的账号状态检查，见下一节。
2. **定位会话**：新建会话时以提示词作标题，超过 80 个字符就截断；`--session` 与 `--continue` 要求会话属于同一工作区，归档会话与子 Agent 会话都不能用（`runner.ts:841`）。
3. **排查未决交互**：会话里若有待答问卷或待批权限，直接失败，提示回 TUI 或 ACP 处理（`runner.ts:601`）。
4. **确定模型**：`--model` 必须写成 `provider/model` 或 `provider/model#variant`；给了 `--effort` 才去读模型列表，逐项校验档位，不支持思考或档位不在列表里都在开跑前报错（`packages/tui/src/headless/model-selection.ts:55`）。
5. **执行回合**：交给 `TuiRunCoordinator`，策略里总带 `requireAnswer: true`，跑完没有最终答案记为 `EMPTY_RESPONSE`；`--timeout` 由客户端计时器主动停止回合（`packages/tui/src/application/run-coordinator.ts:197`）。
6. **盯住回合**：`ExecRunSupervisor` 消费事件流，流断了就按游标重连或回读本回合历史，连续恢复超过 5 次仍无进展才放弃（`packages/tui/src/headless/supervisor.ts:40`）。
7. **收尾**：校验结构化输出，在 1 秒预算内补齐实际模型与用量（`runner.ts:637`），关闭运行时，最后才写 stdout 与 `-o` 文件。

## 登录检查：模型覆盖只读地参与判断

仓库文档对这一点有专门的一句（`docs/architecture.md:22`）：

> Headless model overrides are passed to a read-only account-state check so authentication requirements follow the model selected for that run.

对应的代码在 `runner.ts:182`：

```ts
    const loginModel = input.model ? parseHeadlessModelOverride(input.model) : undefined;
    await requireTuiAgentAccess(
      dependencies.runtime, input.sessionId, undefined, undefined,
      loginModel ? { model: loginModel } : undefined,
    );
```

模型一路传下去：TUI 适配器把它拼成 `provider/model` 交给 `CliService` 的 `getAccountStatus`（`packages/tui/src/runtime/adapters/product-access.ts:56`），最终落到宿主的 `getProcessLocalAccountStatus`。这里只在内存里拷一份配置、把默认模型换成本次指定的，再据此生成账号与模型的诊断快照（`packages/local-runtime/src/api/host.ts:1532`）：

```ts
    const effectiveModel = model ?? session?.effectiveModel;
    const config = effectiveModel
      ? { ...baseConfig, defaultModel: effectiveModel }
      : baseConfig;
```

全局配置与会话模型都没有被改动。拿到状态后，`tuiAgentAccessNeedsLogin` 只在两种情况下要求登录：状态本身是 `needs-login`，或者模型走 Token Plan 的托管登录却没有托管令牌（`packages/tui/src/application/login-gate.ts:34`）。所以 `--model` 指向自带密钥（BYOK）的提供方时，未登录也能跑，与 `docs/tui-capabilities.md` 的描述一致。需要登录却没登录时抛 `TuiLoginRequiredError`，归类为配置错误，退出码 3。

## 无人值守时的权限

`--permission` 映射到运行时的权限模式（`packages/tui/src/cli/run-exec-command.ts:129`）：`smart` 对应 `auto`，`full` 对应 `bypassPermissions`，`off` 就是 `off`。交互式的 `ask` 在解析参数时就被拒绝，错误信息让用户改用 TUI、ACP 或另外三档（`invocation.ts:119`）。三档的差别见权限核心的模式画像（`packages/agent-modules/permission/src/permission-core.ts:523`）：`bypassPermissions` 与 `off` 都不询问，但前者仍是硬闸门，`off` 则让权限层不再拦截任何执行；`auto` 允许分类器参与判定，交互方式却仍是“询问”。

headless 运行时在创建时就声明没有权限弹窗、问卷与 elicitation 能力（`lifecycle.ts:450`）。所以 `smart` 模式下若判定仍落到“需要询问”，审批环节立刻拒绝，而不是挂起等人（`packages/local-runtime/src/api/hosted-agent-permission-capabilities.ts:93`），错误信息是：

> HOST_CAPABILITY_UNAVAILABLE: this Runtime host cannot prompt for permission.

万一回合还是进入了等待用户决定的 `decision-blocked` 状态，监督器报 `INTERACTION_NOT_AVAILABLE` 收场（`supervisor.ts:211`）；以 `awaiting-user-continuation` 结束的回合也同样改记为失败（`packages/tui/src/headless/contract.ts:148`）。

## 输出契约

- **`text`**：只在成功时往 stdout 写最终答案，失败原因一律写 stderr（`packages/tui/src/headless/output.ts:61`）。
- **`json`**：结束时输出一行 `ExecResult`。
- **`stream-json`**：逐行输出事件，依次是 `exec.started`、`session.started` 或 `session.resumed`、`turn.started`，中间是 `item.started`、`item.updated`、`item.completed`（条目分 `agent_message`、`reasoning`、`tool_call` 三类），最后 `turn.completed` 或 `turn.failed`，再以携带完整结果的 `exec.completed` 收尾；每个事件带 `schemaVersion`、递增的 `sequence` 与时间戳（`packages/tui/src/headless/events.ts:27`）。

`ExecResult` 的形状（`headless/contract.ts:37`）：

```ts
export interface ExecResult {
  readonly schemaVersion: 1;
  readonly type: 'exec.result';
  readonly runId: string;
  readonly sessionId: string;
  readonly turnId: string;
  readonly status: ExecResultStatus;
  readonly output?: unknown;
  readonly error?: ExecError;
  readonly model?: ExecModelIdentity;
  readonly usage?: ExecTokenUsage;
  readonly usageSource?: ExecUsageSource;
  readonly usageIncomplete?: boolean;
  readonly durationMs: number;
}
```

用了 `--output-schema` 时，最终答案先按 JSON 解析，再用 Ajv 校验；空答案、非 JSON、不合 schema 都把状态改成失败，错误码 `STRUCTURED_OUTPUT_INVALID`（`headless/contract.ts:160`）。`-o` 的文件先写临时文件再改名，只在成功时写（`output.ts:73`）。`--diagnostics-dir` 必须是空目录，事件记录上限 4 MiB、答案 256 KiB，形似凭据的字段一律打码（`packages/tui/src/headless/diagnostics.ts:18`）。参数名、事件名与 [Codex 的非交互模式](https://daiw.net/manual/codex/exec)多有相同，可以对照着读。

## mcode acp：让编辑器来驱动

`mcode acp` 以 stdio 提供服务（`packages/tui/src/cli/program.ts:107`），编辑器把它当子进程启动，在 stdin、stdout 上交换换行分隔的 JSON-RPC 消息（`packages/tui/src/acp/stdio.ts:17`）。实现基于 `@agentclientprotocol/sdk` 1.3.0（`packages/tui/package.json:23`）。为了不污染协议流，进程里的 `console` 输出全部改走 stderr（`packages/tui/src/cli/run-acp-command.ts:86`）；一个进程只接受一个客户端连接（`packages/tui/src/acp/agent.ts:318`）。

`initialize` 的回答说明了能力边界（`agent.ts:399`）：

```ts
    return {
      protocolVersion: acp.PROTOCOL_VERSION,
      agentCapabilities: {
        loadSession: true,
        mcpCapabilities: {
          http: true,
          sse: true,
        },
        promptCapabilities: {
          image: false,
          audio: false,
          embeddedContext: false,
        },
        sessionCapabilities: {
          list: {},
          fork: {},
          resume: {},
          close: {},
        },
      },
```

提示词只收文本与 `resource_link` 两种内容块，后者被改写成一行写明资源标题与 URI 的文本（`agent.ts:2098`）。客户端声明支持终端认证时，还会得到一个参数为 `['login']` 的认证方法；`acp` 下正好挂着一个与顶层 `mcode login` 相同的 `login` 子命令（`program.ts:115`）。新建、加载、恢复、列出会话前都先查登录状态，未登录时返回 `authRequired` 错误，提示先运行 `mcode login`（`agent.ts:2087`）。

协议之上还有几层 MiniMax 自己的东西：

- **斜杠命令**：`/help`、`/model`、`/status`、`/mcp`、`/compact` 等 10 个命令由适配器就地处理，不经模型，结果作为一条 `agent_message_chunk` 发回（`packages/tui/src/acp/commands.ts:7`）；`/new` 只提示在客户端里新开一个会话。
- **模式与配置项**：模式只有 `default` 与 `plan`，切换在下一个提示时生效；配置项有权限模式、模型、思考强度三个（`packages/tui/src/acp/control-state.ts:12`）。其中权限模式可选 `default`、`auto`、`bypassPermissions`，并标注 `'minimax-code/scope': 'process'`，改的是整个进程，不只当前会话（`control-state.ts:151`）。
- **扩展方法**：`mcode/session/steer`、`mcode/session/queue/list` 等队列方法，以及目标与委派相关的一组方法，列表通过 `initialize` 返回的 `_meta` 告知客户端（`packages/tui/src/acp/extensions.ts:17`）。

## 会话映射

下文 session/new 这类名字是 ACP 协议规定的方法名，代码里通过 SDK 的 `acp.methods` 常量引用。ACP 的会话 ID 就是运行时的会话 ID：session/new 以请求里的 `cwd` 创建运行时会话，客户端带来的 `mcpServers` 注册为这个会话专属的 MCP 服务器（见 [MCP](https://daiw.net/manual/minimax-code/mcp)），然后直接返回运行时的 ID（`agent.ts:519`）。其余请求的对应关系：

| ACP 请求 | 做什么 |
| --- | --- |
| session/load | 挂接已有会话并按页回放全部历史，每页 100 条 |
| session/resume | 挂接已有会话，不回放 |
| session/list | 列出含归档在内的会话，跳过子 Agent 会话 |
| session/fork | 在运行时里分叉会话 |
| session/prompt | 跑一个回合；成功返回 `end_turn`，取消返回 `cancelled`，步数超限返回 `max_turn_requests`，其余作为内部错误抛出（`agent.ts:1166`） |
| session/cancel | 中止进行中的提示；没有进行中的提示时，留一个一次性标记，拦下紧随其后到达的那一个 |
| session/close | 断开挂接，中止进行中的提示，清掉会话级 MCP |

挂接已有会话时，`cwd` 必须与会话记录的工作区一致，子 Agent 会话不能直接打开，`additionalDirectories` 不支持（`agent.ts:1654`）。子 Agent 在运行时里另开会话，适配器用 `runtimeParentSessionIds` 记下父子关系；子会话里冒出的权限询问沿着父链找到挂接中的那个 ACP 会话，再转给编辑器（`agent.ts:1533`）。

## 权限询问怎样转给编辑器

ACP 表面不是 headless，运行时保有权限弹窗能力：需要询问时，它发出 `permission.ask` 事件并等待回复。适配器在 `runTuiAcpInteractions` 里订阅运行时事件（`packages/tui/src/acp/interactions.ts:68`），把询问翻译成 session/request_permission：

```mermaid
sequenceDiagram
  participant E as 编辑器
  participant A as mcode acp
  participant R as 运行时
  E->>A: session/prompt
  A->>R: 执行回合
  R-->>A: permission.ask 事件
  A->>E: session/request_permission
  E-->>A: 选中 allow-once
  A->>R: replyPermission 回复 allowOnce
  R-->>A: 流式事件
  A-->>E: session/update 通知
  A-->>E: prompt 响应与 stopReason
```

给编辑器的选项是 `allow-once`、`deny`，运行时支持时再加一个 `allow-always`（`interactions.ts:898`）。处理逻辑处处按拒绝兜底（`interactions.ts:560`）：

```ts
  let decision: 'allowOnce' | 'allowAlways' | 'deny' = 'deny';
  try {
    const response = await options.connection.client.request(
      acp.methods.client.session.requestPermission,
      permissionRequest(resolved.acpSessionId, requestId, event.request),
      {
        cancellationSignal: AbortSignal.any([
          options.connection.signal,
          resolved.attachmentSignal,
          schedulerSignal,
        ]),
      },
    );
    if (response.outcome.outcome === 'selected') {
      decision =
        response.outcome.optionId === 'allow-always' && event.request.allowAlwaysSupported
          ? 'allowAlways'
          : response.outcome.optionId === 'allow-once'
            ? 'allowOnce'
            : 'deny';
    }
  } catch {
    decision = 'deny';
  }
```

请求出错、被取消、客户端没选任何项，或者回复时会话已被别的挂接取代，结果都是 `deny`。Agent 向用户提的问卷走另一条路：客户端声明支持表单式 elicitation 时发 elicitation/create；否则退而借 session/request_permission 的选项形式只问第一步，而且第一步必须是不许自由填写的单选题，其余步骤记为跳过；条件不满足就撤销问卷（`interactions.ts:607`）。工具调用的 `kind` 按工具名里的关键词粗分成 `read`、`edit`、`execute` 等几类（`packages/tui/src/acp/updates.ts:124`）。

对照 [OpenCode 的 ACP 实现](https://daiw.net/manual/opencode/frontends)：那边先起一个真正的 HTTP 服务端，再用 SDK 连回自己；这里的适配器直接拿着 TUI 用的那个进程内运行时适配器（`run-acp-command.ts:67`），与 TUI、`mcode exec` 地位相同。

下一篇：[遥测、更新与诊断](https://daiw.net/manual/minimax-code/telemetry-and-update)——使用统计的开关、`mcode update` 的更新流程与诊断上报。
