Function hooks: the long form / 函数钩子:完整长文
The long form of the plugin-authoring skill: what a plugin of function hooks is in full, where its exact contract is written down for the build you are running in, and where to look when something does not take. SKILL.md beside this file says where a mod is written, where the types are and what happens when the turn ends. Run claude plugin validate <dir> on the plugin's folder early and often: it reads the manifest and the hooks
module's source the way the engine will and reports what the module hooks and calls and everything the engine would refuse, before a
session loads it. The API is early access and moves between releases: the declaration file is the authority, this note the map.
插件编写技能的完整长文:函数钩子插件究竟是什么、其确切契约在你正在运行的这个构建中写在哪里、以及当某处不生效时该去哪里查。本文件旁边的 SKILL.md 说明 mod 写在哪里、类型在哪里、回合结束时会发生什么。尽早并经常在插件文件夹上运行 claude plugin validate <dir>:它以引擎将会采用的方式读取清单和 hooks 模块的源码,在会话加载之前报告该模块钩住什么、调用什么,以及引擎会拒绝的一切。该 API 处于早期访问阶段,版本之间会变动:声明文件是权威,本说明只是地图。
What a plugin of function hooks is / 函数钩子插件是什么
A mod is three files written directly: claude plugin init scaffolds another kind of plugin, one of command hooks under ~/.claude/skills.
mod 就是直接手写的三个文件:claude plugin init 脚手架生成的是另一种插件,即 ~/.claude/skills 下的命令钩子插件。
A plugin is a folder with a .claude-plugin/plugin.json manifest. Its
function hooks live in one hooks module: a TypeScript or JavaScript file
that hooks/hooks.json names under modules (one path, relative to that
file), exporting register(on, options); the module, and every file it imports from the plugin, is named .ts, .tsx, .jsx, .js, .mjs, .cjs, .mts or .cts (a file named otherwise is not loaded) and is an ES module whatever its suffix. A file of the plugin is imported with an import declaration. A module holding import() does not load. on(event, matcher?, hook) adds a
hook; options holds the values of the fields the manifest's userConfig
declares. Every hook has the shape ($, e, next): $ is the engine
interface (display, model, session, prompt, tools, filesystem, store,
clock, network, host commands, settings, environment, the config menu's rows and the rest), e is the event's input as a plain value,
and next(e) continues to the other plugins and then the engine's own
behaviour, resolving to the event's result. A hook that returns without
calling next answers for itself; one that calls next({ ...e, ... })
rewrites what the rest of the chain sees, within what that event allows.
The module runs in an environment of its own, with no DOM and no Node:
everything outside it is reached through $. JSX is available with h as the factory.
插件是一个带有 .claude-plugin/plugin.json 清单的文件夹。它的函数钩子位于一个 hooks 模块中:一个由 hooks/hooks.json 在 modules 下指名的 TypeScript 或 JavaScript 文件(一个路径,相对于该 json 文件),导出 register(on, options);该模块以及它从插件导入的每个文件,必须命名为 .ts、.tsx、.jsx、.js、.mjs、.cjs、.mts 或 .cts(其他命名的文件不会被加载),且无论后缀如何都是 ES 模块。插件内的文件用 import 声明导入。持有 import() 的模块不会加载。on(event, matcher?, hook) 添加一个钩子;options 持有清单中 userConfig 所声明各字段的值。每个钩子的形状都是 ($, e, next):$ 是引擎接口(显示、模型、会话、提示词、工具、文件系统、存储、时钟、网络、宿主命令、设置、环境、配置菜单的行等),e 是作为普通值的事件输入,next(e) 继续传给其余插件、再到引擎自身的行为,并解析为该事件的结果。不调用 next 直接返回的钩子自行作答;调用 next({ ...e, ... }) 的钩子则改写链上其余部分所见的内容,以该事件允许的范围为限。模块运行在它自己的环境中,没有 DOM 也没有 Node:外部一切都要通过 $ 触达。JSX 可用,以 h 作为工厂函数。
The events cover tool calls and their descriptions, the rows the conversation keeps (session.append), the prompt as submitted, the system prompt's sections and the first message's context blocks, what the interface
draws, the turn's start, steps and completion, the session's start, end (a /clear too: session.end with reason: 'clear', and no session.start after it) and deliveries, each hooks module's admission, skills, subagents and attribution text. The settings hooks' own events are hookable as classic.<Event> (classic.Stop, classic.SessionEnd), e being what that hook receives on stdin, transcript_path and the other base fields included. Which of them a
feature is, and what it needs from $, are the two questions worth settling before writing. Two events stream, turn.step (a model request of the turn) andprocess.spawn (a child's output, piece by piece; for the caller $.process.spawn({ argv }) is the stream and the loop's end is the child's): a hook on either is an
async generator (async function* ($, e, next) {}, the one form that loads there); next(e) is the stream beneath, yield* next(e) forwards it and evaluates to the
result, for await over it rewrites the chunks one at a time, yielding without next answers alone, and a hook that fails mid-stream is left where it stood. A telemetry event is hooked by name (telemetry.* names them all; * and a negation do not select one), and the hook picks its stream in the matcher, to written as a string literal or a list of them (on("telemetry.log", { to: "collector" }, hook)): collector is the customer's OpenTelemetry collector, anthropic is Anthropic's own analytics and is the stream the plugins built into the CLI stand on, and claude plugin validate lists the streams a module's hooks stand on.
这些事件覆盖工具调用及其描述、会话保留的行(session.append)、提交的提示词、系统提示词的各个部分和第一条消息的上下文块、界面绘制的内容、回合的开始、步骤与完成、会话的开始、结束(一次 /clear 也算:session.end 且 reason: 'clear',其后没有 session.start)与投递、每个 hooks 模块的准入、技能、子代理与署名文本。设置钩子自身的事件也可以按 classic.<Event> 被钩住(classic.Stop、classic.SessionEnd),e 就是该钩子从 stdin 收到的内容,包含 transcript_path 等基础字段。某个功能对应哪个事件、它需要从 $ 拿到什么,是动笔前值得弄清的两个问题。有两个事件是流式的:turn.step(回合中的一次模型请求)和 process.spawn(子进程输出,逐段送达;对调用者而言 $.process.spawn({ argv }) 就是这个流,循环的终点即子进程的终点):挂在二者之上的钩子是异步生成器(async function* ($, e, next) {},这是能在那里加载的唯一形式);next(e) 是底下的流,yield* next(e) 转发它并求值为结果,对它做 for await 可以逐块改写,不经过 next 的 yield 则自行作答,而在流中途失败的钩子就停在原地。telemetry 事件按名称钩住(telemetry.* 匹配全部;* 和取负不能选中单个),钩子在 matcher 中挑选自己的流,to 写作字符串字面量或它们的列表(on("telemetry.log", { to: "collector" }, hook)):collector 是客户的 OpenTelemetry 收集器,anthropic 是 Anthropic 自己的分析流,也是 CLI 内置插件所依赖的流,claude plugin validate 会列出某个模块的钩子依赖哪些流。
The types are the reference / 类型即参考
types/claude-code.d.ts beside this file is this build's declaration of the
whole API, written by the engine as the skill loaded: it declares the moduleclaude-code (import types from it; at run time the import is empty), the
globals a hooks module has, and the inputs of this build's built-in tools, soe narrows per tool. Its header carries a tsconfig.json that fits a hooks
module and shows how to type register against Register. A mod the engine
loads from a folder the person owns (the mods folder after Enable for this session, a --plugin-dir or CLAUDE_CODE_PLUGIN_DIRS folder) has the same
declarations laid beside it, in .claude-plugin/types/, at every load and
reload: claude-code/index.d.ts, the API; claude-code-tools/index.d.ts, this
build's built-in tools; claude-code-mcp/index.d.ts, the MCP tools connected
when the mod last reloaded (refreshed when you save the mod; a restart
never replaces it); one entry per plugin its plugin.json lists underdependencies (below); and a tsconfig.json its own extends, so its editor
and tsc -p <mod folder> type it with no step taken. There is no command to
run. The built-in tools' table is what this build registers on this machine: a
tool another build has (Glob and Grep, which a native macOS or Linux build
does not register) is compared through String(e.tool).
本文件旁边的 types/claude-code.d.ts 是这个构建对整个 API 的声明,由引擎在技能加载时写出:它声明 claude-code 模块(从它导入类型;运行时该导入为空)、hooks 模块拥有的全局量,以及本构建内置工具的输入,因此 e 可按工具收窄。它的头部附带一个适合 hooks 模块的 tsconfig.json,并展示如何对照 Register 为 register 定型。引擎从用户拥有的文件夹加载的 mod(点按 Enable for this session 之后的 mods 文件夹、--plugin-dir 或 CLAUDE_CODE_PLUGIN_DIRS 文件夹),在每次加载和重载时都会在旁边得到同样的声明,位于 .claude-plugin/types/:claude-code/index.d.ts,即 API;claude-code-tools/index.d.ts,即本构建的内置工具;claude-code-mcp/index.d.ts,即该 mod 上次重载时连接的 MCP 工具(保存 mod 时刷新;重启从不替换它);其 plugin.json 在 dependencies 下列出的每个插件各有一个条目(见下文);以及一个供它自己 extends 的 tsconfig.json,因此其编辑器和 tsc -p <mod folder> 无需任何步骤即可为其定型。没有需要运行的命令。内置工具表是本构建在这台机器上注册的内容:另一个构建才有的工具(Glob 和 Grep,原生 macOS 或 Linux 构建不注册它们)要通过 String(e.tool) 比较。
Read that file for every event's input and result, every noun and method on$ with its doc comment and example, every element each surface draws and
the props each element accepts, and the limits it states. Shapes there are
the engine's own, not the Messages API's: $.session.messages(), for one,
answers SessionMessage rows of { role, text, toolUses }, not content
blocks. When the build updates, regenerate rather than edit.claude plugin validate <path> reads a plugin's manifest and its hooks
module's source and reports what the module hooks and calls, which is the
quickest check that the engine sees what you meant. A plugin that adds a noun to $ in engine.create ships that noun's types as a contract: one self-contained .d.ts (say types/index.d.ts) that exports the noun's types at its top level and declares the noun on the engine's interface, export type Topo = { ... } then declare module 'claude-code' { interface EngineInterface { topo: Topo } }, with no import or reference, its exported names led by the noun's PascalCase name (Topo, TopoRun), named in plugin.json as "types": "./types/index.d.ts". The plugin's own hooks module imports those types from that file, so the contract is the one place they are written. A plugin that depends on it never copies the file: it lists that plugin under dependencies in its own plugin.json, and each time the engine loads it from a folder the person owns it lays that plugin's contract into .claude-plugin/types/<plugin>/index.d.ts, so the noun is typed on the dependent's $; claude plugin validate checks the contract.
读那个文件可以了解每个事件的输入和结果、$ 上的每个名词和方法及其文档注释与示例、每个界面绘制的每个元素及各元素接受的 props,以及它声明的限制。那里的形状是引擎自己的,不是 Messages API 的:例如 $.session.messages() 返回的是 { role, text, toolUses } 形状的 SessionMessage 行,而不是 content 块。构建更新时,重新生成而不是手改。claude plugin validate <path> 读取插件的清单和 hooks 模块的源码,报告模块钩住了什么、调用了什么,这是验证"引擎所见即你所想"的最快检查。在 engine.create 中向 $ 添加名词的插件应将该名词的类型作为契约交付:一个自包含的 .d.ts(比如 types/index.d.ts),在顶层导出该名词的类型并在引擎接口上声明该名词,先 export type Topo = { ... } 再 declare module 'claude-code' { interface EngineInterface { topo: Topo } },不带任何 import 或 reference,其导出名以该名词的 PascalCase 名字开头(Topo、TopoRun),并在 plugin.json 中以 "types": "./types/index.d.ts" 指名。插件自己的 hooks 模块从该文件导入这些类型,因此契约就是写下它们的唯一之处。依赖它的插件绝不复制该文件:它在自己 plugin.json 的 dependencies 下列出那个插件,而引擎每次从用户拥有的文件夹加载它时,会把那个插件的契约放进 .claude-plugin/types/<plugin>/index.d.ts,于是该名词在依赖方的 $ 上就有了类型;claude plugin validate 会检查契约。
Developing one / 开发一个插件
A mod made in this session goes where SKILL.md's WHERE TO WRITE IT says and loads as it describes. The rest of this section is the other ways a plugin is run from disk.
在本会话中制作的 mod 按 SKILL.md 的 WHERE TO WRITE IT 所说的地方存放,并按其描述加载。本节其余部分是从磁盘运行插件的其他方式。
claude --plugin-dir <folder> loads the plugin from disk for that session only (repeat the flag for several). CLAUDE_CODE_PLUGIN_DIRS names the same folders where no flag can be given (a session the desktop app or an SDK host starts): one or more absolute paths (~ allowed) separated by the platform's path-list separator, each loaded exactly as a --plugin-dir, taken from the process environment or the env block of ~/.claude/settings.json (never a project's settings). In an interactive session the folder is watched, as is a plugin auto-loaded from a skills folder (~/.claude/skills/<name>, the project's .claude/skills/<name>): saving a file reloads the hooks module, soregister runs again in a fresh environment and the previous environment's timers are dropped. Saves made while the session's own turn runs (the model editing the plugin) reload once, when the turn ends, or sooner when a tool or command the plugin registered is about to run, so the turn can try what it wrote. Saves from anywhere else reload once the folder has been quiet: a lone save within a quarter second, a run of saves seconds apart once the run stops. A headless claude -p always loads fresh, and a long-lived headless session (SDK, desktop) watches too when CLAUDE_CODE_PLUGIN_DIR_WATCH=1 is set the same way, its reload lines reaching the host as ui_log messages and the debug log.
Options for a plugin loaded this way are read from settings under pluginConfigs, keyed by the plugin's <name> (or <name>@inline); each non-secret userConfig field is a row in the config menu too, and a change there reloads the module with the new options. A string field that lists options ("options": ["gist", "turbo"], its default among them) is a picker over exactly those values there, and a stored value outside them counts as unset.
claude --plugin-dir <folder> 仅在该会话中从磁盘加载插件(重复该标志可加载多个)。CLAUDE_CODE_PLUGIN_DIRS 在无法给出标志的场合(桌面应用或 SDK 宿主启动的会话)指名同样的文件夹:一个或多个绝对路径(允许 ~),以平台的路径列表分隔符分隔,每个都严格按 --plugin-dir 的方式加载;取自进程环境或 ~/.claude/settings.json 的 env 块(绝不取项目的设置)。在交互式会话中,该文件夹会被监视,从技能文件夹自动加载的插件也一样(~/.claude/skills/<name>、项目的 .claude/skills/<name>):保存文件会重新加载 hooks 模块,register 在新环境中再次运行,先前环境的定时器被丢弃。会话自身回合运行期间(模型在编辑插件)的保存只在回合结束时重载一次,或在插件注册的工具或命令即将运行时更早重载,让回合能尝试它写下的东西。来自其他任何地方的保存都在文件夹安静之后重载:四分之一秒内的单独一次保存立即生效,间隔数秒的一串保存则在这串停止后生效。无头的 claude -p 总是全新加载,而长时运行的无头会话(SDK、桌面)在以同样方式设置 CLAUDE_CODE_PLUGIN_DIR_WATCH=1 时也会监视,其重载行以 ui_log 消息和调试日志的形式抵达宿主。
以这种方式加载的插件,其选项从设置的 pluginConfigs 下读取,以插件的 <name>(或 <name>@inline)为键;每个非机密的 userConfig 字段也是配置菜单中的一行,在那里更改会用新的 options 重载模块。列出 options 的 string 字段("options": ["gist", "turbo"],其 default 在其中)在那里就是一个恰好取这些值的选择器,存储的值若在此外则视为未设置。
Run with claude --debug while developing. A hook that fails is skipped and the chain continues without it, unless its registration's .catch handler
answers in its place; while the session hot-reloads a plugin folder (the mods folder once hot reloading is on, a --plugin-dir or CLAUDE_CODE_PLUGIN_DIRS folder an interactive session watches) the transcript says so once, in a dim line naming the
plugin, the event and the reason, as it names a module that did not load, and in any other session that line goes to the debug log alone, as <plugin>: <line> (a claude -p run has no transcript: one printing text names a --plugin-dir plugin whose module was not loaded, or failed to load, once on stderr with the reason, the switch being off included; a json or stream-json run keeps it in the debug log). The debug log has a line for every occurrence and each result the engine
refused; a skipped hook's line has the error's name and message length in place of its text (the first one's text, cut to a short line, is on that transcript line, which the debug log has too), so a plugin that seems to do nothing has usually been told why. claude plugin test <folder> runs the plugin's *.test.ts files against the engine itself: a test holds the engine's $ and an on whose hooks sit beneath the plugin (import test, expect, mock from claude-code/testing; the typings say the rest). Nothing sits beneath those hooks: they stand for the engine, so what one answers reaches the plugin as given, fields only the engine sets included (a tool.call answer's isReadOnly, and its ref and text when the plugin relays it), while every plugin in the test, wherever it stands, is read as in a session. A test gives the plugin under test its userConfig values with test(name, { options }, body), which it reads as the values stored in settings (a value outside a field's options unset, defaults filled in, then validated); left out, the plugin gets its manifest's defaults. A UI test mounts a component through the plugin on a surface it names, never an assumed one (the kit's mount on the test's ui noun: { plugin, surface, component, props }), and acts on the drawing by key (press, input, find, a Client's key and post), each act typed by that surface's element table; write the body once and loop it over ['terminal', 'desktop'] as const so the test shows the plugin does not depend on one surface. A tree the test drew through the engine's ui noun (render) is acted on the same way through that noun's press, input ({ plugin, key, text, kind? }: change is one edit reaching onInput, submit, the default, is Enter reaching onSubmit) and select ({ plugin, key, value }); every act resolves only once the chain, the element's own handler and any work that handler left running unawaited have settled, so a test asserts right after the await with no settling of its own (work asleep on the mocked clock waits for the test to advance it). The test's classic noun raises a classic hook event as the engine does (SessionStart({ source: 'clear' }), the envelope fields stamped unless given; every event but PreToolUse, which a test reaches through the tool noun's call: $.tool.call raises classic.PreToolUse as a session does, beneath every plugin's tool.call hook and above the test's own, so a deny ends the call with the errored result a session's tool.call hooks see (isError, the reason as text; { deny } to a plugin's own $.tool.call) and the test's hook never runs, while a rewrite, a pass, an ask or an allow goes on to it; with no settings hook configured, no decision stands beneath the test's own classic.PreToolUse hooks). The kit exercises the plugin's hooks and the description they return under each surface's rules, not any surface's paint.
开发时用 claude --debug 运行。失败的钩子会被跳过,链在没有它的情况下继续,除非其注册时的 .catch 处理器代为作答;当会话热重载一个插件文件夹时(启用热重载后的 mods 文件夹、交互式会话所监视的 --plugin-dir 或 CLAUDE_CODE_PLUGIN_DIRS 文件夹),转录中会用一行暗色文字说明一次,指名插件、事件和原因,如同指名一个未加载的模块;在任何其他会话中,该行只进入调试日志,形如 <plugin>: <line>(claude -p 运行没有转录:输出文本的运行会在 stderr 上就模块未加载或加载失败的 --plugin-dir 插件说明一次并附原因,包括开关未开启这种情况;json 或 stream-json 运行则将其保留在调试日志中)。调试日志对每次发生和引擎拒绝的每个结果都有一行;被跳过的钩子那一行以错误的名称和消息长度代替其文本(第一个错误的文本截短后放在那行转录上,调试日志里也有),所以看似毫无动作的插件通常已经被告知了原因。claude plugin test <folder> 针对引擎本身运行插件的 *.test.ts 文件:测试持有引擎的 $ 和一个 on,其钩子位于该插件之下(从 claude-code/testing 导入 test、expect、mock;其余看类型定义)。那些钩子之下没有任何东西:它们代表引擎,因此钩子给出的答案会原样到达插件,包括只有引擎才设置的字段(tool.call 答案的 isReadOnly,以及插件转发时的 ref 和 text),而测试中的每个插件无论位于何处,都按会话中的方式读取。测试用 test(name, { options }, body) 给被测插件其 userConfig 值,插件将其读作存储在设置中的值(超出字段 options 的值视为未设置,先填默认值,再做校验);省略时插件得到其清单的默认值。UI 测试通过插件把组件挂载到它指名的界面上,绝不假设界面(用测试 ui 名词上的套件 mount:{ plugin, surface, component, props }),并按键作用于绘制(press、input、find、Client 的 key 和 post),每个动作按该界面的元素表定型;测试体只写一次,用 ['terminal', 'desktop'] as const 循环,以证明插件不依赖单一界面。测试通过引擎 ui 名词(render)绘制的树,同样通过该名词的 press、input({ plugin, key, text, kind? }:change 是一次到达 onInput 的编辑,默认的 submit 是回车到达 onSubmit)和 select({ plugin, key, value })来作用;每个动作只在链、元素自身的处理器以及该处理器留下的未等待工作都结算完毕后才解析,因此测试在 await 之后立即断言即可,无需自行等待(睡在模拟时钟上的工作要等测试推进它)。测试的 classic 名词像引擎那样引发经典钩子事件(SessionStart({ source: 'clear' }),信封字段未给则盖戳;除 PreToolUse 外的每个事件皆可——测试要通过 tool 名词的 call 到达它:$.tool.call 像会话那样引发 classic.PreToolUse,位于每个插件的 tool.call 钩子之下、测试自己的钩子之上,因此拒绝会以会话的 tool.call 钩子所见的那份出错结果结束调用(isError,原因作为 text;对插件自己的 $.tool.call 则是 { deny }),测试的钩子永不运行,而改写、放行、询问或允许则继续传到它;未配置设置钩子时,测试自己的 classic.PreToolUse 钩子之下没有任何决定)。套件按每个界面的规则检验插件的钩子及其返回的描述,而不是任何界面的绘制效果。
Drawing: ui.render / 绘制:ui.render
A ui.render hook receives one component instance. e.component says
which component, e.surface where it is drawn (terminal, desktop,mobile or vscode), e.requestId which instance (the tool_use_id for a tool row or
dialog, the message id for a message or a command's output row, the agent id for a spinner), e.props
the component's plain-data props, and e.viewport, when the surface has
measured, the size it draws into in character cells: columns and rows. A transcript message's e.props.onScreen says which of its rows the viewport shows now ({ first, last, of }, from the site's first laid-out row; null while off screen; absent where the surface does not say, as on the terminal's main screen), and the hook re-runs for that message when it changes.
A change of width re-runs every hooked site once the resize settles, so a
tree sized to columns stays right; a change of height alone re-draws
nothing. A Pane or AbovePrompt hook sizes its tree to e.props.bodyColumns instead: the box it draws into, which is narrower than the viewport while a pane is docked beside the transcript. e.viewport.isFullscreen says whether the surface docks a pane at all (the terminal's fullscreen layout does, its main screen opens one inline; absent where a surface does not say, so do not assume), the fact command.run's presentation carries, so a plugin opens a pane unasked only where it would be a sidebar. $.ui.invalidate asks for a redraw
when the hook's own state changed. State a drawing draws from belongs in $.state, not a module variable (a hot reload loses those): named values the host holds for the session, each with a version. Declare them in the contract (interface PluginState { counter: { count: number; isOpen: StateFamily<boolean> } }), refer to one by a typed reference whose plugin and key are literals (const count = { plugin: "counter", key: "count" } as const, a const used for nothing but $.state calls, the state library's functions and a spread adding a family member's id), and read it while drawing: const { value = 0 } = await $.state.get(count). That read subscribes the instance, and a later $.state.set draws exactly the readers again, so nobody calls $.ui.invalidate for it. A render hook never writes ($.state.set while drawing is denied); write from a handler closure or another event, and never from a value the closure captured at draw time: import { update } from "claude-code" and onPress: () => update($, count, n => (n ?? 0) + 1) reads, applies and writes with ifVersion, again on a miss, so two presses before the redraw both land; atom(ref, initial), derive(sources, fn), memberOf(family, e) and read($, source) come from the same import. Only the owner writes a value; another plugin changes it by hooking state.set with a matcher on plugin and key and passing next another value. To keep a value past the session, write it to $.store too.
ui.render 钩子接收一个组件实例。e.component 说明是哪个组件,e.surface 说明画在哪里(terminal、desktop、mobile 或 vscode),e.requestId 说明是哪个实例(工具行或对话框用 tool_use_id,消息或命令输出行用消息 id,spinner 用 agent id),e.props 是组件的纯数据 props,e.viewport 在界面已测量时给出以字符单元格计的绘制尺寸:columns 和 rows。转录消息的 e.props.onScreen 说明视口当前显示该消息的哪些行({ first, last, of },从站点首个已布局的行计起;不在屏幕上时为 null;界面不说明处缺省,如终端主屏),且该消息变化时钩子会为它重跑。
宽度变化会在缩放稳定后重跑每个被钩住的站点,因此按 columns 定尺寸的树保持正确;仅高度变化不重绘任何东西。Pane 或 AbovePrompt 钩子改为按 e.props.bodyColumns 定其树的尺寸:即它绘制入内的盒子,当窗格停靠在转录旁边时该盒比视口窄。e.viewport.isFullscreen 说明该界面是否根本支持停靠窗格(终端的全屏布局支持,其主屏内联打开;界面不说明处缺省,故不要假设),这一事实由 command.run 的 presentation 携带,因此插件只在会成为侧栏的地方才主动打开窗格。钩子自身状态变化时用 $.ui.invalidate 请求重绘。绘制所依据的状态应放在 $.state,而不是模块变量(热重载会丢掉后者):宿主为会话持有的具名值,各带一个版本。在契约中声明它们(interface PluginState { counter: { count: number; isOpen: StateFamily<boolean> } }),用具 plugin 和 key 为字面量的类型化引用来指称(const count = { plugin: "counter", key: "count" } as const,这个 const 只用于 $.state 调用、状态库的函数以及为家庭成员补充 id 的展开),并在绘制时读取:const { value = 0 } = await $.state.get(count)。这次读取会让该实例订阅,此后 $.state.set 恰好重绘那些读者,因此无需任何人为它调用 $.ui.invalidate。渲染钩子从不写入(绘制期间的 $.state.set 被拒绝);要从处理器闭包或另一个事件写入,且绝不从闭包在绘制时捕获的值写入:import { update } from "claude-code" 加 onPress: () => update($, count, n => (n ?? 0) + 1) 会读取、按 ifVersion 应用并写入,未中再试,因此重绘前的两次按压都能落地;atom(ref, initial)、derive(sources, fn)、memberOf(family, e) 和 read($, source) 来自同一个导入。只有所有者写入某个值;其他插件通过钩住 state.set、按 plugin 和 key 匹配并向 next 传另一个 value 来更改它。要让某个值越过会话存活,也要写到 $.store。
Build trees from the table $.ui.resolve(e) returns: the surface's element constructors,
destructured into the hook's JSX tags (a module has no element globals). Tables differ per
surface, see Elements (mobile has no Input, Select or Client, vscode no Client,terminal no Svg but alone Raster and Image); narrowing e.surface narrows the table. A grid of colored cells
(sparkline, heat map, rendered frame) is one Raster, its cells packed per RasterProps, never a Box per cell; $.ui.blit
repaints a mounted one without a render pass. A picture (PNG or RGBA bytes, or the name of a file or POSIX shared-memory object another local process wrote, per ImageProps) is one Image over a box of cells: the kitty graphics protocol where the terminal has it (kitty, Ghostty), its alt elsewhere or where the terminal cannot read this machine's files; a new source updates it in place, and a keyed one is swapped at the frame rate by $.ui.blit({ requestId, key, source }), the pixels never crossing $. Model-style text (headings, lists, tables, code fences, links; one outside https:/http:/file: draws as text) is one Markdown, drawn as an assistant reply is; given key and onLinkPress, a plain single click on a link it drew (any, or one pressableLinks names) raises ui.press carrying the link's href instead of the surface opening it, where the surface reports clicks (the fullscreen terminal; a ctrl- or alt-click still opens it). Return a
tree, or next({ ...e, props }) to change what the engine draws, or next(e) to leave it.
A tree that does not validate (an element the surface lacks, a prop it does not take, a
child where none goes) is not drawn: the engine draws its own instead and writes to the
debug log a line beginning ui.render (<Component>): a hook returned a tree that does not validate, followed by the reason. While the session hot-reloads a plugin folder (the mods folder once hot reloading is on, a --plugin-dir or CLAUDE_CODE_PLUGIN_DIRS folder an interactive session watches) the transcript says it too, once per load, component and reason: <plugin>: ui.render (<Component>) refused: <reason>; the engine drew its own, <plugin> being the one plugin whose hook could have drawn the tree, hooks otherwise; in any other session the debug log is the only sign. One value is drawn in a lesser form rather than refused: a Code under format: 'diff' whose source does not parse as hunks (a diff cut mid-hunk, one with no @@ header) is drawn as plain code, as with no format and no startLine, and everything around it draws; the debug log says so, and while the session hot-reloads a plugin folder the transcript too, each once per load, site (a pane or band by its instance, the transcript's rows of one component together) and Code: <plugin>: ui.render (<Component>): Code <n> is drawn as plain code, its source not a unified diff: <reason>, <n> its place among the tree's Code elements in the order written, (path "<path>") after it when it names one; in the tree a Client's surface module draws, Client <module>: stands where ui.render (<Component>): does, once per load, module and Code. That reason and the
element's props type are the two things to read. A Button is [ label ] on the terminal, or with plain no brackets: 1: label beside its hotkey and the label alone without one, so a one-glyph label is a one-glyph control the focus still inverts; variant="primary" marks the main action of several (the terminal draws its [ label ] in the accent color, each other surface its own primary look), "secondary" or none is the default look, and plain wins over it. role="dismiss" marks the Button that closes its site, a drawing hint only: the terminal draws it as without, a desktop its native close control. Buttons, text fields and selects keep their
handlers in the plugin and raise ui.press, ui.input and ui.select; keys reach one only
while it has focus, Esc returns to the prompt; a Button's hotkey (one digit or lowercase letter) presses it while its site holds the keyboard, the band after ctrl+x tab or a click and a pane the same or opened with focus, except that a Button naming one of the engine's keybinding actions (action: "app:cycleDiffBase") is also pressed by the person's chord for it from the prompt while it is mounted: chords, or a modified key Global or an active context binds, and not while an engine handler of that action is mounted. A pane opened with focus, closeOnEscape and holdToasts behaves as a dialog: it takes the keys, Tab and the arrows walk its buttons, Esc closes it, and toasts wait behind it; an element drawn autoFocus holds the ring from the start, every move of the ring is the ui.focus event first (its element the key now holding it, absent on the engine's close mark; { deny } keeps it) and $.ui.focus({ requestId, key }) moves it while the site holds the keys; rows opens it inline as tall as its content needs (up to what the layout spares, and the person's own size wins), so a short dialog shows whole and its arrows walk rather than scroll; the command.run input's presentation says whether the answer shows fullscreen and how wide the terminal is. A keyed Box scopes hover styles, a hover scope groups elements across sites, and a Box drawn position: "absolute" with cell offsets (top: -2, left: 2) paints over its surroundings without moving them, so display: "none" with hover: { display: "flex" } on it is a card that appears over the rows above a hovered glyph. Every user-role transcript row is the UserMessage site: the person's prompt, a background task's notification (e.props.task: its id, status, durationMs) and a message another agent, teammate, session or channel sent (e.props.from.name), told apart by e.props.origin.kind, which a matcher narrows on ({ props: { origin: { kind: 'task-notification' } } }); a hook that draws a compact row of its own returns next(e) while e.props.isExpanded (ctrl+o), so the full row still shows there, and a rewritten text changes the row alone, never what the model read. A slash command's output row is the CommandOutput site: a plugin whose command answers command.run with { text, context? } (text the row the model also reads, context notes only the model reads, recorded after it) hooks it with { component: 'CommandOutput', props: { command: 'mine' } } and draws that text as a tree inline in the transcript, where a built-in command's lines would sit.
用 $.ui.resolve(e) 返回的表来构建树:界面的元素构造器,解构成钩子的 JSX 标签(模块没有元素全局量)。表因界面而异,见 Elements(mobile 没有 Input、Select 或 Client,vscode 没有 Client,terminal 没有 Svg 但独有 Raster 和 Image);收窄 e.surface 即收窄该表。彩色单元格网格(迷你图、热力图、渲染帧)用一个 Raster,其单元格按 RasterProps 打包,绝不用每格一个 Box;$.ui.blit 无需渲染趟即可重绘已挂载的 Raster。图片(PNG 或 RGBA 字节,或另一个本地进程写入的文件或 POSIX 共享内存对象的名字,见 ImageProps)用一个盖在单元格盒上的 Image:终端支持处(kitty、Ghostty)走 kitty 图形协议,其他地方或终端无法读取本机文件处用其 alt;新来源原地更新图片,带 key 的图片由 $.ui.blit({ requestId, key, source }) 按帧率切换,像素从不过 $。模型风格文本(标题、列表、表格、代码围栏、链接;https:/http:/file: 之外的按纯文本绘制)用一个 Markdown,像助手回复那样绘制;给了 key 和 onLinkPress 后,在界面报告点击处(全屏终端;ctrl- 或 alt-点击仍会打开链接),对其绘制的链接的普通单击(任意链接,或 pressableLinks 点名的链接)会引发携带该链接 href 的 ui.press,而不是由界面打开它。返回一棵树,或用 next({ ...e, props }) 改变引擎绘制的内容,或 next(e) 维持原样。未通过校验的树(界面缺少的元素、它不接受的 prop、不该有子节点的位置出现子节点)不会被绘制:引擎改为绘制自己的,并向调试日志写入一行以 ui.render (<Component>): a hook returned a tree that does not validate 开头的文字,后接原因。当会话热重载插件文件夹时(启用热重载后的 mods 文件夹、交互式会话监视的 --plugin-dir 或 CLAUDE_CODE_PLUGIN_DIRS 文件夹),转录中也会说明,每次加载、每个组件和原因各一次:<plugin>: ui.render (<Component>) refused: <reason>; the engine drew its own,<plugin> 是其钩子本可绘制该树的那个插件,否则为 hooks;在任何其他会话中调试日志是唯一迹象。有一个值是以降级形式绘制而非拒绝:format: 'diff' 的 Code 若其 source 无法按 hunk 解析(在 hunk 中途截断的 diff、没有 @@ 头的 diff),就按普通代码绘制,如同没有 format 和 startLine,周围一切照常绘制;调试日志会说明,会话热重载插件文件夹时转录也会,各自每次加载、每个站点(一个窗格或条带按其实例计,同一组件的转录行合在一起)和每个 Code 一次:<plugin>: ui.render (<Component>): Code <n> is drawn as plain code, its source not a unified diff: <reason>,<n> 是按书写顺序它在树的 Code 元素中的位置,若它指名了路径则后跟 (path "<path>");树中由 Client 的界面模块绘制时,Client <module>: 站在 ui.render (<Component>): 的位置,每次加载、每个模块和 Code 一次。该原因和元素的 props 类型是要读的两样东西。Button 在终端上是 [ label ],带 plain 则无括号:有 hotkey 时为 1: label,没有则只有标签本身,因此单字符标签就是单字符控件,焦点仍会将其反色;variant="primary" 标记多个动作中的主动作(终端以强调色绘制其 [ label ],其他界面各有自己的主样式),"secondary" 或不设是默认样式,plain 盖过它。role="dismiss" 标记关闭其站点的 Button,这只是绘制提示:终端照常绘制,桌面则用原生关闭控件。按钮、文本框和选择器把处理器留在插件里,引发 ui.press、ui.input 和 ui.select;按键只在持有焦点时到达它,Esc 返回提示符;Button 的 hotkey(一位数字或小写字母)在其站点持有键盘时按下它——ctrl+x tab 之后或点击之后的条带、以及同样方式或用 focus 打开的窗格——但指名了引擎键位动作之一的 Button(action: "app:cycleDiffBase")在挂载期间也会被用户在提示符处为该动作按的组合键按下:组合键,或 Global 或活动上下文绑定的修饰键,且当该动作的引擎处理器挂载时不生效。用 focus、closeOnEscape 和 holdToasts 打开的窗格表现如对话框:它接管按键,Tab 和方向键在其按钮间走动,Esc 关闭它,toast 在其后排队;绘制为 autoFocus 的元素从一开始就持有焦点环,焦点环每次移动都先是 ui.focus 事件(其 element 是现在持有它的键,引擎的关闭标记则缺省;{ deny } 可拦下),站点持有按键时 $.ui.focus({ requestId, key }) 移动它;rows 让它内联打开、高度按内容所需(至多到布局让出的程度,用户自己调的尺寸优先),因此短对话框完整显示、其方向键用于走动而非滚动;command.run 输入的 presentation 说明答案是否全屏显示以及终端多宽。带 key 的 Box 为 hover 样式划定作用域,hover scope 把跨站点的元素分组,而绘制为 position: "absolute" 带单元格偏移的 Box(top: -2, left: 2)盖在周围之上且不移动它们,因此在其上 display: "none" 配 hover: { display: "flex" } 就是一张出现在悬停字符上方各行之上的卡片。每条用户角色的转录行都是 UserMessage 站点:用户的提示词、后台任务的通知(e.props.task:其 id、status、durationMs)以及另一个代理、队友、会话或频道发来的消息(e.props.from.name),以 e.props.origin.kind 区分,matcher 可按它收窄({ props: { origin: { kind: 'task-notification' } } });绘制自己紧凑行的钩子在 e.props.isExpanded(ctrl+o)时返回 next(e),完整行便仍显示在那里,而改写 text 只改这一行,绝不改模型读到的内容。斜杠命令的输出行是 CommandOutput 站点:其命令以 { text, context? } 回答 command.run 的插件(text 是模型也读的那一行,context 是只有模型读的备注,记录在它之后)用 { component: 'CommandOutput', props: { command: 'mine' } } 钩住它,把该文本绘制为内联在转录中的树,位于内置命令的行本会占据之处。
The rows a conversation keeps: session.append / 会话保留的行:session.append
session.append IS the append: every place the engine adds a row to a conversation it keeps calls it, once, as the row's originator, and its bottom performs the store. The rows are the person's prompt, a slash command's record and output, each block of the model's response, a tool's result and the rows a tool hands over, a prompt or notification folded into a running turn, the reminders and listings the engine injects, a settings hook's or a chain's context, the loop's own nudges, a compaction's boundary and summary, and the notices the transcript shows, in the main conversation and in every subagent's alike (the event's agentId names the subagent's loop). The owner of each list appends the rows it originates (the interactive transcript, the headless session, a subagent's run for its opening rows), and the query loop appends every row it makes as the row leaves the loop; what the owner behind a loop receives, what the transcript file stores and what the next request sends are all the row as the chain answered it. Never a progress row, a row that rides one request alone, or a row a load returned (a --resume, a teleport, a subagent's saved transcript): loads are not appends. While no plugin hooks the event the call is the store itself.
session.append 就是追加本身:引擎向其保留的会话添加行的每一处都会调用它,一次,以该行发起者的身份,而链的底端执行存储。这些行包括:用户的提示词、斜杠命令的记录与输出、模型响应的每个块、工具的结果及工具移交的行、并入运行中回合的提示词或通知、引擎注入的提醒与列表、设置钩子或链的上下文、循环自身的催促、压缩的边界与摘要,以及转录显示的通知,主会话和每个子代理的会话皆然(事件的 agentId 指名子代理的循环)。每份列表的所有者追加它发起的行(交互式转录、无头会话、子代理运行的开场行),查询循环在每一行离开循环时追加它制造的每一行;循环背后的所有者收到的、转录文件存储的、下一次请求发送的,都是链作答后那一行。绝不包括进度行、只随单次请求存在的行,或一次加载返回的行(--resume、teleport、子代理保存的转录):加载不是追加。没有插件钩住该事件时,这次调用就是存储本身。
The event's door names the route the row came in by (prompt, command, response, tool-result, tool-message, delivery, attachment, hook-context, note, compaction, notice), origin who caused it (a prompt's sender, { kind: 'model', model }, { kind: 'tool', tool }, the engine, a settings hook, a plugin), and uuid its id in the transcript. Its message is the row: type, name (an attachment's type, a notice's subtype), role (absent for a row no request carries), isMeta, and content, its blocks as the Messages API spells them, so { role, content } of a row a request carries is an API message. All but the message's content is pinned: left out, it is kept; changed, the hook fails and is skipped.
事件的 door 指名该行进来的路径(prompt、command、response、tool-result、tool-message、delivery、attachment、hook-context、note、compaction、notice),origin 指名谁引发它(提示词的发送者、{ kind: 'model', model }、{ kind: 'tool', tool }、引擎、设置钩子、插件),uuid 是它在转录中的 id。它的 message 就是该行:type、name(附件的类型、通知的子类型)、role(请求不携带的行则缺省)、isMeta 和 content,其块按 Messages API 的写法,因此请求携带之行的 { role, content } 就是一条 API 消息。除 message 的 content 外全部钉死:省略则保留;改动则钩子失败并被跳过。
A hook rewrites content with next({ ...e, message }): text blocks (edited, added, dropped, reordered) and a tool_result's content and is_error (tool_results answered with no tool_use_id, one for each the row holds, stand for them in order, and one naming no is_error keeps the row's flag); an image or document block may be dropped or moved, not changed or added. Text a hook puts ahead of every pinned block follows the row's leading thinking blocks or tool results, which a request needs first. The bottom puts back what the record depends on: thinking and redacted_thinking blocks, tool_use blocks and every block it does not author, whole and in place, and each tool_result's id and the set of them. Content left empty removes every block a hook may drop: the pinned ones are put back, and a row left with no block at all is stored with the engine's own words for an empty message, (no content), never with what the hook removed. A rewrite of an attachment rendered per request or carrying media keeps the row as made (said in the debug log). next(e) resolves { message, uuid } once the row is stored; a hook that answers a row without next is skipped and the row is kept: a row the engine appends is never refused. The engine's later edits of a kept row (a tool result cut to its budget, a hint cleared, compaction) are its own business and no append.
钩子用 next({ ...e, message }) 改写 content:文本块(编辑、添加、删除、重排)和 tool_result 的 content 与 is_error(未带 tool_use_id 应答的 tool_result,该行持有几个就按顺序代表几个,未指名 is_error 的保留该行的标志);图片或文档块可以删除或移动,不能更改或添加。钩子放在所有钉死块之前的文本,位于该行开头的 thinking 块或工具结果之后,而请求需要它们在最前。底端会放回记录所依赖的东西:thinking 和 redacted_thinking 块、tool_use 块以及每个非钩子自己写的块,完整且原位,还有每个 tool_result 的 id 及其集合。留空的 content 会移除钩子可删的每一个块:钉死的被放回,而一个块都不剩的行按引擎给空消息的自带措辞 (no content) 存储,绝不按钩子删掉的样子存储。对按请求渲染或携带媒体的附件的改写会让该行保持原样(调试日志有说明)。行存储后 next(e) 解析为 { message, uuid };不用 next 而自行作答的钩子被跳过且行保留:引擎追加的行绝不会被拒。引擎随后对保留行的编辑(工具结果裁到预算内、提示清除、压缩)是它自己的事,不算追加。
In a hooked session a row is stored once its chain has run, a conversation's appends strictly in the order they were made; a row the engine appends from a synchronous callback (a notice) therefore lands after its chain, and the places that read a list back right away (a turn building its first request) wait for the appends made so far. A tool result's structured record beside its tool_result (toolUseResult, what the screen draws), the row's timestamps and parent links, and an attachment's payload are stored as made. So is the message queue's own record of a prompt as it was queued (the transcript file's queue-operation entries), which is no row of the conversation: a plugin that must change a prompt's words everywhere rewrites them at prompt.submit, before they are queued.
在被钩住的会话中,行的链跑完后即存储,会话的追加严格按其发生的顺序;因此引擎从同步回调(一条通知)追加的行落在其链之后,而立即读回列表的地方(正在构建首个请求的回合)会等待到目前为止的追加。工具结果在其 tool_result 旁的结构化记录(toolUseResult,屏幕绘制的内容)、行的时间戳与父链接、附件的载荷都按原样存储。消息队列对提示词入队时自身的记录(转录文件的 queue-operation 条目)也是如此,它不是会话的行:必须在所有地方更改提示词措辞的插件要在 prompt.submit、即入队之前改写。
$.session.append({ message: { type, content }, agentId? }) is the same dispatch from a plugin, and it REALLY appends: a user-role row the person does not see as typed (type: "user", stored isMeta, the model reads it) or a notice (type: "system", one text block, the model never reads it), of text blocks alone in this release, to the main conversation or to a running subagent's, under origin: { kind: 'plugin', name } and door note, with an id the engine mints. The row is in the list at once and in a running turn's requests from the loop's next top. The bottom refuses any other row or block, an agentId that names no running loop, and a run no plugin may shape (one started with the settings hooks off, or as a delegated observation), with the reason (the call rejects). The stored user row carries the plugin's name as its origin, so a later reader of the transcript knows who appended it. A plugin above may rewrite it like any row, or refuse it by answering { deny: reason } in place of next (after next the row is stored, and a deny then fails the hook): the call then resolves { deny } and nothing is stored. A hook's answer is what its next answered, the row as stored or the refusal from below, unchanged: a different row fails the hook. That is the one row a session.append hook may refuse; where an organization seats its policy plugin, the event continues past the person's plugins like the prompt events do.
$.session.append({ message: { type, content }, agentId? }) 是从插件发起的同一次分发,而且它真的会追加:一条用户不会作为输入看到的用户角色行(type: "user",以 isMeta 存储,模型会读它)或一条通知(type: "system",一个文本块,模型永不读它),本版本仅支持文本块,进入主会话或运行中的子代理的会话,以 origin: { kind: 'plugin', name }、门类 note,id 由引擎铸出。该行立即进入列表,并从循环的下一个顶层起进入运行中回合的请求。底端会拒绝任何其他行或块、指名不存在运行循环的 agentId,以及插件不得塑形的运行(以设置钩子关闭方式启动的,或作为受托观察的),并附原因(调用 reject)。存储的用户行以插件名作为其 origin,因此日后转录的阅读者知道是谁追加的。上方的插件可以像任何行一样改写它,或以 { deny: reason } 代替 next 作答来拒绝它(next 之后该行已存储,再 deny 会使钩子失败):此时调用解析为 { deny },什么也不存储。钩子的答案就是其 next 所答,即存储后的行或来自下方的拒绝,原样不变:不同的行会使钩子失败。这是 session.append 钩子唯一可以拒绝的行;组织安放其策略插件之处,该事件会像提示词事件那样越过用户的插件继续。
【评论】origin 携带发起方插件名,使追加的行可溯源;链上否决点({ deny: reason })是组织策略插件常见的介入位置,文档也点明了这一部署模式。
on("session.append", ($, e, next) => next({ ...e, message: redact(e.message) }))
.catch(($, e, next) => next({ ...e, message: placeholderFor(e.message) }))
Work that outlives a dispatch / 超出单次分发的工作
A hook runs inside one dispatch with a budget of its own time (a next or $ call in flight does not count; a $.clock.sleep does, so a turn.step generator that polls with it pays every sleep from its one budget), and next.signal aborts
when that dispatch is abandoned (the user interrupted, another hook settled
first, the budget ran out); anything started for the dispatch should stop
on it. Work meant to outlive a dispatch belongs elsewhere: start it from asession.start hook, which fires once when the session is ready and is
awaited before the first prompt (so a $.tool.register awaited there is
listed by turn one), and keep it going with $.clock.every and$.clock.after, whose timers run until cancelled or until the module
reloads. $.prompt.submit queues a prompt that starts a turn of its own once the session is idle (never folded into a running turn; the call resolves as that turn starts, not when it ends), so
background work can wake a quiet session. $.model.complete({ model, prompt }) runs one text completion with no history on the session's own client and always resolves a result (ModelCompleteResult), never a bare string and never a rejection over what the provider did: isAnswered with text and usage (a ModelUsage, the four token counts as the API spells them: the one shape session.compact's result, turn.complete's usage and the context breakdown's apiUsage report a call's cost in too), or isAnswered: false with a reason, api-error (with the HTTP status and the error kind, never the error's text), empty-reply, or aborted (its timeoutMs elapsed, or the dispatch that made the call was abandoned), usage on each arm; only a request the engine refuses to send (a blocked model, a bad maxTokens) rejects. $.model.fork({ prompt }) asks one tool-less question over the session's own transcript as the main thread last sent it, same model and system prompt, so the API serves that prefix from its prompt cache (usage.cache_read_input_tokens says how much it served); its result is the same arms plus nothing-to-fork (a new session before its first turn, and again right after a /clear), its aborted the turn whose hook forked interrupted while the fork ran. $.ui.status, $.ui.toast and$.ui.log show state without starting a turn, $.ui.copy({ text, surface }) puts text on the clipboard of the surface the caller names (a press hook passes e.surface; left out, the session's first): the terminal's through the machine's clipboard tool and OSC 52, resolving { isCopied: true }; { isCopied: false, reason } on a remote surface, no-clipboard, or with nothing drawing, no-surface; the event ui.copy carries the target surface, so a hook may rewrite, refuse or take it), $.store keeps values
across sessions, $.session.version() answers the engine's version, its base release and its builtAt build time (the values the engine's own analytics rows carry, in every mode and build), and $.process.run runs a host command by argv. $.fs reads (text, or { as: 'bytes' } for { base64 }), writes, lists and stats paths; $.fs.stat(path, { resolve: true }) also answers realPath, every symbolic link and .. resolved (what realpath gives: a hard link, a /.vol/ file-id spelling or a case alias keeps its own spelling), so a guard's robust form is an allow-list on realPath under a root it resolved the same way, and a deny-list on spellings is best effort.
钩子在一次分发内运行,拥有自己的时间预算(在途的 next 或 $ 调用不计入;$.clock.sleep 计入,因此用它轮询的 turn.step 生成器要为每次 sleep 从其唯一预算中付费),而 next.signal 在该分发被放弃时中止(用户打断、另一个钩子先行结算、预算耗尽);为该分发启动的任何东西都应在它上面停止。意欲超越一次分发的工作另有归属:从 session.start 钩子启动——它在会话就绪时触发一次,并在第一条提示词之前被等待(因此在那里被 await 的 $.tool.register 到第一回合就已列出)——并用 $.clock.every 和 $.clock.after 让它持续运行,其定时器直到被取消或模块重载前一直运行。$.prompt.submit 排入一条提示词,一旦会话空闲就开启它自己的回合(绝不并入运行中的回合;调用在该回合开始时解析,而非结束时),后台工作因此可以唤醒安静的会话。$.model.complete({ model, prompt }) 在会话自己的客户端上运行一次无历史的文本补全,且总是解析出一个结果(ModelCompleteResult),绝不是裸字符串,也绝不因提供方的行为而 reject:要么 isAnswered 附 text 和 usage(一个 ModelUsage,按 API 写法的四个 token 计数:session.compact 的结果、turn.complete 的 usage 和上下文分解的 apiUsage 也用同一形状报告调用的开销),要么 isAnswered: false 附 reason:api-error(附 HTTP status 和 error 种类,绝无错误文本)、empty-reply 或 aborted(其 timeoutMs 已到,或发起调用的分发被放弃),每个分支都带 usage;只有引擎拒绝发送的请求(被封锁的模型、坏的 maxTokens)才 reject。$.model.fork({ prompt }) 就主线程最后发送的会话自身转录提出一个无工具的问题,同样的模型和系统提示词,因此 API 会从其提示词缓存服务该前缀(usage.cache_read_input_tokens 说明服务了多少);其结果除同样分支外加上 nothing-to-fork(首个回合之前的新会话,以及 /clear 之后立即如此),其 aborted 表示发起 fork 的钩子所在回合在 fork 运行期间被中断。$.ui.status、$.ui.toast 和 $.ui.log 显示状态而不开启回合;$.ui.copy({ text, surface }) 把文本放到调用者指名界面的剪贴板(press 钩子传 e.surface;省略则用会话的第一个):终端的通过机器的剪贴板工具和 OSC 52,解析为 { isCopied: true };远程界面、no-clipboard 或没有绘制内容时为 { isCopied: false, reason } 的 no-surface;事件 ui.copy 携带目标 surface,钩子可以改写、拒绝或接受它。$.store 跨会话保存值;$.session.version() 回答引擎的 version、其 base 发布版和 builtAt 构建时间(引擎自己的分析行携带的那些值,每种模式和构建皆有);$.process.run 按 argv 运行宿主命令。$.fs 读取(文本,或 { as: 'bytes' } 得 { base64 })、写入、列出并对路径做 stat;$.fs.stat(path, { resolve: true }) 还回答 realPath,每个符号链接和 .. 都已解析(realpath 给出的结果:硬链接、/.vol/ 文件 id 写法或大小写别名保留其原有写法),因此守卫的稳健形式是对以同样方式解析出的根之下的 realPath 建立允许清单,而按写法的拒绝清单只是尽力而为。
【评论】以 realPath 允许清单而非路径字面写法做安全判断,是防范符号链接与大小写别名绕过的常见做法;文档也直言按写法建拒绝清单只是尽力而为。
$.ui.selection() answers what the person last selected with the mouse in
the fullscreen terminal, { text, requestId? }: the text as a copy would
take it, and the transcript row it lies in, by the id that row's ui.render
reads as e.requestId (absent when it spans several rows or lies outside
the transcript). A key or a click takes the highlight down before a command
or a press runs, so the answer stays what they last selected until they
select again, dismiss it, or their next prompt or command has run: a command
they type, or a Button they press, reads it. A prompt typed while a turn
runs and folded into that turn is no run of its own: the selection stays
kept until the next one has run. It resolves undefined with
nothing selected, and wherever the engine sees no selection (fullscreen off,-p, a remote surface). A command that quotes the selection into the prompt:
$.ui.selection() 回答用户在全屏终端中最后一次用鼠标选中的内容,{ text, requestId? }:文本如复制所得,及其所在的转录行——以该行的 ui.render 读作 e.requestId 的 id 为准(横跨多行或在转录之外时缺省)。按键或点击会在命令或按压运行前清除高亮,因此答案保持为他们最后选中的内容,直到再次选择、取消选择,或他们的下一条提示词或命令已经运行:他们键入的命令或按下的 Button 都能读到它。回合运行期间键入并并入该回合的提示词不算一次独立的运行:选区保持保留直到下一次运行完成。没有选中时解析为 undefined,引擎看不到选区的地方(全屏关闭、-p、远程界面)亦然。一个把选区引用进提示词的命令:
export function register(on) {
on('session.start', async ($, e, next) => {
await $.command.register({
name: 'quote',
description: 'Quotes the selection into the prompt.',
})
return next(e)
})
on('command.run', { command: 'quote' }, async $ => {
const selected = await $.ui.selection()
if (selected === undefined) return { text: 'Nothing is selected.' }
const quoted = selected.text.replace(/^/gm, '> ')
await $.prompt.fill({ text: quoted + '\n\n', mode: 'insert' })
return { text: 'Quoted the selection.' }
})
}
Tools and agent types the model can call / 模型可调用的工具与代理类型
$.tool.register declares a tool: its name, the description the model
reads and its input schema; the tool is listed as mcp__<plugin>__<name>.
The plugin serves it by hooking tool.call with the matcher{ tool: 'mcp__<plugin>__<name>' } and returning the result, and a call
no hook answers fails saying so. Registering the same name again replaces
the tool, and a plugin may register several, each listed as it lands. $.agent.register declares an agent type the same way, <plugin>:<name>, from an agent definition as settings JSON spells one (prompt, tools, model, and the rest, all in force); a plugin folder's agents/*.md files declare them too. $.agent.spawn({ subagentType }) runs one and its answer is its turn.complete; an agent.offer hook returning { isOffered: false } keeps it from the model while the plugin's own spawn still runs it.
$.tool.register 声明一个工具:它的名字、模型读取的描述以及它的输入 schema;该工具以 mcp__<plugin>__<name> 列出。插件通过以 matcher { tool: 'mcp__<plugin>__<name>' } 钩住 tool.call 并返回结果来服务它,没有钩子应答的调用会失败并如是说明。再次注册同一名字会替换该工具,插件可注册多个,各自就位时即列出。$.agent.register 以同样方式声明代理类型,<plugin>:<name>,其定义按设置 JSON 的写法(prompt、tools、model 及其余,全部生效);插件文件夹的 agents/*.md 文件也可声明它们。$.agent.spawn({ subagentType }) 运行一个代理,其答案就是它的 turn.complete;返回 { isOffered: false } 的 agent.offer 钩子把该类型对模型隐藏,而插件自己的 spawn 仍可运行它。