ClaudeClaude Code系列第 13 / 15 篇
Claude Code mod 进阶:拦工具调用、给提示词加上下文、加工具、调模型、画一个带输入框的窗格
会写第一个 mod 之后的下一步:一个 mod 里常用的七类写法,各配一段能运行的代码。本站把它们写成一个示例 mod,在服务器上用测试套件和真实会话都跑了一遍,文末附事件、接口和各项上限的速查表。
本站已实测,测试日期:2026-10-13。
本页目录(14 节)
这篇解决一个问题:第一个 mod 跑起来之后,想让它真的管点事,拦命令、补上下文、给 Claude 加工具、弹一个面板,各该怎么写?
mod 是什么见《Claude Code Mods 是什么》,从零做一个见《做第一个 mod》,怎么测和排错见《mod 的测试和排错》。这一篇接着往下,把官方的事件、接口、界面三页里最常用的写法做成一个示例 mod,叫 guard-desk。
| 实测环境 | |
|---|---|
| 日期 | 2026-10-13 |
| 版本 | Claude Code 2.1.296,Linux 服务器 |
| 模型 | Sonnet 5.5 |
| 连接方式 | 通过酷站AI 中转站,不是直连官方 |
| 做法 | claude plugin validate 和 claude plugin test;无界面运行 9 次;交互式会话两段 |
先说实测的结论:
- 七类写法都跑通了:拦命令、工具跑完后记账、给提示词加上下文、在回答下面加一行、给 Claude 加一个工具、调一次模型、画窗格。
- mod 自己调模型时要指定中转站有的模型。 官方例子里用的
haiku,在这家中转站上没有,调用拿到的是“没有回答”。 - 窗格里的输入框要加
autoFocus: true,才能一打开就直接打字。 第一次实测没有加,打的字进了下面的提示框;补上之后在真实终端里能打字、能提交,见第八节。按钮是用测试套件验证的。
准备:三个文件
mod 就是一个多了代码的插件:
guard-desk/
├── .claude-plugin/plugin.json # 名字、版本、描述
├── hooks/hooks.json # 指向代码:{ "modules": ["./register.js"] }
├── hooks/register.js # 代码,导出 register(on)
└── tests/guard-desk.test.ts # 测试,可选
register.js 的开头是几个变量和一个单独写出来的函数,后面各节的代码都在 register(on) 里面:
// guard-desk:一个把常用的几类 hook 放在一起的示例 mod。
const PANE = 'desk'
const RISKY = /\brm\s+-rf?\b|\bgit\s+push\b.*--force/
let blocked = [] // 被拦下的命令
let changed = [] // 这一轮改过的文件
let extra = [] // 在窗格里另外加的词
// 拦命令的 hook 单独写成一个函数,下面好给它挂出错时的处理
const guard = async ($, e, next) => {
const hit = RISKY.test(e.command) || extra.some((word) => e.command.includes(word))
if (!hit) return next(e)
blocked.push(e.command)
$.ui.invalidate('ui.render')
// 不调用 next,命令就不会执行;deny 的文字是写给 Claude 看的
return { deny: '这条命令被 guard-desk 拦下了。这个项目不允许直接删除目录或强制推送,换一种做法,或者先问用户。' }
}
一、先记住 hook 的三种用法
每个 hook 是一个函数,夹在“事件”和“Claude Code 本来要做的事”之间。它拿到三样东西:$ 是 mod 能调的接口,e 是事件的内容,next 是“接着往下走”。
| 想做什么 | 怎么写 |
|---|---|
| 只看不改 | 做完自己的事,return next(e) |
| 在事件之后做事 | const result = await next(e),做事,再 return result |
| 改写事件 | return next({ ...e, 字段: 新值 })。e 是冻结的,直接赋值会抛错 |
| 自己应答 | 不调用 next,直接返回结果。后面的 mod 和 Claude Code 自己的行为都不会运行 |
on 的第二个参数可以放一个过滤器,只处理一部分事件:
on('tool.call', { tool: 'Bash' }, hook) // 字符串:只匹配这一个
on('tool.call', { tool: ['Edit', 'Write'] }, hook) // 数组:其中任何一个
on('tool.call', { tool: /^mcp__github__/ }, hook) // 正则:按模式
同一个事件、不带过滤器,只能注册一次,注册两次模块会加载失败。
命令和工具要在 session.start 里注册,Claude Code 会等这个 hook 跑完才接第一条提示:
on('session.start', async ($, e, next) => {
await $.command.register({ name: 'desk', description: '打开 guard-desk 窗格,看拦下了哪些命令' })
await $.command.register({ name: 'label', description: '让模型给一句话贴标签', argumentHint: '<一句话>' })
await $.tool.register({
name: 'project_note',
description: '读取这个项目的约定:发布流程和禁止的操作。回答和项目约定有关的问题之前先调用它。',
inputSchema: { type: 'object', properties: {}, required: [] },
})
return next(e)
})
二、拦工具调用
tool.call 在工具要执行之前触发,子代理的调用和 MCP 工具的调用也算。不调用 next、返回 { deny: 理由 },命令就不会执行,Claude 读到的工具结果就是这段理由,所以理由要写成它能照着办的话。
拦命令的函数在上面的开头部分,这里把它挂上去,并且给它加一个出错时的处理:
// 一、拦命令。guard 自己出错时也按“拦下”处理,而不是放行
on('tool.call', { tool: 'Bash' }, guard).catch(async ($, e, next) => {
if (next.called) return next(e)
return { deny: '命令检查出错了,这条命令没有执行:' + next.error.kind }
})
.catch 那一段值得多说一句。hook 自己抛错或者超时的时候,默认是被跳过,命令照常执行。 对一个负责拦截的 hook 来说,这等于失效时自动放行。加上 .catch,出错时由它来应答:next.called 是真,说明原来的 hook 已经放行并拿到了结果,原样交回;否则就拒绝。
实测
无界面模式,让它“直接运行 rm -rf build”,把工具调用和结果打出来:
工具调用: Bash {"command": "rm -rf build", "description": "Remove the build directory"}
工具结果: (标为出错) <tool_use_error>这条命令被 guard-desk 拦下了。这个项目不允许直接删除目录或强制推送,换一种做法,或者先问用户。</tool_use_error>
最后的回答: `rm -rf build` 被项目里的 guard-desk 拦下了。它不允许直接删除目录或强制推送,所以 `build` 目录没有删。 我不会换个写法绕过这个限制。……
build 目录还在
命令没有执行,也没有出现权限提示:hook 没调用 next,流程到不了权限检查那一步。
除了拒绝,还能做什么
- 改参数:把改过的参数传给
next。 - 重试:结果里有
isError时再调一次next(e)。 - 自己给结果:返回
{ result },工具不运行。 - 事后扣下结果:
await next(e)之后返回{ deny: 理由 }。工具已经跑了,做过的事不会撤销,只是 Claude 看不到结果。 - 先问用户:
await $.ui.ask('要运行吗?', ['运行', '拒绝']),工具调用会一直等着。无界面模式里没人可问,这个调用会抛错,所以要先把答案默认成拒绝。
它和设置里的 hook 谁先谁后
- 组织策略里的
PreToolUsehook 最先,它拦下的调用 mod 看不到。 - 然后是各个 mod 的
tool.call,按顺序一层套一层。 - 其他设置文件里的
PreToolUsehook 排在最后一个 mod 调用next之后。所以 mod 不调用next直接应答的话,你写在设置里的PreToolUsehook 也不会运行。
还有一个事件叫 tool.check,在权限规则和上面那些 hook 都判断完之后触发,适合“要看当时的情况才能决定”的场合,比如只在 main 分支上禁止推送。
三、工具跑完之后做事
先 await next(e) 让权限检查和工具都跑完,再看结果:
// 二、工具跑完之后记一笔:先等 next,再看结果
on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
const result = await next(e)
if (!result.deny && !result.isError) changed.push(e.file_path)
return result
})
被拒绝的调用,结果里有 deny;失败的调用,isError 是真。把结果原样返回,Claude 看到的就和没有这个 mod 时一样。
四、给提示词加上下文
prompt.submit 在每条提示提交之后、这一轮开始之前触发,e.text 是用户打的字。
| 想做什么 | 返回什么 |
|---|---|
| 改写提示词,记录和历史里显示的都是新的 | next({ ...e, text: 新的文字 }) |
| 加一段只有 Claude 看得到的话 | next({ ...e, context: [...(e.context ?? []), 要加的话] }) |
| 不让这条提示发出去 | { drop: '原因' },文字会回到输入框,原因是给用户看的 |
示例 mod 在提示词提到“发布”时,把上一个版本的标签查出来告诉 Claude:
// 四、提示词里提到发布时,把上一个版本的标签告诉 Claude
on('prompt.submit', async ($, e, next) => {
if (!/发布|release/i.test(e.text)) return next(e)
const git = await $.process.run(['git', 'describe', '--tags', '--abbrev=0'])
// 没有标签或者不在仓库里,命令会失败,这时什么都不加
if (git.exitCode !== 0) return next(e)
// 保留前面的 hook 加过的上下文,再添一行
return next({ ...e, context: [...(e.context ?? []), '上一个版本的标签:' + git.stdout.trim()] })
})
实测,带对照
仓库里打了一个标签 v1.4.2。同一个问题问两次,区别只是有没有“发布”两个字,并且要求它不运行命令、不读文件:
| 提示词 | 回答 |
|---|---|
| 准备发布了。上一个版本的标签是什么?…… | v1.4.2 |
| 上一个版本的标签是什么?…… | 不知道。 |
本站一开始加的是“当前分支”,结果对照组也答对了:分支名本来就在 Claude Code 给模型的 git 信息里。验证“加上下文”有没有生效,要挑一条模型自己拿不到的信息。
系统提示的各个部分、第一条消息带的上下文、skill 展开的文字,各有自己的事件(prompt.section、prompt.context、skill.prompt),见文末的表。
五、跟着一轮走
一轮是 Claude 为回答一条提示做的全部事情。三个事件跟着它:
| 事件 | 什么时候 | 能做什么 |
|---|---|---|
turn.start |
一轮开始 | 只能看。e.turnId 在另外两个事件里是同一个 |
turn.step |
每次要向模型发请求之前,带工具调用的一轮会有好几次 | 读这次请求的 token 用量,或者换模型、换 effort |
turn.complete |
一轮结束,包括被用户打断的 | 返回 { text } 在回答下面加一行 |
// 五、跟着一轮:开始时清零,结束时在回答下面加一行
on('turn.start', async ($, e, next) => {
changed = []
return next(e)
})
on('turn.complete', async ($, e, next) => {
if (changed.length === 0 && blocked.length === 0) return next(e)
return { text: '本轮改了 ' + changed.length + ' 个文件,累计拦下 ' + blocked.length + ' 条命令' }
})
turn.step 的 hook 要写成异步生成器,因为响应是流式的,用 yield* next(e) 把内容一段段往下传。这次没有用到它。
实测
交互式会话里,同样让它运行 rm -rf build。这一轮结束后,回答下面多了一行:
● guard-desk+cc-plugin-agents-md: 本轮改了 0 个文件,累计拦下 1 条命令
行首不只是本站的 mod 名,后面还跟着 cc-plugin-agents-md,那是 Claude Code 自带的一个 mod,同一个事件它也处理了。多个 mod 处理同一个事件时就是这样串在一起的。
六、给 Claude 加一个工具
命令是给人用的,工具是给 Claude 用的。注册时给名字、给 Claude 看的描述、输入的 JSON Schema(上面 session.start 那一段里有)。Claude 看到的名字更长:mcp__ 加插件名,加两个下划线,加你注册的名字。然后处理这个名字的 tool.call,直接返回结果:
// 三、自己注册的工具:不调用 next,直接给结果
on('tool.call', { tool: 'mcp__guard-desk__project_note' }, async ($) => {
return { result: await $.fs.read('NOTES.md') }
})
$.fs.read 的相对路径按会话的工作目录算。mod 的代码里没有 Node.js 的接口,也没有 setTimeout,读文件、跑进程、发请求都要走 $,权限和运行 Claude Code 的用户一样。
实测
项目里放了一个 NOTES.md,写着三条发布约定。只放行这一个工具,问“这个项目发布前有什么约定”:
回答: 根据项目工具返回的约定,发布前有三条: 1. **先测试**:发布前跑 `npm test`,全部通过才能打标签。
2. **版本和标签**:版本号写在 `package.json` 里,标签格式是 `v` 加版本号,例如 `v1.2.3`。 3. **禁止的操作**:……
模型调了这个工具,答案出自 NOTES.md。
七、在 mod 里调一次模型
分类、打标签、总结一段话这类小活,mod 可以自己向模型发请求:
$.model.complete |
$.model.fork |
|
|---|---|---|
| 用哪个模型 | 你指定的 | 会话当前的 |
| 系统提示 | 一小段署名,加你传的 system |
会话的系统提示 |
| 消息 | 只有你的 prompt |
到目前为止的整段对话,后面接你的 prompt |
| 项目上下文 | 不带 | 带 |
两种都用会话的凭据,钱算在用户头上。
// 六、调一次模型的命令
on('command.run', { command: 'label' }, async ($, e) => {
const model = (await $.env.get('GUARD_DESK_MODEL')) || 'sonnet'
const r = await $.model.complete({
model,
system: '只回答一个词:bug、feature 或 question。',
prompt: e.args,
maxTokens: 20,
timeoutMs: 20000,
})
return { text: '标签:' + (r.isAnswered ? r.text.trim() : '模型没有回答(' + r.reason + ')') }
})
接口调用失败不会抛错,要先看 r.isAnswered,是假的话原因在 r.reason。maxTokens 不写默认是 1,024。
实测:换成 haiku 就没有回答
$ claude -p "/label 导出按钮点了没反应" --plugin-dir ./guard-desk
guard-desk: 标签:bug
$ GUARD_DESK_MODEL=haiku claude -p "/label 导出按钮点了没反应" --plugin-dir ./guard-desk
guard-desk: 标签:模型没有回答(api-error)
官方文档的例子写的是 model: 'haiku'。haiku 这个别名现在指向 Haiku 5.5,这家中转站没有它,所以调用失败。给别人用的 mod,模型别写死,像这里一样留一个可以改的口子,或者用 $.model.fork 跟着会话的模型走。别名怎么钉到指定版本,见《Claude Code 的模型怎么设》。
顺带一个观察:这两次运行,无界面模式结果里的花费和模型用量都是 0。mod 自己发起的模型调用没有记进这一栏,但它确实发了请求。
八、画一个窗格
窗格是 mod 自己的一块界面:终端够宽并且开了全屏渲染时在记录旁边,否则在提示框上方。做法分两步,命令只负责“打开”,画什么由 ui.render 决定:
// 七、窗格:命令只负责打开,内容由 ui.render 画
on('command.run', { command: 'desk' }, async ($) => {
await $.ui.open({ id: PANE, title: 'Guard desk', focus: true, closeOnEscape: true })
return {}
})
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
if (e.requestId !== PANE) return next(e)
const { Box, Text, Button, Input } = $.ui.resolve(e)
const redraw = () => $.ui.invalidate('ui.render')
return Box({
flexDirection: 'column',
children: [
Text({ bold: true, children: ['拦下的命令:' + blocked.length + ' 条'] }),
...blocked.slice(-5).map((command) => Text({ children: ['· ' + command] })),
Input({
key: 'word',
label: '再拦一个词',
placeholder: '比如 DROP TABLE',
// 窗格一打开,键盘先给这个输入框,否则打的字会落到下面的提示框里
autoFocus: true,
onSubmit: (value) => {
if (value.trim()) extra.push(value.trim())
redraw()
},
}),
Text({ dimColor: true, children: ['另外拦:' + (extra.join('、') || '(无)')] }),
Button({
key: 'clear',
label: '清空记录',
hotkey: 'c',
onPress: () => {
blocked = []
redraw()
},
}),
],
})
})
几个要点:
ui.render对每个绘制位置都会触发,所以用过滤器限定到Pane,再用e.requestId认出是不是自己的窗格,别人的原样next(e)。- 元素从
$.ui.resolve(e)里取,返回的是一棵树:Box排版,Text文字,Button、Input、Select是能操作的控件。 - 每次重画都是从变量重新搭一遍。 回调里改了变量之后,调
$.ui.invalidate('ui.render')请 Claude Code 再跑一次这个 hook。 - 每个控件给一个
key,测试靠它来按按钮、填输入框。 - mod 不直接读键盘。 用户按键,Claude Code 决定这是给哪个控件的,再调那个控件的回调。
- 想让窗格一打开就能往输入框里打字,给它加
autoFocus: true。 开窗格时的focus: true只是把键盘焦点给了窗格,字要进输入框,得输入框自己有焦点。一个窗格里只给一个控件加。
窗格有键盘焦点时各个键的作用:
| 键 | 作用 |
|---|---|
| Tab | 下一个控件 |
| 回车 | 按下当前的按钮,或者提交当前的输入框 |
按钮的 hotkey |
按那个按钮。焦点在输入框里时,能打出来的键都进输入框 |
| Ctrl+X 再按 X | 关掉窗格 |
| Esc | 焦点回到提示框;开窗格时写了 closeOnEscape: true 的话同时关掉 |
实测
交互式会话里拦下一条命令之后输入 /desk:
╭──────────────────────────────────────────────────────────
│ 拦下的命令:1 条
│ · rm -rf build
│ 再拦一个词: 比如 DROP TABLE
│ 另外拦:(无)
│ [ 清空记录 ]
╰──────────────────────────────────────────────────────────
窗格画出来了,内容是对的,按 Esc 也能关掉。
往输入框里打字,第一次没有成功。 当时的示例里输入框没有写 autoFocus: true。本站是用 tmux 往终端里送按键的,打的字进了下面的提示框,被当成一条提示发给了模型;先按 Ctrl+X 再按 Tab 也没能把焦点移过去。
原因后来查清了:focus: true 把键盘焦点给的是窗格,输入框自己没有焦点。补上 autoFocus: true 之后再试(上面的代码已经是补过的),打一个词:
│ 再拦一个词: DROP TABLE ⏎ submit
回车之后:
│ 另外拦:DROP TABLE
字进了输入框,提示框是空的,提交之后这个词加进了清单。补过之后,下一节的 4 项测试重跑仍然通过。
“清空记录”这个按钮在真实终端里没有单独按过,它的行为是下一节用测试套件验证的。按钮、下拉这些控件在真实终端里怎么用键盘操作,见《mod 的界面元素一览》。
九、用测试把它们固定下来
claude plugin test 不需要会话,不联网:它把事件送进你的 hook,凡是 mod 指望 Claude Code 回答的地方,由测试里的“桩”来答。
拦命令的测试:
import { expect, test } from 'claude-code/testing'
test('rm -rf 被拦下,别的命令照常执行', async ($, on) => {
// 替 Claude Code 回答放行的那些工具调用
on('tool.call', () => ({ result: 'ok' }))
const refused = await $.tool.call({ tool: 'Bash', command: 'rm -rf build' })
expect(refused.deny).toContain('guard-desk')
const passed = await $.tool.call({ tool: 'Bash', command: 'ls' })
expect(passed.result).toBe('ok')
})
窗格的测试。$.ui.mount 把窗格画出来,返回的句柄可以按 key 来操作:
test('窗格里能看到拦下的命令,能加一个词,能清空', async ($, on) => {
on('tool.call', () => ({ result: 'ok' }))
await $.tool.call({ tool: 'Bash', command: 'git push origin main --force' })
const ui = await $.ui.mount({ ...PANE, surface: 'terminal' })
expect(await ui.find({ type: 'Text', text: /^拦下的命令:[1-9]\d* 条$/ })).toBeDefined()
// 在输入框里打字并回车
await ui.input({ key: 'word', text: 'DROP TABLE' })
expect(await ui.find({ type: 'Text', text: '另外拦:DROP TABLE' })).toBeDefined()
// 新加的词马上生效
const refused = await $.tool.call({ tool: 'Bash', command: 'psql -c "DROP TABLE users"' })
expect(refused.deny).toBeDefined()
// 按“清空记录”
await ui.press({ key: 'clear' })
expect(await ui.find({ type: 'Text', text: '拦下的命令:0 条' })).toBeDefined()
await ui.unmount()
})
PANE 是 Claude Code 画这个窗格时会传给 ui.render 的那些字段,照官方测试文档里的例子填。
四个测试的结果:
tests/guard-desk.test.ts:
(pass) rm -rf 被拦下,别的命令照常执行 [152.65ms]
(pass) project_note 工具返回 NOTES.md 的内容 [70.94ms]
(pass) /label 把模型的回答整理成一行,模型没回答时也有话说 [61.30ms]
(pass) 窗格里能看到拦下的命令,能加一个词,能清空 [98.32ms]
4 pass
0 fail
写桩时最常用的几条:
| mod 调了什么 | 桩返回什么 |
|---|---|
$.command.register、$.tool.register、$.ui.log 等 |
{ value: undefined } |
$.fs.read |
{ value: '文件内容' },e.path 是绝对路径,用 endsWith 比 |
$.process.run |
{ value: { exitCode: 0, stdout: '…', stderr: '' } } |
$.model.complete |
{ value: { isAnswered: true, text: '…', usage } } |
| 放行的工具调用 | on('tool.call', () => ({ result: 'ok' })) |
| 想让某个调用失败 | { deny: '原因' } |
所有的桩要在测试第一次调用 $ 之前注册完。
本站第一次跑测试时多出一个失败的文件 tests/._guard-desk.test.ts。那是从 Mac 打包上传时带过去的隐藏文件,名字也以 .test.ts 结尾,被当成测试加载了。删掉就好;从 Mac 往别处传插件目录时留意一下。
十、看懂 validate 的输出
❯ ./register.js hooks: session.start, tool.call{tool=Bash}, tool.call{tool=Edit|Write}, …
❯ ./register.js answers its own command: command.run{command=label}
❯ ./register.js gating hook with .catch: tool.call{tool=Bash}
❯ ./register.js gating hook without .catch: tool.call{tool=Edit|Write}
❯ ./register.js gating hook without .catch: prompt.submit
❯ ./register.js calls: $.command.register, $.env.get, $.fs.read, $.model.complete, $.process.run, …
❯ ./register.js env reads: GUARD_DESK_MODEL
✔ Validation passed
它把这个 mod 用到的事件、调的接口、读的环境变量都列了出来。装别人的 mod 之前跑一遍,就知道它会碰什么。
gating hook without .catch 那几行是提醒:这些 hook 有能力拦截或改写,但没有挂出错时的处理,出错时会被跳过。示例里只有拦命令的那个需要“出错也要拦”,其余几个出错时放行是可以接受的,所以没有加。
速查
常用的事件
| 事件 | 什么时候 | 能返回什么 |
|---|---|---|
session.start |
每个 mod 加载时一次,第一条提示之前 | next(e) |
session.end |
会话结束,或者 /clear、/resume |
next(e) |
session.compact |
要压缩对话之前 | { skip: 原因 } |
tool.call |
工具要运行之前 | next(e)、{ deny }、{ result } |
tool.check |
决定这次调用能不能运行时,在权限规则之后 | allow、ask、deny |
prompt.submit |
提示提交时 | 改 text、加 context、{ drop } |
prompt.section |
系统提示的每个部分 | { text },或者 { text: null } 去掉 |
prompt.context |
每段对话一次,第一条消息带的上下文 | { blocks } |
skill.prompt |
skill 的文字展开时 | { text } |
command.run |
命令要运行之前 | { text }、{}、next(e) |
turn.start、turn.step、turn.complete |
见第五节 | 见第五节 |
agent.spawn |
子代理要启动之前 | 换 model,或 { deny } |
ui.render |
某个绘制位置要画之前 | 一棵元素树 |
classic.<事件名> |
设置里的每一种 hook 事件,比如 classic.Stop |
next(e) |
另外,$ 上的每个方法本身也是一个事件,名字是去掉 $. 的写法,比如 fs.read、model.complete。排在前面的 mod 可以看到、改写或者拒绝后面的 mod 发起的调用,组织就是靠这个来限制 mod 能碰什么的。
$ 上有什么
| 命名空间 | 做什么 |
|---|---|
$.ui |
open、close、invalidate、resolve、toast、status、log、ask 等 |
$.command、$.tool、$.agent |
注册和调用命令、工具、子代理类型 |
$.model |
complete、fork、classify |
$.prompt |
submit 可以从后台任务里发起一轮 |
$.session |
对话记录、工作目录、当前模型、用量 |
$.fs、$.process、$.http、$.mcp |
文件、进程、网络请求、MCP 工具 |
$.store |
一个键值存储,这台机器上的所有会话共用,跨会话保留 |
$.state |
响应式的状态 |
$.clock |
every、after、sleep,后台定时任务用它 |
$.env、$.settings、$.config |
环境变量、设置 |
给用户看点东西而不开一轮,有三个地方:$.ui.status 是提示框下面常驻的一行,$.ui.toast 是几秒就消失的提示,$.ui.log 是记录里一行 Claude 看不到的灰字。
上限
| 项目 | 上限 |
|---|---|
一个 hook 处理一次事件的时间,不算等 next 和等接口的时间 |
10 秒 |
.catch 里的处理 |
1 秒 |
$.process.run |
默认 30 秒,最多 10 分钟 |
$.model.complete 的 maxTokens |
默认 1,024 |
$.fs 读写一个文件 |
4 MiB |
$.http.fetch 一次调用 |
30 秒,响应体 4 MiB,最多跟 5 次跳转 |
$.store 总共 |
4 MiB 的 JSON |
| 重画 | 每秒 10 次,终端里看得见的窗格 30 次 |
| 一个测试 | 5 秒 |
| 命令、工具、窗格的名字 | 字母、数字、下划线、连字符,最多 64 个字符 |
超时的 hook 会被跳过。等用户回答这种事放在 $.ui.ask 里面等,那段时间不算在 10 秒里。
花费
无界面的 9 次运行里有 7 次产生了费用,Claude Code 报的估算合计约 0.5 美元;两次 /label 的模型调用没有计入,金额很小。两段交互式会话的费用没有记。这是估算,不是中转站实际扣的金额。
这次实测没有覆盖到的
- “清空记录”按钮在真实终端里没有按过,只用测试套件验证了。往输入框里打字第一次没有测成,补上
autoFocus: true之后测通了。 - 只在终端里测过,桌面应用里窗格长什么样没有看。
turn.step、tool.check、$.ui.ask、$.model.fork、后台定时任务、会话之间收发消息没有测,是按文档整理的。Select、Code、Markdown等其他元素没有用到。 这些元素本站另外逐个画过,见《mod 的界面元素一览》。- 多个 mod 之间的顺序、组织策略下的行为没有测。
- mod 调模型的费用怎么算、记在哪没有查清,只看到无界面模式的结果里不含它。
- 只在一个版本、一家中转站上测过。