ClaudeClaude Code系列第 13 / 15 篇

Claude Code mod 进阶:拦工具调用、给提示词加上下文、加工具、调模型、画一个带输入框的窗格

会写第一个 mod 之后的下一步:一个 mod 里常用的七类写法,各配一段能运行的代码。本站把它们写成一个示例 mod,在服务器上用测试套件和真实会话都跑了一遍,文末附事件、接口和各项上限的速查表。

发布 · 约 18 分钟读完 · 经人工审核()· 本站的内容怎么来的

本站已实测,测试日期:2026-10-13。

本页目录(14 节)
  1. 准备:三个文件
  2. 一、先记住 hook 的三种用法
  3. 二、拦工具调用
  4. 三、工具跑完之后做事
  5. 四、给提示词加上下文
  6. 五、跟着一轮走
  7. 六、给 Claude 加一个工具
  8. 七、在 mod 里调一次模型
  9. 八、画一个窗格
  10. 九、用测试把它们固定下来
  11. 十、看懂 validate 的输出
  12. 速查
  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 谁先谁后

  • 组织策略里的 PreToolUse hook 最先,它拦下的调用 mod 看不到。
  • 然后是各个 mod 的 tool.call,按顺序一层套一层。
  • 其他设置文件里的 PreToolUse hook 排在最后一个 mod 调用 next 之后。所以 mod 不调用 next 直接应答的话,你写在设置里的 PreToolUse hook 也不会运行。

还有一个事件叫 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 调模型的费用怎么算、记在哪没有查清,只看到无界面模式的结果里不含它。
  • 只在一个版本、一家中转站上测过。

来源

通过 API 中转站使用这些模型?到 Veridrop 免费检测中转站给你的是不是它声称的模型,或者看看中转站排行榜前 100 名。

相关文章