Skip to Content
用户指南扩展实验性 Mods

实验性 Mods

Qwen Code 可以在无头会话中运行 Claude Code Mod API 的一个小子集。 此功能为 opt-in,需要 trusted workspace 以及已启用的 Claude 或 原生 Qwen 扩展。Agent Plugins v1 包将继续忽略 hook。

在 Qwen 设置中添加以下配置:

{ "experimental": { "mods": true } }

创建一个包含以下文件的插件目录:

hello-mod/ .claude-plugin/plugin.json hooks/hooks.json hooks/register.js

.claude-plugin/plugin.json:

{ "name": "hello-mod", "version": "1.0.0" }

hooks/hooks.json(模块路径相对于此文件):

{ "modules": ["./register.js"] }

hooks/register.js:

let calls = 0; export function register(on) { on('session.start', async ($, e, next) => { await $.command.register({ name: 'hello', description: 'Say hello without a model request', }); return next(e); }); on('command.run', { command: 'hello' }, ($, e) => ({ text: `Hello ${e.args || 'world'}! Call ${++calls}.`, })); on('session.end', ($, e, next) => { $.ui.log(`Finished ${e.sessionId}`, { to: 'debug' }); return next(e); }); }

安装插件并运行其命令:

qwen extensions install ./hello-mod --consent qwen -p '/hello Qwen' --output-format json

启动时仍需要正常的 CLI 身份验证配置。Mod 命令本身不会发起模型请求。支持 Text、JSON 和 stream-json 无头输出。 stream-json 进程在轮次之间保留模块变量。

支持的子集

层面行为
打包一个 .js、.mjs、.cjs、.ts、.mts 或 .cts ESM 入口;已安装插件内的相对静态 import 和 re-export
入口register(on, options);异步注册会被等待;类型化的非 secret options 会被复制并深度冻结
生命周期所有模块在初始 session.start 链之前注册;退出和会话替换时触发 session.end;clear/resume 不会重新加载模块
命令在 session.start 期间使用 name、description 和可选的 argumentHint 进行注册;跨插件拦截已注册的 Mod 命令;返回 {text} 或 {}
工具使用 tool.call 拦截现有的主会话工具;使用 tool.check 检查和限制原生权限决策
中间件模式和递归匹配器;显式、可重复的 next(e);.catch 和按插件的故障恢复;冻结的输入和宿主身份
API 事件command.register 和 ui.log 调用通过中间件链传递,并附带宿主提供的 origin
日志$.ui.log 返回 void 并排队传递;transcript 日志在 text 模式下使用 stderr,在 JSON 模式下使用 system/ui_log;debug 日志保留在 debug logger 中;两者都不会成为模型输入
隔离每个插件在会话 worker 内拥有独立的 QuickJS 环境;无 Node、文件系统或网络 API;内存、栈、源码/输出和执行限制

这是一个受支持的子集,并非与现有 Claude Mods 的完全兼容。 JSX/TSX、运行时包导入、内置命令拦截、工具注册和活跃的 $.tool API、context、 state/store、渲染、流式事件和热重载均未实现。 命令尚不能返回 context、exitCode 或引擎引用。

两个 Mod 不能注册相同的命令名:后注册的会被拒绝。 与 Qwen 内置命令的冲突仍使用 Qwen 的命令冲突策略: 扩展命令会被重命名为 extensionName.commandName。已禁用的斜杠命令 名称会与重命名后的名称进行匹配。

中间件与恢复

模式在 session.start、session.end、command.run、 command.register、ui.log、tool.call 和 tool.check 之间进行选择。使用精确名称、command.* 这样的前缀、 *、或单个否定如 !session.*。其他具体事件以及 !* 和 !!... 模式会被拒绝。

匹配器是部分对象。所有指定字段必须匹配;数组表示 备选值,嵌套对象匹配其自身字段,正则表达式 测试字段的字符串表示。匹配器在注册时被复制。 它们最多允许八层和 256 个节点。有状态的 g/y 正则表达式和嵌套 量词会被拒绝;超过 65,536 个字符的正则输入会保守匹配, 将决定权留给 hook。嵌套对象是数据,而非 startsWith 之类的操作符。

