青雲的博客
深入浅出 DeepSeek Harness 第六部:能力接入——Skills、MCP和插件 第 37 章

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注册新贡献。

源码版本
47f943859bef60e4160492346772ded9b24f765a
验证日期
Commit
47f943859bef60e4160492346772ded9b24f765a

项目级 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的关键设计:

  1. Session ownership:cordis_define/cordis_run/undefine都需要exec.agent(requireAgent检查),plugin归创建它的agent session所有。
  2. Approval required:runHostHalf需要用户approval(ApproveFutureVersions可以一次批准后续版本)。
  3. Define-then-Run:先cordis_define提交源代码(得到pluginId+packageId),再cordis_run激活——这两步分离让approval可以在执行前检查代码。
  4. Host/Client split:plugin可以有Host half(Node.js侧,访问Cordis context)和Client half(浏览器侧,访问UI API),两边通过Inspect query通信。
  5. 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()时:

  1. _setEpoch(INACTIVE):标记fiber为inactive
  2. _refresh()→发现epoch变了→`_updateState→进入UNLOADING
  3. _unload()遍历_disposables.clear().reverse()(实际上clear()返回元素数组,map中顺序保证reverse order LIFO),逐个await disposer
  4. 每个disposer执行时:取消tool注册、移除prompt section、注销skill provider、断开MCP连接、清除event listener
  5. unloading完成→检查epoch→如果有新epoch则_reload()
  6. _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执行——最后注册的最先清理。这是资源依赖顺序的保证:

  1. 插件A先启动,注册了基础服务X(effect 1)
  2. 插件B后启动,注册工具Y依赖X(effect 2)
  3. 工具Y的注册产生了事件监听Z(effect 3)
  4. 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怎么给单个会话换一套运行时。