首页 / 资讯中心 / 文章详情

ThinkRail 扩展服务端注册表深度解析:从 registry 到 inline 装载、禁用开关与技能解析

ThinkRail 扩展服务端注册表深度解析:从 registry 到 inline 装载、禁用开关与技能解析 ★ FEATURED ARTICLE
【免费下载链接】thinkrailVibe code with pi in a lightweight, real IDE that customises itself around the way you work — The Vibe You Need项目地址https://gitcode.com/GitHub_Trending/th/thinkrail点击查看免费下载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为visualizepackages/server/src/extensions/registry.test.ts。描述符本身由极小的契约包定义见 packages/extension-api/src/server.tsname身份、可选的title/descriptionSettings 展示文案、extensions父会话工厂、可选childExtensions隐藏子会话工厂与可选skillPackages包 specifier 字符串数组。defineServerExtension只是带类型的恒等函数不注入默认值、不做注册。边界清单该模块拥有什么、被禁止触碰什么设计文档把职责拆成了六个具名导出它们共同构成 barrelpackages/server/src/extensions/index.ts。下表按能力—签名—用途归纳导出签名要点作用serverExtensionsreadonly ServerExtensionEntry[]有序条目specifier 字符串 descriptorregistryInlineExtensions(kind, isDisabled?, entries?)把extensions/childExtensions摊平成带名字的 piInlineExtensionomitDisabledRegistryExtensions(result, isDisabled, entries?)从 piLoadExtensionsResult中剔除被关闭扩展的inline:…条目registryExtensionName(path, entries?)把inline:name/inline:name#n路径反查回所属扩展listExtensionCatalog(disabled: ReadonlySetstring, entries?)Settings 目录快照文案、工具名、子扩展标记、启用态resolveExtensionSkillRoots(entry)以扩展为属主解析skillPackages的pi.skills目录依赖白名单同样明确thinkrail/extension-api/server类型、全部thinkrail/ext-*/server、earendil-works/pi-coding-agent仅类型、thinkrail/contractsExtensionCatalogEntry类型以及 Node 的module/fs/path。禁止项是这一层的精髓不依赖host——注册表不能反向依赖调用它的宿主不依赖settings——禁用集合只能作为参数传入。这解释了为什么listExtensionCatalog(disabled)接收ReadonlySetstring而不是自己去读配置不依赖同级功能模块不得用宿主自己的createRequire去解析扩展拥有的包。因为宿主并不依赖扩展所组合的那些 pi 包在隔离布局如打包后的 bundle、或扩展自带的node_modules树里这种解析会直接失败。这条禁令正是resolveExtensionSkillRoots存在的根本原因。内联工厂命名规则决定诊断可读性pi 的资源加载器把内联扩展显示成位置信息若不命名诊断里只会出现一个无意义的序号。registryInlineExtensions的目标就是让 pi 诊断显示inline:visualize而不是一个位置。核心实现如下packages/server/src/extensions/inline.tsfunction 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:3pi 的纯序号内联与/disk/solo.ts磁盘路径都返回undefinedpackages/server/src/extensions/inline.test.ts。Settings 目录一次计算、进程内复用、绝不依赖真实会话listExtensionCatalog是extension.listRPC 的实现。它要为每个注册表条目给出name、title缺省回退到name、可选description、enabled !disabled.has(name)、hasChildExtensions以及父工厂会注册的工具名。难点在于只想知道工具名却不想真的启动一个会话。实现方式是构造一个只记录registerTool的 stubExtensionAPI其余所有成员都是惰性的递归 Proxyconst inert: unknown new Proxy(() inert, { get: (_target, key) (key then ? undefined : inert), apply: () inert, }); async function recordToolNames(factory: ExtensionFactory): Promisestring[] { 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.tsthen必须返回undefined否则 Proxy 会被当成 thenableawait时触发无限递归。只记录父工厂describeEntry只对extension.extensions做Promise.allchildExtensions完全不执行——测试显式断言 child 工厂not.toHaveBeenCalled()。工厂抛错不影响已记录结果一个先emit再注册工具最后throw的工厂仍然保留beta错误被catch吞掉。这正是宁可展示不完整目录也不能让设置页整体失败的选择。工具名去重且保序[...new Set(tools)]让两个工厂注册同名alpha时只出现一次。目录结果还会按条目数组缓存const described new WeakMapreadonly ServerExtensionEntry[], PromiseExtensionDescription[]();以条目数组本身作为 WeakMap 键意味着每个进程只计算一次因为serverExtensions是模块级常量缓存天然命中。enabled是唯一的动态字段在缓存之后按传入的disabled集合逐条叠加因此切换开关不需要重新执行任何工厂。返回类型由契约包固定见 packages/contracts/src/domain.tsexport 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交给omitDisabledRegistryExtensionspackages/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.tsconst 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 rootbundled.skillsDir运行时不再做任何模块解析packages/server/src/agent/extensions.ts。新增一个注册表扩展的最小改动面把上面的约束串起来可以得到一份可执行的检查清单对应 thinkrail-extensions/SPEC.md 的两个入口、一个注册表 per side约定扩展包在thinkrail-extensions/name/下建thinkrail/ext-namepackage.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.tsspecifier 与 descriptor 一致性、工厂摊平、技能根解析含失败路径。inline.test.ts命名规则name与name#n、装载时重新判定启用态、裁剪只针对注册表内联项、路径反查。catalog.test.ts真实visualize条目的文案/工具/状态、stub 只记录父工厂、抛错工厂的容错与去重。跨模块的端到端行为另有两处覆盖agent/extensions.test.ts验证 loader 装载与 reload 的启用态切换、子集拼接顺序、抛错解析器视为全部启用agent/agentSessionManager.test.ts验证活跃会话的实时扣留与恢复。如果你只记住一句话这个模块把扩展是否存在与扩展是否启用彻底解耦了——注册表决定前者静态、构建期可见、进程内不变disabled集合决定后者参数传入、装载时判定、运行时可刷新而 specifier 字符串则是连接构建期模块解析与运行时工厂装配的那把钥匙。赞分享【免费下载链接】thinkrailVibe code with pi in a lightweight, real IDE that customises itself around the way you work — The Vibe You Need项目地址https://gitcode.com/GitHub_Trending/th/thinkrail点击查看免费下载相关推荐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语音多模态上一篇Apple Store 登录流程全解析Pastel 历史版本下载的 StoreServices 认证与双重验证指南下一篇一周 9 篇部署实测中文开发者为什么突然集体刷 YuE创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
阅读完成 · 觉得有帮助?
咨询建站