一个精确事件可以有一个不带匹配器的注册和多个 带匹配器的注册,包括相同的匹配器。每个通配符或否定 模式只能注册一次。在插件内部,注册顺序决定 链的顺序;消费者在其声明的依赖提供者之前运行。一个 handler 可以提前响应,包括在 session.start 时,阻止后续 handler 运行。 在 command.run 上只有命令 args 可以被重写;生命周期和 命令身份字段保持不变。

工具拦截

tool.call 以扁平字段的形式接收 Qwen 工具的参数, alongside tool 和 tool_use_id。名称、参数 schema 和输出记录使用 Qwen 的原生 约定,因此为 Claude 内置工具编写的 Mod 可能需要适配。 没有单独的 tool.result 事件;检查或修改 await next(e) 返回的值:

export function register(on) { on('tool.call', { tool: 'write_file' }, async ($, e, next) => { if (!e.file_path.endsWith('.txt')) return { deny: 'Only text files are allowed' }; return next({ ...e, content: e.content.trim() }); }); on('tool.check', async ($, e, next) => { const verdict = await next(e); if (e.tool === 'run_shell_command') { return { decision: 'deny', reason: 'Shell is disabled by this Mod' }; } return verdict; }); }

每次普通的 tool.call next 都会启动一次新的原生工具尝试:验证、 经典 hook、权限、审批和调用守卫仍然全部适用。 额外的尝试计入 --max-tool-calls;catch 重放不增加 尝试次数。带有工具中间件的会话会序列化工具调用,包括并行的 next 调用,因为重写的参数可能引入副作用。一个根 调用最多允许 64 次原生尝试和 32 个未完成的宿主操作。 60 秒的根限制包括原生执行和等待审批的时间。 在未完成的 next 完成之前返回会取消该工作并 等待其结束。

tool.check 在原生验证和 经典输入重写之后接收 {tool, input, tool_use_id}。所有字段都被固定。其 next 返回声明式的 {decision: 'allow' | 'ask' | 'deny', reason?},不执行工具。 Qwen 以限制性方式合并结果:Mod 不能将原生的 deny 或 ask 升级为 allow。Mod 的 ask 遵循当前审批模式的现有决策 流程;需要的人工交互和经典的强制 ask 仍然具有权威性。 这有意比 Claude 的消费者权限覆盖更严格。 如果原生 PermissionRequest hook 重建了参数,它们会被再次 检查。Mod 编写的 rule、hook 或 ceiling 不能创建宿主权限。

返回 {deny: reason} 以阻止,或 {result: value} 以提供 JSON 结果 而不执行。原生 next 返回 {ref, result, text, isError?}。 result 是原生函数响应记录,通常是 {output: ...} 或 {error: ...}。返回的 ref 如果 result 未变则保留原始 输出,包括图像和输出元数据;更改该引用旁边的 text 或 error 标志 不会改变它。宿主执行事实会在 所有尝试中保留,即使选择了更早的结果。要更改输出, 返回新的 result: {result: {output: 'Replacement text'}}。仅包含字符串 output/error 字段的记录保留该结构;其他 JSON 会完整序列化到 output 字符串中,因此原生输出预算仍然适用。宿主拥有 函数响应:JSON 不会被解释为 ID、媒体部分或执行事实。 未知的 ref 和 isReadOnly 声明不授予来源。结果过大 无法放入 Mod payload 时会包含省略通知和 ref;保持该对 不变可以保留完整的原生响应。

原生错误、审批要求和取消不能通过更改 Mod 结果变为 成功执行。next 之后的 deny 不能 撤销工具的效果:Qwen 会报告工具已经运行。同样的情况适用 于当更早的尝试已运行而后续尝试被拒绝时。不支持上下文注入 和请求元数据。

工具身份、调用 ID、consent 和宿主元数据都被固定。与 tool、tool_use_id、agentId、consent、 requestMeta 或 $shadowed 冲突的原生 参数名会被保存在宿主创建的、固定的 $shadowed 记录中,并在执行时恢复。有效的延迟 tool_call 请求会暴露 已解析目标的名称和参数,同时保留包装器的原生 权限门控。无效的包装器、未知的工具、固定策略的调用和 合成的 structured_output 终端工具保持其原始路径。 tool_search 和 search_memory 也保持原生,因此 guest 编写的结果不能 继承其原生输出预算豁免。 Goal 工具调用也保持原生路径:此阶段不允许 Mod 编写的 结果作为外部证据进入 Goal 的目录。 子代理和嵌套的代码模式调度器不继承此中间件。

