Repository Plugin 和 Self-modification
Repository Plugin是携带Skills+MCP配置+Bundles的仓库级能力包。打开workspace时,项目根目录(.git向上查找确定)的.dsh/目录配置被skill-filesystem自动扫描——.dsh/skills下的skill自动注册( rank=100最高优先级)。Cordis工具(cordis_define/cordis_run/cordis_stop/cordis_undefine)允许运行时动态创建/修改/移除插件,但修改受scope边界限制——不能修改root composition除非有对应权限。Self-modification边界:Cordis HMR热替换config和services时,旧Fiber dispose→LIFO cleanup→新Fiber创建,但已创建的Agent/Session持有旧引用不受影响——只影响后续创建。effect scope管理撤销:HMR或Fiber unload时disposers LIFO执行,取消旧贡献(tool注册/prompt section/provider注册)后新Fiber注册新贡献。
项目级 Skills / MCP 配置不一定要改全局 cordis.yml,也不一定要安装 npm 包。
当你 cd 到一个 git 仓库里运行 dsh,项目根目录 .dsh/skills/ 下的 SKILL.md 会被自动发现和注册:rank=100,比 user skills(400) 和 bundled skills(600) 优先级都高。这就是 Repository Plugin:workspace cwd 敏感的自动能力发现。你把 skill 文件放进项目的 .dsh/skills 目录,任何在这个项目里启动的 dsh session 都能看到它们。
但self-modification有硬边界。HMR热替换时,正在进行的对话不会突然换工具。Fiber restart先LIFO清理旧资源再创建新资源,但已存在的Agent/Session继续使用旧generation——只有之后创建的session看到新组合。Cordis dynamic tools(cordis_define/cordis_run)让模型能运行时写插件,但只能操作当前session拥有的plugin,碰不到root composition。
Repository Plugin:workspace级自动能力发现
Repository Plugin不是一个单独的npm包或类。它是多个provider协同实现的workspace自动发现机制:
Skills自动发现:skill-filesystem provider的roots()方法在cwd存在时,调用findProjectRoot(cwd)向上遍历目录找.git:
if (this.includeDefaultRoots && cwd !== undefined) {
const projectRoot = await findProjectRoot(resolve(cwd), optionalFileSystem(this.ctx))
roots.push(
{ path: join(projectRoot, '.dsh/skills'), source: 'project-dsh', rank: PROJECT_DSH_RANK, projectRoot },
{ path: join(projectRoot, '.agents/skills'), source: 'project-agents', rank: PROJECT_AGENTS_RANK, projectRoot },
)
}
project-dsh的rank是100(最低数字=最高优先级),意味着项目目录下的skill会覆盖同名的user skill(rank=400)和bundled skill(rank=600)。这是故意的——项目专属指导应该覆盖通用指导。
skill-filesystem用chokidar watch这些roots,文件增删改时invalidate catalog缓存并发skills/change事件。而且它还监听fs/observed事件——当模型通过edit/write工具修改了skill文件,observeHostMutation立即触发invalidate,不需要等chokidar的stability threshold。
ScopedLayers隔离:Repository plugins注册到global layer(因为它们在host plane,不属于特定agent scope)。Agent Preset的layer叠加在global layer之上——preset层的同名skill覆盖project层的。合并顺序:global→scope chain ancestors→current scope。
.watchManager还维护projects LRU缓存(watchMaxProjects默认128个project roots),超出后evict最老的project watcher。这避免打开太多不同项目时watchers无限增长。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "project-dsh\|project-agents\|PROJECT_DSH_RANK\|findProjectRoot" "$repo/packages/skill/skill-filesystem/src/index.ts" | head -15
flowchart TD
A["用户cd到项目目录\ndsh启动/cwd变化"] --> B["skill-filesystem.list({cwd})"]
B --> C["findProjectRoot(cwd)\n向上查找.git"]
C --> D{"找到.git?"}
D -->|是| E["加入roots:\n.dsh/skills(rank100)\n.agents/skills(rank200)"]
D -->|否| F["project roots为空\n只用custom/user/bundled"]
E --> G["chokidar watch roots\nLRU bounded(128 projects)"]
F --> G
G --> H["discoverRoot扫描文件:\n目录→SKILL.md\n文件→.md"]
H --> I["parseFrontmatter\n验证name/description/invocation"]
I --> J["注册到global layer\nSkillCandidate{rank,locator,resourceBase}"]
J --> K["模型看到catalog包含\n项目专属skills"]
K --> L{"文件变化?"}
L -->|chokidar检测到| M["invalidate()\nrevision++\nclear cache\nemit skills/change"]
L -->|fs/observed(edit/write)| M
M --> N["下次list()重新collect\n新catalog生效"]
style E fill:#006400,color:#fff
style K fill:#006400,color:#fff
style F fill:#555,color:#fff
Cordis Dynamic Tools:运行时self-modification
tool-cordis包注册了一组tools让模型能动态创建、运行、检视和移除Cordis插件。这些是self-modification的官方接口:
| Tool | 功能 | 类型 |
|---|---|---|
| cordis_inspect_list | 列出所有Inspect Provider(host+client) | 只读 |
| cordis_inspect_query | 执行Inspect Provider的只读query | 只读 |
| cordis_inspect_self | 检视当前session的dynamic plugins | 只读 |
| cordis_define | 创建新Plugin或追加Package版本(源代码+metadata) | 写入 |
| cordis_run | 启动host/client halves执行plugin代码 | 写入(需approval) |
| cordis_stop | 停止active run,保留packages | 写入 |
| cordis_undefine | 移除Plugin和所有packages | 写入 |
dynamic plugin的关键设计:
- Session ownership:cordis_define/cordis_run/undefine都需要exec.agent(requireAgent检查),plugin归创建它的agent session所有。
- Approval required:runHostHalf需要用户approval(ApproveFutureVersions可以一次批准后续版本)。
- Define-then-Run:先cordis_define提交源代码(得到pluginId+packageId),再cordis_run激活——这两步分离让approval可以在执行前检查代码。
- Host/Client split:plugin可以有Host half(Node.js侧,访问Cordis context)和Client half(浏览器侧,访问UI API),两边通过Inspect query通信。
- Undefine清理:cordis_undefine停止active run,dispose所有resources,移除plugin。
这些dynamic tools通过ctx.dynamicCordisRunner服务工作,属于extensions/cordis-host-runner和cordis-client-runner包的能力。不是core内置——你需要在composition里包含它们(standard preset包含了这些工具)。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "name: 'cordis_\|defineTool.*cordis" "$repo/packages/extensions/tool-cordis/src/index.ts" | head -20
Self-modification边界:HMR不影响已创建Session
这是self-modification最重要的设计约束:配置和服务的热替换只影响之后创建的Agent/Session,不影响已存在的。
实现机制是generation和standing mount:
Composition generation:AgentPresets的ensureStanding方法为每个preset维护一个standing mount,key是preset id。创建时记录compositionStamp(文件mtimeMs+size)。下次ensureStanding时如果stamp变了(文件被编辑),删旧pending→重新mount→新一代。但旧generation不立即dispose——它被自己的Scope持有,直到最后一个joined agent的scope key被GC(WeakMap引用)才释放。
private async ensureStanding(preset: AgentPreset): Promise<StandingMount> {
const pending = this.standing.get(preset.id)
if (pending !== undefined) {
const mounted = await pending
const current = await compositionStamp(preset.path)
if (current === undefined || sameStamp(mounted.stamp, current)) return mounted
if (this.standing.get(preset.id) === pending) this.standing.delete(preset.id)
return this.ensureStanding(preset)
}
// ...创建新一代
}
这意味着:
- 你编辑了agent.cordis.yml,新session看到新组合
- 已有session继续运行在旧generation上,不受影响
- 如果旧generation还有agent在用(比如一个长session),它的工具、persona、skills保持创建时的状态
- 你resume一个session时,session header记录了agentPreset id,通过standingKeyFor找到或创建standing mount——恢复到同一preset,但compositionStamp可能已变(新session用新版本,resume用当时joing的版本?不——实际通过scope parent binding保持)
Fiber restart的LIFO cleanup:HMR触发Fiber.restart()时:
_setEpoch(INACTIVE):标记fiber为inactive_refresh()→发现epoch变了→`_updateState→进入UNLOADING_unload():遍历_disposables.clear().reverse()(实际上clear()返回元素数组,map中顺序保证reverse order LIFO),逐个await disposer- 每个disposer执行时:取消tool注册、移除prompt section、注销skill provider、断开MCP连接、清除event listener
- unloading完成→检查epoch→
如果有新epoch则_reload() _reload():store={...this._store}→resolveConfig→执行runner.execute(plugin callback)→注册新effects→provide新services→状态变ACTIVE
关键:如果plugin callback在Fiber构造时通过ctx.effect()注册了所有资源,dispose时这些资源全部自动清理。你不需要手写teardown逻辑——effect返回的disposer就是你的teardown。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
grep -n "sameStamp\|compositionStamp\|ensureStanding" "$repo/packages/preset/agent-presets/src/index.ts" | head -15
effect scope管理撤销:LIFO为什么重要
所有Cordis资源注册都走effect,这让HMR的撤销变得可靠:
// 插件里注册tool
ctx.effect(() => {
const dispose = ctx.tools.register(myTool)
return dispose // 返回的函数就是disposer
}, 'my-tool')
// 注册event listener
ctx.effect(() => {
const off = ctx.on('some/event', handler)
return off // event off就是disposer
}, 'some-event-listener')
// 注册skill provider
ctx.effect(() => {
const dispose = ctx.skills.registerProvider(control => myProvider)
return dispose
}, 'my-skill-provider')
Fiber dispose时,这些disposers按LIFO执行——最后注册的最先清理。这是资源依赖顺序的保证:
- 插件A先启动,注册了基础服务X(effect 1)
- 插件B后启动,注册工具Y依赖X(effect 2)
- 工具Y的注册产生了事件监听Z(effect 3)
- HMR触发dispose:Z先清理(不再有事件fire到Y),然后Y清理(注销工具,不再调用X),然后X清理(释放基础服务)
如果顺序反了(FIFO),X先被销毁,然后Y的dispose可能调用X的方法→use-after-free→crash。LIFO就是栈展开——函数调用时局部变量创建顺序是A→B→C,返回时销毁顺序是C→B→A。Cordis effect LIFO cleanup模仿的就是这个。
sequenceDiagram
participant P as Patch File
participant W as Watcher
participant L as Loader
participant OF as Old Fiber
participant NF as New Fiber
participant T as ToolRegistry
participant SP as SkillProvider
participant MCP as MCP Client
P->>W: 文件变化(chokidar)
W->>W: composeLive() fresh structuredClone
W->>L: entry.update(newPatches)
L->>OF: restart()
OF->>OF: _setEpoch(INACTIVE)
OF->>OF: _unload()
Note over OF: LIFO disposers:
OF->>T: dispose tool registrations
OF->>SP: dispose skill providers
OF->>MCP: disconnect MCP servers
Note over OF: remove event listeners
OF->>NF: _reload()
NF->>NF: resolveConfig(new patches)
NF->>NF: execute plugin callbacks
NF->>T: register new tools
NF->>SP: register new providers
NF->>MCP: connect MCP servers
Note over NF: register event listeners
NF->>NF: state=ACTIVE
Note over Existing Sessions: 继续用旧Fiber
Note over New Sessions: 使用新Fiber
容易踩的坑
坑一:在非git仓库里打开看不到project skills。 findProjectRoot向上找.git,找不到就返回cwd本身作为root(不加入.dsh/skills)。如果你在/tmp或home目录运行dsh,.dsh/skills可能不存在或不被扫描。
坑二:同名skill时project覆盖user覆盖bundled。 这是rank决定的(100 < 200 < 300 < 400 < 500 < 600,低rank赢)。如果你在user目录定义了一个deploy skill,又在项目.dsh/skills定义了同名deploy,项目里看到的是项目版——user版被shadow了。这通常是你想要的,但debug时可能困惑为什么全局skill在项目里”不见了”。
坑三:以为HMR会改变正在进行的对话。 不会。正在进行的session继续使用创建时的composition generation。只有新session看到新配置。如果你改了persona或tool config,当前对话的模型行为不变——开新对话才生效。
坑四:cordis_define创建的plugin session结束就没了。 dynamic plugins归session拥有(agent-scoped),session结束时fiber dispose→plugin undefine→资源清理。如果你想让一个插件持久存在,用YAML composition声明而不是dynamic tool。
坑五:edit/write修改skill文件后catalog不立即更新。 model-facing的edit/write工具通过fs/observed事件触发observeHostMutation立即invalidate,但如果是通过外部编辑器(VS Code等)修改,靠chokidar检测,有awaitWriteFinish稳定性阈值(默认200ms)。改完skill后等一下再/skill list,或重新list一次。
坑六:isolate realm里的修改不泄露到root。 Agent Preset的isolate:true realm里注册的服务是preset-local的,不会出现在root realm。你在preset里通过Cordis tool动态注册的东西,只在该preset的agent scope内可见——其他preset或host plane看不到。
下一章预告
你现在知道了Repository Plugin的自动发现、Cordis dynamic tools的self-modification、以及HMR的generation边界。但还有一个更上层的组合机制没讲:Agent Preset。它让单个会话使用完全不同的运行时组合——不同的persona、工具集、sandbox policy、LLM配置——通过standing mount和scope parentage实现,不需要重启进程。standard preset是默认的完整coding agent组合,但你可以切换到code preset、minimal preset、或自定义preset。下一章看Agent Preset怎么给单个会话换一套运行时。