【免费下载链接】thinkrail
Vibe code with pi in a lightweight, real IDE that customises itself around the way you work — The Vibe You Need
packages/server/src/extensions/是 ThinkRail 宿主"唯一列出它所组合的扩展"的地方。本文以该模块的设计文档为骨架,逐条拆解serverExtensions的静态注册表、registryInlineExtensions的内联工厂命名规则、omitDisabledRegistryExtensions的装载结果裁剪、registryExtensionName的反向归属映射、listExtensionCatalog的目录快照,以及resolveExtensionSkillRoots的属主范围技能解析,并对照源码与测试说明这些能力在宿主会话中如何真正生效。读完后你可以准确判断"新增一个扩展该改哪个文件"、"扩展被关闭后到底发生了什么"以及"技能目录为什么必须由扩展自己解析"。
模块定位:一份静态注册表,而非运行时市场
设计文档给该模块的职责定义非常克制:它是宿主列出所组合扩展的唯一位置(参见 packages/server/src/extensions/SPEC.md)。registry.ts静态导入每一个@thinkrail/ext-*/server,并导出有序条目{ specifier, extension }——既包含defineServerExtension描述符,也包含它的公共 server specifier 字符串。
实际实现只有十余行,语义却完整:
import visualize from "@thinkrail/ext-visualize/server"; import type { ServerExtension } from "@thinkrail/extension-api/server"; export interface ServerExtensionEntry { readonly specifier: string; readonly extension: ServerExtension; } export const serverExtensions: readonly ServerExtensionEntry[] = [ { specifier: "@thinkrail/ext-visualize/server", extension: visualize }, ];源码位置见 packages/server/src/extensions/registry.ts。这里有两个容易被忽略的设计点:
- 静态导入而非动态发现。静态 import 让工厂函数顺着常规 server 依赖图进入编译后的 CLI 二进制与桌面端 server-runtime bundle,因此打包器不需要为注册表扩展生成工厂清单(清单机制仍保留给尚未包装成扩展的 pi 包)。
- 为什么同时保留字符串 specifier。描述符里装的是函数,函数不携带模块身份;而构建期要解析扩展自身的依赖图、定位入口文件,就必须有一个可
require.resolve的模块标识。registry.test.ts直接验证了二者的一致性:解析该 specifier 后import()得到的 default 必须严格等于注册表里登记的 descriptor,且name为visualize(packages/server/src/extensions/registry.test.ts)。
描述符本身由极小的契约包定义,见 packages/extension-api/src/server.ts:name(身份)、可选的title/description(Settings 展示文案)、extensions(父会话工厂)、可选childExtensions(隐藏子会话工厂)与可选skillPackages(包 specifier 字符串数组)。defineServerExtension只是带类型的恒等函数,不注入默认值、不做注册。
边界清单:该模块拥有什么、被禁止触碰什么
设计文档把职责拆成了六个具名导出,它们共同构成 barrel(packages/server/src/extensions/index.ts)。下表按"能力—签名—用途"归纳:
| 导出 | 签名要点 | 作用 |
|---|---|---|
serverExtensions | readonly ServerExtensionEntry[] | 有序条目:specifier 字符串 + descriptor |
registryInlineExtensions | (kind, isDisabled?, entries?) | 把extensions/childExtensions摊平成带名字的 piInlineExtension |
omitDisabledRegistryExtensions | (result, isDisabled, entries?) | 从 piLoadExtensionsResult中剔除被关闭扩展的<inline:…>条目 |
registryExtensionName | (path, entries?) | 把<inline:name>/<inline:name#n>路径反查回所属扩展 |
listExtensionCatalog | (disabled: ReadonlySet<string>, entries?) | Settings 目录快照:文案、工具名、子扩展标记、启用态 |
resolveExtensionSkillRoots | (entry) | 以扩展为属主解析skillPackages的pi.skills目录 |
依赖白名单同样明确:@thinkrail/extension-api/server(类型)、全部@thinkrail/ext-*/server、@earendil-works/pi-coding-agent(仅类型)、@thinkrail/contracts(ExtensionCatalogEntry类型)以及 Node 的module/fs/path。
禁止项是这一层的精髓:
- 不依赖
host——注册表不能反向依赖调用它的宿主; - 不依赖
settings——禁用集合只能作为参数传入。这解释了为什么listExtensionCatalog(disabled)接收ReadonlySet<string>而不是自己去读配置; - 不依赖同级功能模块;
- 不得用宿主自己的
createRequire去解析扩展拥有的包。因为宿主并不依赖扩展所组合的那些 pi 包,在隔离布局(如打包后的 bundle、或扩展自带的node_modules树)里这种解析会直接失败。这条禁令正是resolveExtensionSkillRoots存在的根本原因。
内联工厂:命名规则决定诊断可读性
pi 的资源加载器把内联扩展显示成位置信息,若不命名,诊断里只会出现一个无意义的序号。registryInlineExtensions的目标就是让 pi 诊断显示<inline:visualize>而不是一个位置。核心实现如下(packages/server/src/extensions/inline.ts):
function inlineNames(entry: ServerExtensionEntry, kind: RegistryExtensionKind): string[] { const factories = entry.extension[kind] ?? []; return factories.map((_, index) => factories.length === 1 ? entry.extension.name : `${entry.extension.name}#${index + 1}`, ); } export function registryInlineExtensions( kind: RegistryExtensionKind, isDisabled: ExtensionDisabled = never, entries: readonly ServerExtensionEntry[] = serverExtensions, ): InlineExtension[] { return entries.flatMap((entry) => { const factories = entry.extension[kind] ?? []; const names = inlineNames(entry, kind); return factories.map((factory, index) => ({ name: names[index] ?? entry.extension.name, factory: async (pi) => { if (isDisabled(entry.extension.name)) return; await factory(pi); }, })); }); }从中可以提炼出三条确定性规则:
- 单工厂用裸名:只有一个 factory 时名字就是扩展名(
visualize)。 - 多工厂加
#n(从 1 开始):multi#1、multi#2。测试用例明确覆盖了这一命名(packages/server/src/extensions/inline.test.ts)。 - 禁用判定发生在"pi 加载它的时候",而不是构建数组时。包装后的
factory每次被调用都会重新询问isDisabled(name),所以一次 reload 就能重新决定成员关系,不需要重建 loader。测试用同一个 inline 实例在 enable → disable → enable 之间反复调用,验证工具注册结果随之出现、消失、再出现(packages/server/src/extensions/inline.test.ts)。
这一点在宿主侧非常重要:buildResourceLoader会在每次 reload 前重新快照禁用集合(disabledAtReload),并用isDisabledAtReload闭包传给工厂(packages/server/src/agent/extensions.ts)。
结果裁剪:让"被关闭"表现为缺席,而不是存在但为空
仅有工厂层的return还不够。pi 加载完扩展后会产生一个LoadExtensionsResult,里面依然会出现该扩展的<inline:…>条目(工具为空、命令为空)。如果把这个结果继续传给父加载器的extensionsOverride,界面上就会出现一个"存在但空"的扩展。omitDisabledRegistryExtensions负责把这类条目整体丢弃:
export function omitDisabledRegistryExtensions( result: LoadExtensionsResult, isDisabled: ExtensionDisabled, entries: readonly ServerExtensionEntry[] = serverExtensions, ): LoadExtensionsResult { const kept = result.extensions.filter((extension) => { const name = registryExtensionName(extension.path, entries); return name === undefined || !isDisabled(name); }); return kept.length === result.extensions.length ? result : { ...result, extensions: kept }; }实现见 packages/server/src/extensions/inline.ts。两个细节值得注意:
- 不认识的路径一律保留:
registryExtensionName返回undefined时(磁盘上的第三方扩展、<inline:headless>之类的非注册表内联项),条目照常保留。裁剪只针对注册表自己产出的内联项。 - 无变化时返回同一对象:当没有任何条目被剔除,直接返回原引用,避免下游因对象身份变化做无谓的重算。测试同时断言
errors数组仍是同一引用(packages/server/src/extensions/inline.test.ts)。
反向映射registryExtensionName用一条精确正则提取名字,再在两类工厂的命名集合里查找:
const match = /^<inline:(.+)>$/.exec(path); if (!match) return undefined; return entries.find((entry) => (["extensions", "childExtensions"] as const).some((kind) => inlineNames(entry, kind).includes(match[1] ?? ""), ), )?.extension.name;注意它同时接受父命名与子命名:<inline:multi>与<inline:multi#2>都映射回multi;而<inline:3>(pi 的纯序号内联)与/disk/solo.ts(磁盘路径)都返回undefined(packages/server/src/extensions/inline.test.ts)。
Settings 目录:一次计算、进程内复用、绝不依赖真实会话
listExtensionCatalog是extension.listRPC 的实现。它要为每个注册表条目给出name、title(缺省回退到name)、可选description、enabled = !disabled.has(name)、hasChildExtensions,以及父工厂会注册的工具名。
难点在于"只想知道工具名,却不想真的启动一个会话"。实现方式是构造一个只记录registerTool的 stubExtensionAPI,其余所有成员都是惰性的递归 Proxy:
const inert: unknown = new Proxy(() => inert, { get: (_target, key) => (key === "then" ? undefined : inert), apply: () => inert, }); async function recordToolNames(factory: ExtensionFactory): Promise<string[]> { const tools: string[] = []; const recorder = new Proxy( {}, { get: (_target, key) => { if (key === "registerTool") return (tool: { name: string }) => tools.push(tool.name); return key === "then" ? undefined : inert; }, }, ); await Promise.resolve() .then(() => factory(recorder as ExtensionAPI)) .catch(() => undefined); return tools; }源码见 packages/server/src/extensions/catalog.ts。这段代码体现了四个明确的工程取舍,每一条都能在测试里找到对应断言(packages/server/src/extensions/catalog.test.ts):
then必须返回undefined:否则 Proxy 会被当成 thenable,await时触发无限递归。- 只记录父工厂:
describeEntry只对extension.extensions做Promise.all,childExtensions完全不执行——测试显式断言 child 工厂not.toHaveBeenCalled()。 - 工厂抛错不影响已记录结果:一个先
emit再注册工具最后throw的工厂,仍然保留beta;错误被catch吞掉。这正是"宁可展示不完整目录,也不能让设置页整体失败"的选择。 - 工具名去重且保序:
[...new Set(tools)]让两个工厂注册同名alpha时只出现一次。
目录结果还会按条目数组缓存:
const described = new WeakMap<readonly ServerExtensionEntry[], Promise<ExtensionDescription[]>>();以条目数组本身作为 WeakMap 键,意味着"每个进程只计算一次";因为serverExtensions是模块级常量,缓存天然命中。enabled是唯一的动态字段,在缓存之后按传入的disabled集合逐条叠加,因此切换开关不需要重新执行任何工厂。
返回类型由契约包固定,见 packages/contracts/src/domain.ts:
export interface ExtensionCatalogEntry { name: string; title: string; description?: string; enabled: boolean; tools: string[]; hasChildExtensions: boolean; }宿主侧接线只有一行:"extension.list": () => listExtensionCatalog(new Set(getConfig().disabledExtensions))(packages/server/src/host/handlers.ts)。RPC 契约声明在 packages/contracts/src/wsProtocol.ts。前端ExtensionsSettings在挂载时请求一次该目录,并把开关变更写成settings.update的disabledExtensions数组(apps/web/src/panels/ExtensionsSettings.tsx)。
禁用开关的完整链路:从设置项到活跃工具集
disabledExtensions的语义由设置模块定义:它是宿主注册表扩展的name列表,缺省即[](全部开启);线上更新必须是非空字符串数组,否则整次变更被拒绝;重复项去重;未知名字会被保留——因为"名字的含义"由注册表而非设置决定,这样已删除扩展的陈旧条目不会破坏启动或后续更新(packages/server/src/settings/SPEC.md)。
链路自上而下是这样闭合的:
- 注入解析器:宿主启动时调用
setDisabledExtensionsResolver(() => new Set(getConfig().disabledExtensions))(packages/server/src/host/server.ts)。 - 容错读取:
disabledExtensions()在解析器抛错时返回空集,"设置不可用"等价于"什么都没关"(packages/server/src/agent/extensions.ts,测试见 packages/server/src/agent/extensions.test.ts)。 - 装载期裁剪:
buildResourceLoader在每次 reload 时快照禁用集合,把isDisabledAtReload交给registryInlineExtensions("extensions", …),并通过extensionsOverride交给omitDisabledRegistryExtensions(packages/server/src/agent/extensions.ts)。隐藏子集则走childExtensionFactories()的registryInlineExtensions("childExtensions", isExtensionDisabled)(packages/server/src/agent/extensions.ts)。 - 运行期刷新:设置变更被应用后,宿主只在
appliedUpdate.disabledExtensions !== undefined时调用refreshExtensionTools()(packages/server/src/host/server.ts)。
运行期刷新是这条链路中最考究的部分。applyExtensionTools先用registryExtensionName(tool.sourceInfo.path)为每个工具找出属主扩展,再按disabled集合裁决:
const owner = registryExtensionName(tool.sourceInfo.path); if (owner !== undefined) owners.set(tool.name, owner); const isOff = (tool: string): boolean => disabled.has(owners.get(tool) ?? "");接下来的行为可以逐条对应到代码(packages/server/src/agent/agentSessionManager.ts):
- 关闭时,把该属主的活跃工具移入
withheldExtensionTools并从活跃集合剔除; - 重新开启时,只恢复之前被扣留的工具,不会把本来就未激活的工具误激活;
setActiveToolsByName重建系统提示时,被移除工具的指南文案也随之消失;- 流式输出期间不立即执行,而是打上
extensionToolsRefreshPending,等agent_settled再应用; - 若某个已启用的扩展在该会话里从未加载过(会话创建时它是关闭的)且并非加载失败,则触发一次
requestSessionReload。enabledExtensionMissing同时检查extensions与errors的路径,避免对加载失败的扩展反复重载。
同一段设计还明确了一条限制:pi 无法卸载扩展,因此被关闭扩展的 hooks 在会话中会一直运行到下一次资源 reload;每次关闭都强制 reload 会击穿提示缓存,所以不这么做。测试对这一点给出了端到端证据——空闲会话关闭扩展后工具与系统提示中的指南同时消失,重新开启后同时回来(packages/server/src/agent/agentSessionManager.test.ts);装载层则验证了"新 loader 不含<inline:visualize>、reload 后重新决定"(packages/server/src/agent/extensions.test.ts)。
技能解析:以扩展为属主,而不是以宿主为解析起点
skillPackages是包 specifier 列表。宿主需要这些包各自package.json里pi.skills指向的目录。若用宿主的createRequire解析,在隔离布局下会失败,因此实现分两步走(packages/server/src/extensions/skills.ts):
const extensionEntry = createRequire(import.meta.url).resolve(entry.specifier); const requireFromExtension = createRequire(extensionEntry); return skillPackages.flatMap((specifier) => { const manifestPath = requireFromExtension.resolve(`${specifier}/package.json`); const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as SkillPackageManifest; const skills = manifest.pi?.skills ?? []; if (skills.length === 0) { throw new Error(`${entry.extension.name}: skill package ${specifier} declares no pi.skills directories`); } return skills.map((dir) => resolve(dirname(manifestPath), dir)); });关键行为约定:
- 先用宿主解析扩展入口,再以入口为基准解析技能包。第一条解析是"扩展自身"(宿主的直接依赖),第二条是"扩展的依赖",各自在正确的模块树里进行。
- 没有
skillPackages时立即返回空数组,且根本不解析入口。这让没有技能的扩展(例如当前唯一的visualize未声明skillPackages)零成本通过。 - 声明了技能包但包内没有
pi.skills时大声失败。这是刻意的:静默跳过会让"技能不生效"变成难以定位的幽灵问题。 - 目录是相对
package.json所在目录的绝对路径,多个目录按声明顺序平铺返回。
测试用临时目录里手搭的node_modules树验证了完整路径推导,也覆盖了"包无技能必须抛错"和"内置注册表整体解析不抛错"(packages/server/src/extensions/registry.test.ts)。
结果是"一套解析器、三种使用姿势":
- 构建期:
resolveBuildRuntimeSources()把serverExtensions.flatMap(resolveExtensionSkillRoots)映射为requiredPath,任一目录缺失就在构建期报错(packages/server/src/buildSupport.ts),测试断言技能根顺序与内容(packages/server/src/buildSupport.test.ts)。 - 未打包的 dev 运行时:
resolveDevPaths()在运行时解析同样的根(packages/server/src/agent/extensions.ts)。 - 打包后运行时:只用启动器预先注入的 staged root(
bundled.skillsDir),运行时不再做任何模块解析(packages/server/src/agent/extensions.ts)。
新增一个注册表扩展的最小改动面
把上面的约束串起来,可以得到一份可执行的检查清单(对应 thinkrail-extensions/SPEC.md 的"两个入口、一个注册表 per side"约定):
- 扩展包:在
thinkrail-extensions/<name>/下建@thinkrail/ext-<name>,package.json只暴露./server与./web两个子路径(参考 thinkrail-extensions/visualize/package.json)。服务端入口用defineServerExtension包一层(参考 thinkrail-extensions/visualize/server/index.ts)。 - 服务端注册:在
packages/server/src/extensions/registry.ts增加静态 import 与一条{ specifier, extension }。name必须是全局唯一的开关键——Settings 的开关和disabledExtensions都按它匹配。 - 技能(可选):若扩展需要技能,声明
skillPackages: ["<pkg>"],并确保该包package.json里有pi.skills数组;否则构建期会因缺目录、运行期会因缺清单而报错。 - Web 半边(可选):若扩展提供工具卡片渲染,在
apps/web/src/extensions/registry.ts追加条目;registerWebExtensions()会把toolRenderers注入聊天工具注册表(apps/web/src/extensions/registry.ts)。注意./web与./server在内侧互不引用,apps/web只能引@thinkrail/ext-*/web、packages/server只能引@thinkrail/ext-*/server,这些边界由 scripts/check-module-boundaries.ts 检查。 - Settings 展示:
title/description是纯展示文案,缺省时title回退到name;工具名无需手写,目录会从工厂的registerTool记录中自动得出。
验证方式
该模块的四个源文件各自配有同目录测试,可直接运行:
bun test packages/server/src/extensionsregistry.test.ts:specifier 与 descriptor 一致性、工厂摊平、技能根解析(含失败路径)。inline.test.ts:命名规则(name与name#n)、装载时重新判定启用态、裁剪只针对注册表内联项、路径反查。catalog.test.ts:真实visualize条目的文案/工具/状态、stub 只记录父工厂、抛错工厂的容错与去重。
跨模块的端到端行为另有两处覆盖:agent/extensions.test.ts验证 loader 装载与 reload 的启用态切换、子集拼接顺序、抛错解析器视为全部启用;agent/agentSessionManager.test.ts验证活跃会话的实时扣留与恢复。
如果你只记住一句话:这个模块把"扩展是否存在"与"扩展是否启用"彻底解耦了——注册表决定前者(静态、构建期可见、进程内不变),disabled集合决定后者(参数传入、装载时判定、运行时可刷新),而 specifier 字符串则是连接构建期模块解析与运行时工厂装配的那把钥匙。
【免费下载链接】thinkrail
Vibe code with pi in a lightweight, real IDE that customises itself around the way you work — The Vibe You Need
相关推荐
5分钟学会使用vLLM部署Ornith-1.0-35B-GGUF:开发者必备教程
5分钟学会使用vLLM部署Ornith 1.0 35B GGUF:开发者必备教程 Ornith 1.0 35B GGUF是一款高效的开源代码智能模型,专为开发者
大模型代码智能体AI AgentJupyterLab 文档注册表(Document Registry)深度解析:文档类型、工厂机制与扩展开发实战
JupyterLab 文档注册表(Document Registry)深度解析:文档类型、工厂机制与扩展开发实战 @jupyterlab/docregistry
前端后端数据科学开发者工具ModelScope Pipeline 构建机制深度解析:从 pipeline() 工厂函数到 Registry 注册表
ModelScope Pipeline 构建机制深度解析:从 pipeline 工厂函数到 Registry 注册表 导读 ModelScope 将"模型即服务
人工智能机器学习深度学习微调计算机视觉NLP语音多模态
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考