命令恢复

export function register(on) { on('command.run', { command: /^hello/ }, async ($, e, next) => { const result = await next({ ...e, args: e.args.trim() }); return { text: `[wrapped] ${result.text || ''}` }; }).catch(($, e, next) => next(e)); }

普通的 next 在每次调用时执行下游。一个插件的注册构成 一层,共享一个故障记录和时间预算。在抛出、超时或 无效结果时,其 catch 按注册顺序尝试,直到一个返回 非 undefined 的答案或调用 next。返回 undefined 或抛出 而不调用 next 的 catch 允许下一个匹配的 catch 运行。与 Claude Code 2.1.296 一样,无效的答案或 next 之后的故障会结束 catch 选择并使用 共享的回退记录;不会尝试后续的 catch。catch 中的 next.called 表示插件在恢复开始之前已进入下一个插件,而不仅仅是 进入自身的另一个注册。

如果插件已经调用了下游,恢复会等待并重放最后 启动的调用,包括其拒绝,忽略重放参数。如果没有, catch 的第一次 next 运行下游一次,后续调用会重放它。一个 失败的 catch 使用相同的记录。没有成功的 catch 时,插件会被 跳过或保留其记录的结果。无人响应的已注册命令 会显式失败;不会发送给模型。

next 暴露 event、is(pattern)、冻结的 origin、signal,以及带有 ms 和 remainingMs 的新冻结 budget 快照。Catch 额外接收 called 和 error.kind(throw、timeout 或 re-entry)。引擎事件使用 origin { plugin: 'engine', tier: 'core' };Mod API 事件以 tier user 标识调用插件。 Managed-tier 的 next.to、tracing 和自定义命名空间的 lent 恢复不可用。

API 事件从链头开始。其输入是命令规格或 {text, to} 日志 payload。返回 next(e)、{value} 以响应,或 {deny: reason} 以拒绝 API 调用。立即的自重入会跳过该注册;活跃的 祖先注册可以使用其 catch。重入 catch 不能调用 API、 其 next 只能重用原始事件一次。ui.log 保持为 void 通知: await $.ui.log(...) 不等待拦截器。无头边界在 返回输出之前 drain 排队的通知。

一个普通的插件层有 10 秒的自有时间,不包括等待跨插件 next 或 API 调用的时间。待处理的 void ui.log 通知不会暂停 调用者的预算。Catch 和重入恢复使用一秒的预算。 重入派发与该插件中的其他注册共享这个更短的预算。Qwen 还限制每个根派发为 60 秒,每个 不间断的 guest 执行片段为 10 秒。所有 session.end 工作共享 1.5 秒。提前完成的外部 handler 会放弃待处理的下游 工作;取消永远不会启动回退操作。保留的 next/API 函数 在其调用结束后不能执行工作。无法在 250 毫秒内确认 取消的 worker 会被终止,需要新会话。

最多有 32 个 Mod 环境、64 个已注册命令、每个插件 128 个注册、 以及每个根调用 4,096 个根/API 事件链。穿过另一个 插件不会消耗额外的事件。每个环境有 64 MiB 内存限制。日志和消息 payload 也有边界。注册、编译 和配置故障仍然会拒绝初始化;普通的 hook 故障 可以在不重置模块状态的情况下恢复。Guest promise job 仅在宿主 请求活跃时运行,包括其排队的通知 drain。

在此版本中,Mod 不在 TUI、ACP、managed/SSH、沙箱或会话代理入口点中 运行。安全模式、裸模式和 disableAllHooks 也会阻止加载。 更改插件代码、options 或扩展激活后需要重启。已加载的 命令在执行前会重新检查每个参与的插件,并拒绝已更改 或禁用的中间件链。外部设置编辑在重启后生效。 具有绝对或外部 hooks manifest 路径的原生 Qwen 扩展继续 仅使用经典 hook 加载器。转换已经忽略的 Claude hooks 路径(因为它们是绝对的或在扩展外部)也会被 Mod 发现跳过。Mod manifest 必须是扩展内的相对路径。

TypeScript 与相对导入

