Headless 与 ACP
mcode exec 的参数、输出格式与退出码,无人值守时登录与权限的处理,以及 mcode acp 如何把 ACP 会话映射到运行时、把权限询问转给编辑器。
除了交互式 TUI,mcode 还有两个给程序用的入口:mcode exec 跑一次任务就退出,适合脚本与 CI;mcode acp 把自己变成 Agent Client Protocol 服务端,让编辑器来驱动。两者的代码都在 packages/tui 里,分别是 src/headless 与 src/acp。官方分层图把它们与 TUI 并列放在最上层(docs/architecture.md:3):
`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.
几种典型用法:
# 一次性任务,拿机器可读的结果
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):
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):
- 登录检查:带着
--model做一次只读的账号状态检查,见下一节。 - 定位会话:新建会话时以提示词作标题,超过 80 个字符就截断;
--session与--continue要求会话属于同一工作区,归档会话与子 Agent 会话都不能用(runner.ts:841)。 - 排查未决交互:会话里若有待答问卷或待批权限,直接失败,提示回 TUI 或 ACP 处理(
runner.ts:601)。 - 确定模型:
--model必须写成provider/model或provider/model#variant;给了--effort才去读模型列表,逐项校验档位,不支持思考或档位不在列表里都在开跑前报错(packages/tui/src/headless/model-selection.ts:55)。 - 执行回合:交给
TuiRunCoordinator,策略里总带requireAnswer: true,跑完没有最终答案记为EMPTY_RESPONSE;--timeout由客户端计时器主动停止回合(packages/tui/src/application/run-coordinator.ts:197)。 - 盯住回合:
ExecRunSupervisor消费事件流,流断了就按游标重连或回读本回合历史,连续恢复超过 5 次仍无进展才放弃(packages/tui/src/headless/supervisor.ts:40)。 - 收尾:校验结构化输出,在 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:
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):
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):
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 的非交互模式多有相同,可以对照着读。
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):
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),然后直接返回运行时的 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:
给编辑器的选项是 allow-once、deny,运行时支持时再加一个 allow-always(interactions.ts:898)。处理逻辑处处按拒绝兜底(interactions.ts:560):
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 实现:那边先起一个真正的 HTTP 服务端,再用 SDK 连回自己;这里的适配器直接拿着 TUI 用的那个进程内运行时适配器(run-acp-command.ts:67),与 TUI、mcode exec 地位相同。
下一篇:遥测、更新与诊断——使用统计的开关、mcode update 的更新流程与诊断上报。