将 hooks 入口改为 ./register.ts 以使用 TypeScript。Import 和 re-export 可以使用相对路径、无扩展名路径、目录 index 文件、 或 TypeScript 文件的 JavaScript 拼写(./helper.js 可以解析到 helper.ts)。精确文件优先。接下来是 .js → .ts、.mjs → .mts、以及 .cjs → .cts 替换,然后是 .ts、.js、.mjs、.cjs、.mts、 .cts 候选,依次尝试每个文件然后是其目录 index。所有六个 后缀都使用 ESM,包括 .cjs 和 .cts。支持模块循环和一个插件内的共享导入 状态。

编译在可取消的 worker 内使用固定选项;不会 类型检查或加载 tsconfig.json、包解析器或构建插件。Type-only import,包括来自 claude-code 的类型,会被擦除。来自 claude-code 的运行时值 import 需要此子集之外的 API,会被拒绝, 包/Node/URL import、动态 import、require、JSX 和导入的顶层 await 也是如此。Guest 代码继续仅在 QuickJS 中执行。

插件 options

在插件 manifest 中声明 userConfig:

{ "name": "hello-mod", "version": "1.0.0", "userConfig": { "greeting": { "type": "string", "title": "Greeting", "description": "Greeting used by /hello", "default": "Hello" } } }

在用户或系统设置中配置 options,然后从 register 的 第二个参数中读取 options.greeting:

{ "experimental": { "mods": true }, "pluginConfigs": { "hello-mod": { "options": { "greeting": "Welcome" } } } }

字段需要 type、title 和 description。支持的类型有 string、 number、boolean、directory 和 file。可选约束包括 required、default、数值 min/max、字符串 multiple、以及字符串 options 选项。文件和目录值是字符串,不进行存在性 检查或增加文件系统访问。sensitive: true 明确不受支持, 即使提供了默认值;没有 secret 存储或配置 UI。

Qwen 使用 SystemDefaults → User → System 优先级,合并各个 option 键。即使在 trusted workspace 中也会忽略 workspace 设置。Qwen 将 没有 marketplace 来源的扩展映射到 inline 身份。它们接受 name 和 name @inline;后者在每个设置源中优先。 Marketplace 插件使用其实际的 name @marketplace 身份。更高 优先级源会胜过来自较低优先级源的任一本地别名。

与检查的 Claude 实现一样,缺失的可选值变为 '' 除非有默认值;可选的 '' 也会绕过类型检查。必需 值会拒绝缺失或 '',而 false 和 0 仍然有效。无效的已存储 字符串选项会回退到默认值。在非空 schema 下,未知的 option 键也会传递,仅限于字符串、数字、布尔值或字符串数组。 空或空的 schema 会接收 {}。Options 在重启前保持冻结。

已安装的依赖

Manifest 的 dependencies 接受插件名、name @marketplace、或对象 如 { "name": "base", "marketplace": "team", "version": "^1.0.0" }。 裸名引用消费者的 marketplace。同名的已安装 inline 插件可以 覆盖 marketplace 依赖,不进行版本检查。 否则,版本范围会与已安装扩展的 manifest 版本进行检查。没有 Mod 的普通扩展可以满足依赖。 当从选定的 Mod 可达时会检查其传递依赖; 不相关的普通扩展的依赖声明不参与。

依赖必须已安装、启用并为此调用选定。 Qwen 不会自动获取、安装或启用它们。选定的 Mod 会 以稳定的消费者在依赖之前的顺序注册,用于 Mod 之间的直接依赖; 普通提供者不是注册节点。每个 Mod 有独立的 realm;事件按此顺序穿过其 handler,允许 跨插件拦截而不共享模块变量。

缺失、模糊、不兼容或循环依赖会在 任何 Mod worker 启动之前拒绝启动。无效的 Mod options 也会拒绝启动。这不同于 Claude 的选择性依赖降级和循环顺序回退;Qwen 保留了实验性子集的全会话启动失败策略。

资源限制

读取器接受已安装扩展内最大 256 KiB 的常规文件,并 拒绝该根内的符号链接。图限制为 64 个文件、1 MiB 输入和 1 MiB 编译代码。这些边界和符号链接拒绝比 Claude 的更严格。每个 guest 有 64 MiB 内存限制和 1 MiB 栈限制。 上述执行预算包括所有 worker 初始化、编译和启动 hook 的 60 秒启动限制。Guest 内存 限制不包括编译器的宿主内存。

作者 API 基于 Claude Code 2.1.296 声明和 官方 Mod 文档 。

Last updated on