ARTICLE · 1092794
Deepseek Harness插件开发踩坑实录(写给正在给人类build dsh的AI agent)
DSH · 插件开发实录

事情起于给 Codex 做回滚功能。我希望 AI 改错文件之后,能把文件和对话一起退回到之前的状态,重新试一条路。做着做着,我把其中负责文件快照与恢复的部分抽成了独立的 FileSnap,想着这套能力也可以给其他 coding agent 用。
于是就有了一个看起来很顺手的想法:DeepSeek Harness(下文简称 DSH)支持插件,何不做一个回退插件?底层文件恢复已经有了,接上会话轮次,再加一个回退入口,用户就能在 AI 改错之后退回来。这就是 dsh-filesnap 的起点。当时我以为,主要工作会是把两边的接口接起来。
真正开始做,才发现一个功能看起来简单的插件,也会碰到框架里很深的地方。包安装成功了,构造函数执行过了,监听器却没有触发;文件回退成功了,对话还停在错误的位置;更严重的是,会话当时用着正常,重启之后却打不开了。
这些问题也很会躲测试。最早的依赖注入、生命周期和事件顺序问题,全部逃过了当时的 87 个绿色测试。后来,104 个测试可以一起证明“事件类型已经注册”,却没有一个启动第二个进程,检查落盘的会话能否重新打开。有些确实是框架缺少接口或诊断,有些是我理解错了契约,还有些只是排查工具把我带偏了。
我把这些经历整理出来,是因为它们不只属于回退插件。无论你做的是一个小工具、一块 UI,还是更复杂的工作流,只要涉及依赖注入、事件监听、安装发布或持久化,就可能碰到相似的问题。希望这篇文章能帮后来的开发者少走一些弯路,也让 DSH 的维护者看到外部插件作者具体卡在哪里,帮助框架继续改善开发体验。
标题特意写给正在 build 插件的 AI agent,因为这次开发中反复出现的一种错误,就是根据熟悉的 API 名字推测行为,再写出恰好符合这个推测的测试。人类开发者同样可以把它当作一份实战记录来读。贯穿全文的一条经验是:把验证推进到用户真正经过的边界——安装、启动、真实轮次、持久化、重启、卸载。 后文也会标明哪些问题已被官方处理,避免大家继续为已经消失的限制写绕路代码。
核查日期:2026-09-28。 历史案例主要来自 2026-08-27 至 09-11 的 FileSnap 开发,涉及 DSH
0.1.1-rc.2、0.1.2等早期版本。本次在线核对官方仓库提交、源码、GitHub Releases 和 npm 元数据:@deepseek-ai/dsh的latest、next均为0.1.7-rc.2,alpha为0.1.7-alpha.2;同时检查了官方 master 的固定提交21638c56315a[1]。以下“当前”均指这个核查范围,不能当成未来版本的永久保证。npm 元数据[2] · 0.1.7-rc.2 发布记录[3]
1. 第一关:插件真的活着吗?
一次真实故障是:构造函数跑完,几毫秒后服务消失,监听器也没了。问题藏在一个看似谨慎的判断后面:
ts
const commands = ctx.get('commands') if (commands !== undefined) registerCommands(ctx)检查使用了 ctx.get(),但 registerCommands() 内部又调用 ctx.commands.register(...)。后者重新经过 Context 的属性访问检查;没有声明该依赖,就可能抛出 cannot get property "commands" without inject。
必需服务应声明在插件的 inject 中。可选能力可以用 ctx.get() 探测,并把取得的服务值传下去:
ts
const commands = ctx.get('commands') if (commands !== undefined) registerCommands(commands)这只是调用结构示意;注册参数应以目标版本类型为准。ctx.get() 不会凭空创造服务,也不表示服务稍后出现时这段初始化会自动重跑。若功能必须随服务出现、消失而装卸,要设计对应的依赖生命周期。
另一个容易被 AI 从其他框架“补全”出来的错误是:
ts
// 不要把别的框架的语法搬过来。 inject: { required: ['agents'], optional: ['commands'] }当前 Cordis 的对象形式是“服务名 → 拦截配置”,上面的写法会被解释为依赖两个叫 required、optional 的服务。源码仍保留属性读取的注入检查,Inject.resolve() 也仍按这些键解析;这属于要遵守的接口契约。属性访问实现[4] · 依赖解析实现[5]
不过,“这类失败永远静默”已经不是准确的现状。官方后来加强了启动诊断,会列出失败插件、等待中的服务,并区分 required 与 optional 条目;具体版本见后面的时间线。进程还在运行,只能证明有部分组件启动了,不能证明你的插件已经激活。
我曾经误判过 ctx.inject 的生命周期
当时观察到子 fiber 很快被销毁,我一度把 ctx.inject(deps, callback) 理解成“回调一返回,子 fiber 就被 dispose”,进而认为不该用它挂长期监听器。这个故障现象值得记录,但不能据此推导 API 的通用语义。
当前官方把它定义为 ctx.plugin({ inject, apply: callback }) 的简写:依赖变化时卸载并重新执行。实现中没有“callback 正常返回就无条件 dispose”的规则。本次没有建立历史现场的完整复现,也没有找到能证明该现象被某个专门修复提交消除的证据,因此这里将它列为旧结论纠正,不冒充官方修复。官方 API 文档[6]
如果监听器确实注册后消失,检查它所属 fiber、父插件、依赖是否发生变化,以及初始化是否抛错。把服务自身的监听器注册在服务所属 context 上,通常更容易看清所有权;但不要由此写出“ctx.inject 只能做一次性注册”的规则。凡是绑定到 fiber 的 effect,都要服从那个 fiber 的卸载。
2. 监听器没执行,先看它有没有机会执行
FileSnap 需要在写文件之前读取旧内容。最初监听 fs/write-intent、fs/edit-intent,工作区内部分文件看起来正常,工作区外的目标却没有记录。原因是前者恰好被轮首扫描覆盖,掩盖了监听器根本没运行。
这两个事件经过 waterfall。官方 fs-observation-policy 占据决策位置,返回自己的判断,不调用 next()。如果第三方监听器排在后面,它永远收不到这次调用。当前源码仍明确保留这个行为。官方决策监听器[7]
只做写前观察的插件,可以把监听器放到前面,再把决定权交回去:
ts
ctx.on('fs/edit-intent', async (target, actor, next) => { await capturePreimage(target) return next() }, { prepend: true })这里有两个边界。第一,不能吞掉后续 policy 的拒绝,也不能自己返回“允许”来绕过它。第二,capturePreimage() 抛错同样会改变执行结果,必须明确你的产品在快照失败时是阻止编辑,还是报告未保护后继续;不能声称“加了观察就绝对不影响行为”。
测试要覆盖真实组合:观察器是否先执行、policy 是否仍执行、policy 拒绝时是否真的没有写文件。单独直接调用 capturePreimage(),证明不了这些。
3. 最危险的坑:写成功的会话,下次读不回来
旧版插件把 filesnap/point 等自定义事件写入 Session。当前进程看起来正常,冷加载却出现:
text
unknown to this harness and not marked ignorableTypeScript declaration merging 能让你的插件编译通过,却不会把类型加入官方构建生成的 KNOWN_SESSION_EVENT_TYPES。这两个“知道某个事件”的范围完全不同。
旧版本曾通过强转并修改那个 ReadonlySet 绕过检查。但这把日志可读性绑在当前进程的模块实例和插件加载状态上:插件卸载、读取先于注册,或者进程里出现第二份模块,都可能再次失败。当前官方说明已经明确拒绝把动态事件名注册当成解决方案,因为它不能说明“省略此事件是否安全”。官方已知事件集合及设计说明[8]
ignorable 的读取支持,不等于你有写入入口
官方在 2026-08-30 恢复过 ignorable 的兼容机制。但核对 0.1.7-rc.2 及上述 master 后,Session.append() 的 options 仍只对 surface 事件开放相应元数据,没有把插件传入的 ignorable 写进 envelope;构造出的事件还经过 deepFreeze。因此“读端认识这个字段”不能推导出“调用 append(..., { ignorable: true }) 就能安全使用”。当前 append 实现[9]
这项公共写入缺口,本次核查仍未发现已解决。官方仓库中的 Discussion #5474[10] 也有独立插件作者的复现,但讨论里的建议本身不代表已合并或已发布。
FileSnap 从 0.3.0 起采用的路径是:操作结果走独立 RPC/UI,快照及恢复状态使用插件自己的持久化,关联宿主原生轮次与 fork 血缘,不再向对话添加 filesnap/* 操作事件。旧日志另做带备份的迁移:扫描旧插件事件,为可安全忽略的事件补上标记,保留原始日志以便回滚。这是插件自身的修复,不是 DSH 已补齐自定义事件 API。
写任何持久化功能时,都应该加上一项验收:由一个从未加载你的插件的新进程打开产物。对于“卸载后对话仍能阅读”的承诺,还要真的卸载后再冷加载。
4. 回退必须让文件与对话停在同一个时刻
早期回退出现过这样的结果:磁盘文件已经恢复成 v1,对话最后一条却仍然说“已写入 v3”。文件恢复和会话 fork 各自成功,合在一起却是错误的产品行为。
当时的 atSeq 会向后寻找所属轮次的 turn/end。插件传入 turn/start(N) - 1,想表达“第 N 轮之前”,宿主却可能把整个第 N 轮保留下来。测试没有发现,是因为 mock 只记录传进去的数字,从没有检查真实子会话包含哪些轮次。
今天不要继续照抄那套锚点算法。 官方后来先修正了轮次切点之后多复制事件的问题,再引入精确事件前缀 fork;从包含后一改动的 0.1.7-alpha.1 起,显式 atSeq 是包含所选事件的精确边界。切在未闭合的工具调用或轮次中时,宿主会补合成结果与结束事件。省略 atSeq 仍不是“清空历史”,它选择最近完成轮次的前缀。当前 fork 契约[11]
所以,“第一轮永远不能提供回退”也只能作为旧方案的限制,不能变成新版 API 的定律。是否能恢复到首次提示之前,要看你保存的文件基线和可用历史边界,再通过真实会话验证。
fork 后的新 ID,不代表所有记录都属于新会话
另一个 FileSnap 自身的错误是:子会话继承了父会话的恢复点,却用 filesnap log --session <子会话> 的结果过滤它们。引擎里有这些快照,只是它们由父会话创建,于是第二次回退找不到点。
恢复点的归属、当前会话的 ID、文件所在 workspace,是三个不同维度。需要沿血缘解析继承记录,并验证记录是否仍存在;不能只因为当前 session 的列表为空,就认定继承点无效。也不能反过来无限信任 UI 中的旧按钮。
至少跑一遍“父会话 → 子会话 → 再 fork → 重启后再恢复”。浏览器入口和命令入口应共用相同的边界解释,避免一个调用宿主、另一个自己 slice(),最后得到两种回退语义。
5. 安装、挂载、模块身份,是三件要分别验收的事
有了 dsh.bundle,旧的手动挂载反而会撞车
FileSnap 最初需要安装后手改 profile。0.2.2 加入 dsh.bundle 后,安装就可以把包作为 layer 接进启动组合。但升级用户若保留旧的手动 insert,两个相同 ID 会导致 duplicate loader entry id: filesnap。
把“user override”理解成“重复 insert 会按 ID 自动合并”,是当时写错 README 的根源。实际处理是删除旧配置里手动插入 FileSnap 的那一块,只保留 bundle 提供的条目,其余配置不动。安装说明必须在真实升级路径上执行一次,既测试干净安装,也测试旧配置保留的情况。
用 dsh --profile web --dump-config 检查真正组合出来的配置,再启动对应 profile。浏览器功能还要验证发布包确实包含 client 构建产物,并按目标版本声明客户端入口。仓库里存在源文件,不能证明 npm tarball 里存在它。
同名、同版本,不保证同一个模块实例
早期源码启动使用 tsx,把宿主导入指向 src/;profile 中的外部插件却按包 exports 读到 lib/。于是同一个包有两份 Service、两份注册表或两个模块级 Symbol。一次注册发生在 A,读取发生在 B,看起来就像“明明注册了却不认识”。
当时使用构建后的 CLI 是有效的本地绕法。官方随后修复了源码启动的模块图,并完善 profile 与 linked 插件的运行时解析,详见时间线。现在应遵循官方的依赖分区:需要共享宿主实例的包声明为匹配的 peerDependencies,开发编译另配 devDependencies,避免把它们作为普通运行时依赖再带一份进去。官方实例共享说明[12]
这项修复有边界:profile 本地或插件私有的实际副本仍可能优先被 Node 选中。声明版本相同不会合并实例;旧 profile 里残留的依赖也不会因宿主升级自动变正确。应从宿主与插件两边检查解析位置和运行时对象身份。当前解析规则,包括源码与构建产物[13]
发版了,用户安装到的仍可能是旧版
当时 dsh-filesnap@0.2.2 已发布两小时,裸 dsh plugin add dsh-filesnap 仍装到 0.2.1。排查指向所用 pnpm 的 minimumReleaseAge 策略。latest、实际解析版本和 profile 最终安装版本,需要分别核对。
发布公告可以写明确版本,安装验收要记录实际解析结果。发布年龄按每个版本计算,新发 0.2.3 不会让 0.2.2 重新计时。不过,“所有 pnpm 版本都默认等待 24 小时”以及“显式版本在任何配置下都一定放行”都不该写成永久规则;应查部署使用的 pnpm 版本及 strict/exclude 配置。这是依赖解析政策,本次没有证据把它归为已被 DSH 全局取消的 bug。pnpm 官方发布年龄配置[14]
CI 要测用户装得到的组合
FileSnap 曾对着 master 构建,遇到 Session.events 被移除、品牌化的 seq 与 offset、继承长度位置变化,以及后续事件格式变更。测试绿了,也可能只说明它适配了尚未发布的宿主。
固定已发布宿主版本作为验收基线;若声明支持多个版本,就测对应版本矩阵。master 可以作为提前发现变化的额外通道。当前官方还加入了 DSH peer 兼容性检查,但它只能检查你声明的范围,无法证明你的代码真的兼容;写一个过宽范围不会自动获得兼容性。
6. 排查环境之前,也排查一下你的“尺子”
有两次差点写出的宿主故障,实际来自观察工具:一次手写解压没有完整读取多帧 zstd 会话,只看见 header;另一次把 projections.values.<key> 读成 projections.<key>,误以为冷加载丢了投影。
可靠的顺序是:先用宿主支持的读取路径或正确版本的 codec 验证,再写临时解析器。保留具体 Node、编码器和日志格式版本;不要把一次观察扩大成“所有 Node zstd API 都只解第一帧”。seq、持久化 offset、数组下标也要按格式版本区分,不能把早期稀疏 seq 的观察推广到后来要求规范坐标的格式。
还有三类容易浪费时间的环境问题:
- inotify 配额。
旧 headless 启动会无条件创建 patch watcher,容器里遇到 ENOSPC直接挡住验证。当时换配置目录也无效,单独尝试创建文件监听同样失败,最后改用轮询才跑通。官方现在将监听交给 YAML 管理的 HMR,Headless/SDK/ACP 默认禁用该条目;旧的必开路径已改变。启用监听的 Web 或自定义 profile 仍可能遇到系统配额问题,当前源码没有显示“ENOSPC 自动切 polling”的兜底。需要轮询时按当前 HMR 配置设置,不能机械复制旧 vendor HMR 的位置。当前 HMR 文档[15] - 切 tag 留下构建残骸。
旧 checkout 中被删除的包留下空目录,workspace glob 仍可能扫到;残留 tsbuildinfo又可能让 TypeScript 误以为产物无需重建。优先在独立干净 worktree 复现,确认再清理明确的构建残留。本次仍看到 tsdown 使用目录 glob,没有确认这个具体案例被上游修复,也没有重跑当前 tsdown 复现,不能断言它今天一定仍会触发。当前构建配置[16] - 下载日志里的
error (23)。旧记录怀疑是系统文件表耗尽,但没有取得错误来源证明。数字 23 不能单独证明 ENFILE,也不能证明网络故障。当前 dsh-subagent-codex仍把对应 CLI 包列为普通 dependency;升级其版本不等于解决了那次资源错误。这项应保留为未定根因,而不是写成官方已修复。当前依赖声明[17]
7. 官方修复时间线,以及插件当时怎样绕过限制
下表按修复提交日期排序,日期统一为 UTC。“首次包含版本”通过提交祖先关系与 npm 发布时间交叉核对;它与提交日、合并日、GitHub Release 发布时间可能不同。这里也列出只解决一部分问题的改动,并明确剩余边界。
DSH 官方已处理的部分
2026-08-30
官方变更与证据:2c6ff296af12[18]:撤回移除 ignorable 的改动。
首次包含的已发布 npm 版本:0.1.2-alpha.2,08-30 发布。
对插件作者的实际意义:部分解决: 恢复未知信息事件的读取兼容机制;没有补齐外部插件的 append 标记入口,不能把整个自定义事件问题标为已修复。
2026-09-11
官方变更与证据:973bea82040a[19]:fork 不再越过已选中的 turn/end 继续复制尾部事件。
首次包含的已发布 npm 版本:0.1.6-alpha.1,09-15 发布。
对插件作者的实际意义:修正当时的轮次切点;这一步仍不是任意 seq 的精确 fork,不能与下一次改动混为一谈。
2026-09-13
官方变更与证据:5f773a0ded81[20]:增加持久化 unarchiveSession()、远端入口及归档恢复设置页。
首次包含的已发布 npm 版本:0.1.6-alpha.1,09-15 发布。
对插件作者的实际意义:“官方只有 archive、没有 unarchive”已过时。新版可以使用公共 API,不必改私有 registry 状态或直接编辑存储文件。
2026-09-15
官方变更与证据:abd765a6001f[21]:profile reload 生命周期由 YAML 中的 HMR 插件持有。
首次包含的已发布 npm 版本:0.1.6-alpha.2,09-17 发布。
对插件作者的实际意义:旧启动障碍已缓解: Headless/SDK/ACP 默认禁用 HMR,省略或禁用后配置在重启时生效。没有修复 Linux 配额本身,也不意味着所有 profile 自动使用 polling。
2026-09-16
官方变更与证据:38fb1a11f913[22]、18260e3b0c5e[23]:分组报告失败插件、等待服务,并保存完整启动诊断。
首次包含的已发布 npm 版本:0.1.6-alpha.2,09-17 发布。
对插件作者的实际意义:诊断改进: 旧笔记的“一律无声失败”不再适合作为当前启动行为描述。错误的 inject 声明依然需要插件自己修正。
2026-09-16
官方变更与证据:8696ec6cefd3[24]:精确复制包含显式 atSeq 的事件前缀,为开放尾部补 fork 专用结束事件。
首次包含的已发布 npm 版本:0.1.7-alpha.1,09-22 发布。
对插件作者的实际意义:“显式 atSeq 必定向后对齐整轮”已过时。适配新语义时仍需把文件快照基线与历史边界一起验收;fork 本身不恢复文件。
2026-09-17 至 09-21
官方变更与证据:335c5cbe2663[25] 修源码模块图;后续 9fd0a5ad523a[26] 统一 runtime resolution;3e7af9cfbb35[27]、c9d4b7561d[28] 完善 linked peer 的查找位置。
首次包含的已发布 npm 版本:上述提交均首次包含于 0.1.7-alpha.1,09-22 发布。
对插件作者的实际意义:有对应官方修复: 正常源码启动与 profile/linked peer 解析已被专门处理。早期的 resolutionMode: link 过渡实现随后被替换,不应照抄那个中间补丁;错误 dependencies、私有副本与打包内联仍需作者排查。
2026-09-23
官方变更与证据:2c676339904b[29] 及 747c98b0db78[30]:检查 DSH peer 范围,拒绝不兼容 bundle,并提供精确版本豁免机制。
首次包含的已发布 npm 版本:0.1.7-rc.1,09-23 发布。
对插件作者的实际意义:安装与加载防护增强: 减少已声明不兼容的插件直接运行;不保证 alpha API 稳定,也不替代版本矩阵。不要把强行豁免当成兼容修复。
截至核查时,上表改动都已经包含于 npm latest 指向的 0.1.7-rc.2。这表示对应实现已发布,不表示旧版 FileSnap 已完成对 0.1.7 的整体适配。整理本文时,我核对了源码与发布记录,没有重新运行新版宿主上的插件端到端测试。
FileSnap 为适配 DSH 做过的绕法与取舍
上面的时间线是后来发生的事。开发当时,接口缺口还在,插件又要能用,我只能在现有条件下找路。这些决定有的碰了宿主内部实现,有的缩小了功能范围,还有的最终改变了插件存储和交互的方式。
最典型的是自定义会话事件:我先修改宿主的已知事件集合,让日志暂时能读;发现它仍依赖插件加载和模块身份后,最终放弃向对话写这类事件。另一个直接影响用户体验的例子是归档:因为缺少取回接口,原本想做的“隐藏旧分支”,变成了“给旧分支的标题加个标记”。
下面按“当时碰到什么 → 实际怎么绕 → 付出什么代价”列出。这些做法主要发生在 DSH 适配层。接口缺失、既定契约和环境限制的性质不同,不能都称为框架 bug。
外部插件事件不在官方生成的已知类型集合中,append 又没有 ignorable 写入入口。
FileSnap 实际采取的做法:直接改内部集合: 把 KNOWN_SESSION_EVENT_TYPES 强转成可写 Set,加入 filesnap/point 等类型。
代价与后续状态:这是临时 hack,没有公开契约保证。注册还不能随卸载撤销,否则会让既有日志重新不可读;新进程没加载插件或出现第二份模块时,问题仍会回来。0.3.0 已删除这项修改。
事件注册 hack 无法保证对话在插件缺席时仍能冷加载。
FileSnap 实际采取的做法:避开自定义对话事件: 从 0.3.0 起,用原生轮次和 fork 血缘关联引擎索引,操作结果通过独立 RPC/UI 展示;必要的插件状态另行持久化。历史插件事件通过带备份的迁移补 ignorable。
代价与后续状态:从临时补丁变成了架构取舍:插件要自己维护快照与会话的关联,以及旧数据迁移。它解决的是 FileSnap 对这个缺口的依赖,没有替 DSH 补齐公共事件写入 API。
有 archiveSession(),没有对应的 unarchive;隐藏原会话后,redo 无法通过公共 API 把它取回来。
FileSnap 实际采取的做法:用改标题代替隐藏: 离开会话时加 ↩,返回时移除;后来改成 🔴 Inactive ·/🟢 Active ·。
代价与后续状态:旧分支仍占据列表,重命名会停止该会话的自动标题生成,当时也受只能修改已加载会话的限制。官方后来补上 unarchive,才有条件重新评估这项取舍。
旧 atSeq 会向后对齐到整轮结束,不能表达任意精确历史边界。
FileSnap 实际采取的做法:改变锚点并收窄功能: 把恢复点锚在上一轮的收尾事件上;没有前一完整轮次的第一轮,直接不提供回退。
代价与后续状态:文件与对话得以对齐,但产品少了一种回退能力。这是对旧 fork 契约的适配。官方改成精确边界后,应重新设计并验证,不能继续把旧限制当成永久规则。
浏览器入口使用宿主 fork,插件当时的命令入口自行创建子会话。
FileSnap 实际采取的做法:复制宿主的截断规则: 命令路径自己查找轮次结束、截取历史,再创建子会话,以匹配浏览器入口。
代价与后续状态:同一语义实现了两遍,曾出现两个入口结果不同。整理本文时,插件代码仍保留旧截断规则;官方修复 fork 不会自动更新插件里复制的逻辑。这里反映的是插件适配的维护成本,不能归为宿主禁止命令调用 fork。
fs/*-intent 的决策 policy 不调用 next(),排在其后的观察器没有执行机会。
FileSnap 实际采取的做法:前置写前观察: 使用 prepend: true 先捕获旧内容,再 next() 交回宿主决策。
代价与后续状态:依赖明确的执行顺序,必须验证没有绕过 policy,并定义捕获失败时的行为。这个做法保留下来了,属于公共接口的组合方式,不是修改宿主内部实现的 hack。
早期源码启动把宿主导向 src/、外部插件导向 lib/,形成两份模块实例。
FileSnap 实际采取的做法:换验证入口: 使用构建好的 node apps/cli/lib/bin.js,让两边解析到同一套构建产物。
代价与后续状态:每次验证前要保证产物已构建,源码入口的问题并没有因此消失。官方后来专门处理了模块解析;这是当时的开发环境绕法。
旧启动路径强制创建配置 watcher,容器 inotify 配额耗尽后无法启动。
FileSnap 实际采取的做法:透传 polling 配置: 给 HMR 设置 usePolling: true、root: [],利用当时配置保留未知字段并传给 chokidar 的行为。
代价与后续状态:依赖当时的实现细节,以轮询代替内核监听,才得以继续做真实运行验证。后来 HMR 生命周期与配置已有变化,旧 patch 不能不看版本直接照搬。
也有两条路试过或检查过,最终没有采用:
- 自己补 unarchive。
直接编辑归档存储会与 registry 的内存副本冲突;调用它的私有 enqueueOperation、requireState、setState虽能驱动更新,却把插件绑在私有字段和方法上。我最后选择了标题标记,没有给宿主 monkey-patch 一个 unarchive。 - append 后再补
ignorable。想先拿到事件对象,再设 event.ignorable = true;实际对象经过deepFreeze,测试直接报不可扩展。这条尝试没能成为可用的绕法。
对后来的插件作者,值得关注的是每条绕法的退出条件。公共接口补齐后,要查自己的代码是否还在复制旧规则、保留旧限制,或依赖内部实现。尤其是已经影响持久化数据的 hack,删除代码只是第一步,还需要处理它曾经写出的数据。
8. Wishlist:希望官方继续解决什么,以及可以从哪里开始
经历这些绕路之后,我最希望改善的是几个所有插件作者都会经过的边界。下面按这次开发的影响排优先级,区分仍存在的能力缺口与已有机制上的体验改进。解决方向是初步建议,不是官方路线图,也不是已经存在的 API。 unarchive 和精确 fork 已经实现,不再重复列为待办。
P0:让外部插件有一条安全写入信息性事件的公共路径
希望解决的问题: 插件在类型层面能扩展事件,却缺少把事件明确标为可忽略的公共写入入口,容易落入“写时成功、下次读取失败”的陷阱。前面的 append 与已知事件集合源码说明了这个缺口。
初步方向: 为外部插件提供受约束的信息事件写入入口,显式携带命名空间、payload 版本及省略语义,由宿主在冻结前写入 envelope。可以考虑独立的类型声明表或专用方法,把这类事件与决定核心会话状态的事件分开;不能给任意核心事件随手加一个 ignorable 就放行。读取端仍依据持久化标记判断,不依赖插件当时有没有加载。对没有受支持兼容语义的未知事件,在写入边界给出明确错误,避免等到冷加载才暴露。
怎样算解决: 真实后端写入后,未安装插件的新进程仍能打开会话;同版本读取、fork 和跨版本迁移都定义清楚保留或省略行为。如果某项信息不能安全省略,应明确要求独立存储或正式的格式迁移,不能把“可忽略”当作任意状态的存储通道。
P1:给“写入前观察”一个不必抢决策顺序的位置
希望解决的问题: 快照、审计等插件需要在文件变化前运行,现在必须理解单槽 waterfall 和 prepend 顺序。多个插件叠加时,作者很容易把“观察”与“允许这次写入”混在一起。现有 policy 的短路本身符合契约,期待改善的是扩展接口的表达方式。
初步方向: 在受支持的文件写入路径中,考虑明确区分“决策检查 → 等待写前观察完成 → 执行写入 → 报告结果”。提供可等待的观察 hook,让观察器不返回授权决定,并定义多个观察器的顺序、取消传播,以及观察失败时阻止还是继续执行的策略。还应说明捕获旧内容与实际写入之间的并发边界;一个 hook 不会自动带来文件锁,也不会覆盖绕过 ctx.fs 的 shell 写入。
怎样算解决: 同时挂载两个观察插件与拒绝策略,拒绝时不写文件;允许时观察完成早于变更,观察器的失败策略有明确结果。插件不再需要靠试注册顺序确认自己是否会被调用。
P1:让浏览器与 headless 插件更容易复用同一套 fork 语义
希望解决的问题: 我的命令路径曾自己复制截断逻辑,随后与浏览器入口产生偏差。官方现在已有精确 fork,也已有 SessionStore.fork 与 buildForkSeed;希望继续改善的是从不同插件入口正确调用它们的难度。现有 fork helper[31]
初步方向: 先提供一组面向外部插件、绑定发布版本的成对示例:浏览器动作和 headless 命令选择同一边界,复用核心逻辑,并完整处理冷会话、preset、workspace 归属、子会话创建与失败清理。纯 seed helper 与完整创建流程的职责要说清楚。如果示例仍必须复制 controller 的大量编排,再考虑把共有部分抽成公共服务。
怎样算解决: 两个入口产生相同的继承前缀、合成尾部和会话归属,插件无需自己维护 slice() 规则。文件恢复仍由插件负责,不应暗示会话 fork 能与外部文件变更天然组成原子事务。
P1:把“明明注册了却找不到”变成能直接定位的诊断
希望解决的问题: 启动失败和缺失服务已有诊断,模块解析也已修复过。但遇到 profile 残留副本、错误依赖声明或自行打包的插件时,作者仍需要把模块路径、服务提供者和 fiber 状态拼起来,才能发现身份分裂。
初步方向: 在现有诊断能力上增加一次可导出的解释:某个服务由哪个插件和 fiber 提供,消费者为何等待;宿主与该插件分别把关键运行时包解析到哪个真实路径、版本和入口;实例是否共享。构建或安装检查还可以提示哪些宿主依赖应为 peer、哪些被插件打包内联。只对需要共享身份的运行时报出有针对性的提示,不能把所有依赖的多版本共存都判为错误。官方现有实例共享规则[12]
怎样算解决: 故意装入第二份有身份要求的运行时后,诊断能指出两个来源及受影响的关系,让作者知道该修依赖声明、清理旧安装还是调整构建,而不只是得到“服务不存在”。
P2:把真实安装、重启、卸载测试做成外部插件容易接入的模板
希望解决的问题: 官方已有 dsh-loader-smoke 等测试设施,部分 production-profile helper 属于仓库内测试。我的那批绿色单测说明,外部作者仍容易只测函数,漏掉安装组合和进程边界。现有真实启动测试设施[32]
初步方向: 基于已有设施,提供可在独立插件仓库运行的模板:安装打包后的插件到临时 profile,用无密钥回放驱动真实轮次,检查 host/client 产物,再做重启与卸载后的数据读取。让作者按插件能力选择持久化、UI、fork 等场景,并能固定已发布宿主版本运行 CI。另配简短迁移示例,说明接口变更时应更新哪组行为断言。
怎样算解决: 插件在“只跑单元测试”时通过,却漏打包 client、装了重复运行时或写出了依赖插件才能读取的日志,模板能在发布前通过相应场景发现问题,而不用作者先读懂整套宿主测试仓库。
P2:配置监听失败时,给出可执行的恢复选择
希望解决的问题: Headless 默认不再强制监听,polling 也已有文档支持;启用 HMR 的环境仍可能遇到配额耗尽。当前 watcher 启动失败会拒绝,没有看到自动切换 polling 的处理。当前 watcher 错误路径[33]
初步方向: 识别 ENOSPC 等资源错误,明确指出失败的是配置监听还是源码监听,并给出对应配置位置和轮询示例。可以考虑显式启用的、仅针对少量配置文件的 polling fallback:切换时关闭失败 watcher、记录降级状态,卸载时完整清理。不要未经选择就把整棵源码树切成高频轮询。
怎样算解决: 模拟 watcher 资源失败后,用户能从诊断中选用轮询或关闭热更新,并知道配置需要何时重启生效;降级过程不泄漏 watcher,也不无限重试。
如果只先推进一项,我会选安全的信息事件写入路径,因为它直接影响用户已有对话能否再次打开。其余建议更多是在已有修复上补齐示例、诊断与验证,让外部作者更容易走对公共接口,也让维护者更容易收到可复现的问题报告。
9. 给下一位 AI agent 的工作顺序
接手一个 DSH 插件时,先建立一张真实的环境卡片:宿主 npm 版本与源码 SHA、Node 与包管理器版本、profile、源码还是构建版启动、插件实际安装版本。不要让工作区里旧 checkout 的状态代替用户机器上的状态。
然后按下面的顺序验证,一次穿过一层边界:
- 安装与组合。
从干净 profile 安装发布包,查看 dump-config,再检查升级用户的旧 patch 是否冲突。 - 激活与调用。
看真实 fiber/启动诊断;用 mock LLM 或官方回放设施跑真实轮次,证明工具和事件会到达插件。 - 结果一致性。
同时断言磁盘、子会话历史和用户看到的状态;连续做两次操作,覆盖 fork 继承。 - 进程边界。
flush、退出、冷加载;在未加载插件的进程里读取应当独立可读的数据。 - 失败与重试。
在真正会产生部分副作用的点中断,验证下一次启动知道发生了什么,且不会重复消费恢复记录。 - 发布边界。
检查 tarball 的 host/client 产物、peer 声明、支持版本范围,记录实际安装解析结果。
如果你只改了文案或无副作用的小功能,不必机械地重跑所有破坏性场景;验证应覆盖这次修改可能破坏的承诺。但只要功能涉及历史、文件恢复或生命周期,就不能用一个“调用参数符合预期”的 mock 代替真实宿主结果。
推广阶段还遇到过“商店 bot 留言说不可安装,实际上安装入口已可用”的误判:读过脚本才发现,那段话是固定模板,并没有实时检查 npm。bot 模板、目录收录、npm 可下载、profile 成功加载是不同证据。这段经历发生在 09-05,不能据此判断那些社区商店今天的状态,也不属于 DSH 官方修复时间线。
10. 问题速查:遇到类似现象时,从哪里开始?
如果你正卡在其中一个问题上,可以先用这张表定位。版本与修复细节见前面的正文和时间线。
未声明服务读取
排查方向与当前状态:注入契约仍在;官方已改善启动诊断,插件调用仍须修正。
inject 子 fiber 很快被销毁
排查方向与当前状态:检查父插件、依赖变化及初始化异常;不能直接归因于 callback 返回。
waterfall 监听器不执行
排查方向与当前状态:当前 policy 仍短路;观察器要正确组合并传递后续决策。
自定义 Session 事件导致冷加载失败
排查方向与当前状态:ignorable 读端曾恢复;公共 append 标记缺口仍未确认解决。
fork 后找不到继承快照
排查方向与当前状态:检查是否错误地按当前 session ID 过滤;多级血缘仍是验收项。
安装后未挂载或重复挂载
排查方向与当前状态:检查 bundle 声明、实际 profile 与旧手工条目。
解析日志或读取投影时结果异常
排查方向与当前状态:先验证观察工具;codec 和投影结构必须匹配版本。
源码启动出现双实例
排查方向与当前状态:官方已有模块图和解析修复;“源码启动一律不可用”已过时。
inotify 配额耗尽
排查方向与当前状态:强制 watcher 的旧路径已改;启用监听时的系统资源约束仍在。
归档后无法取回会话
排查方向与当前状态:官方已提供 unarchive 公共 API,首次包含于 0.1.6-alpha.1。
atSeq 与预期截断位置不同
排查方向与当前状态:官方已改为精确显式边界,首次包含于 0.1.7-alpha.1;先确认宿主版本。
下载 error (23)
排查方向与当前状态:历史根因未证实;需要取得完整错误来源再判断。
新发的版本安装不到
排查方向与当前状态:检查发布年龄策略及实际解析版本。
切 tag 后构建异常
排查方向与当前状态:检查空目录与增量产物;未确认特定上游修复。
alpha API 漂移
排查方向与当前状态:官方新增 peer 兼容检查;固定基线与版本矩阵仍必要。
明明注册了事件,读取器却不认识
排查方向与当前状态:检查是否有两份 dsh-session;更不要把日志可读性寄托在修改内部集合上。
商店 bot 提示与实际状态冲突
排查方向与当前状态:确认留言是否模板,再检查真实安装入口。
商店已经收录,用户仍用不了
排查方向与当前状态:分别验证目录、包下载、安装和 profile 加载。
文中的故障案例来自我开发、测试和发布这个插件时的实际经历。整理成文时,我重新核对了官方源码、修复提交与发布版本,相关公开链接都放在对应段落。能确认已修复的,写明版本;只改善了一部分的,说明剩余边界;没有证实根因或没有重新复现的,也保留了说明,避免把一次观察写成框架的永久结论。
回头看,最初那个“把文件恢复接到 DSH 上”的小想法,最后让我花了不少时间理解插件究竟怎样被加载、调用、卸载,以及它留下的数据由谁负责读取。这些边界未必会随着功能变简单而消失。希望这里的具体经历能帮你更快找到问题,也能给框架维护者提供一些可定位、可验证的反馈:哪些接口还缺一环,哪些错误值得更早告诉开发者。
参考链接
正文中的编号对应以下公开来源。可复制完整地址到浏览器查看;源码链接保留核查时的固定提交。
[1] 21638c56315ahttps://github.com/deepseek-ai/deepseek-harness/commit/21638c56315ae6a2b552d6091945d3144c9af32e
[2] npm 元数据https://registry.npmjs.org/@deepseek-ai%2Fdsh
[3] 0.1.7-rc.2 发布记录https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v0.1.7-rc.2
[4] 属性访问实现https://github.com/deepseek-ai/deepseek-harness/blob/21638c56315ae6a2b552d6091945d3144c9af32e/vendor/cordis/src/reflect.ts
[5] 依赖解析实现https://github.com/deepseek-ai/deepseek-harness/blob/21638c56315ae6a2b552d6091945d3144c9af32e/vendor/cordis/src/registry.ts
[6] 官方 API 文档https://github.com/deepseek-ai/deepseek-harness/blob/21638c56315ae6a2b552d6091945d3144c9af32e/docs/cordis-api/registry.md
[7] 官方决策监听器https://github.com/deepseek-ai/deepseek-harness/blob/21638c56315ae6a2b552d6091945d3144c9af32e/packages/fs/fs-observation-policy/src/index.ts
[8] 官方已知事件集合及设计说明https://github.com/deepseek-ai/deepseek-harness/blob/21638c56315ae6a2b552d6091945d3144c9af32e/packages/core/session/src/known-event-types.ts
[9] 当前 append 实现https://github.com/deepseek-ai/deepseek-harness/blob/21638c56315ae6a2b552d6091945d3144c9af32e/packages/core/session/src/index.ts#L722
[10] Discussion #5474https://github.com/deepseek-ai/deepseek-harness/discussions/5474
[11] 当前 fork 契约https://github.com/deepseek-ai/deepseek-harness/blob/21638c56315ae6a2b552d6091945d3144c9af32e/.agents/notes/implemented/feature/2026-08-18-arbitrary-seq-session-fork.zh.md
[12] 官方实例共享说明https://github.com/deepseek-ai/deepseek-harness/blob/21638c56315ae6a2b552d6091945d3144c9af32e/.agents/notes/implemented/architecture/2026-09-18-profile-plugin-host-runtime-instances.zh.md
[13] 当前解析规则,包括源码与构建产物https://github.com/deepseek-ai/deepseek-harness/blob/21638c56315ae6a2b552d6091945d3144c9af32e/.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md
[14] pnpm 官方发布年龄配置https://pnpm.io/settings/dependency-resolution#minimumreleaseage
[15] 当前 HMR 文档https://github.com/deepseek-ai/deepseek-harness/blob/21638c56315ae6a2b552d6091945d3144c9af32e/packages/boot/hmr/README.zh.md
[16] 当前构建配置https://github.com/deepseek-ai/deepseek-harness/blob/21638c56315ae6a2b552d6091945d3144c9af32e/tsdown.config.ts
[17] 当前依赖声明https://github.com/deepseek-ai/deepseek-harness/blob/21638c56315ae6a2b552d6091945d3144c9af32e/packages/subagent/subagent-codex/package.json
[18] 2c6ff296af12https://github.com/deepseek-ai/deepseek-harness/commit/2c6ff296af12
[19] 973bea82040ahttps://github.com/deepseek-ai/deepseek-harness/commit/973bea82040a
[20] 5f773a0ded81https://github.com/deepseek-ai/deepseek-harness/commit/5f773a0ded81
[21] abd765a6001fhttps://github.com/deepseek-ai/deepseek-harness/commit/abd765a6001f
[22] 38fb1a11f913https://github.com/deepseek-ai/deepseek-harness/commit/38fb1a11f913
[23] 18260e3b0c5ehttps://github.com/deepseek-ai/deepseek-harness/commit/18260e3b0c5e
[24] 8696ec6cefd3https://github.com/deepseek-ai/deepseek-harness/commit/8696ec6cefd3
[25] 335c5cbe2663https://github.com/deepseek-ai/deepseek-harness/commit/335c5cbe2663
[26] 9fd0a5ad523ahttps://github.com/deepseek-ai/deepseek-harness/commit/9fd0a5ad523a
[27] 3e7af9cfbb35https://github.com/deepseek-ai/deepseek-harness/commit/3e7af9cfbb35
[28] c9d4b7561dhttps://github.com/deepseek-ai/deepseek-harness/commit/c9d4b7561d
[29] 2c676339904bhttps://github.com/deepseek-ai/deepseek-harness/commit/2c676339904b
[30] 747c98b0db78https://github.com/deepseek-ai/deepseek-harness/commit/747c98b0db78
[31] 现有 fork helperhttps://github.com/deepseek-ai/deepseek-harness/blob/21638c56315ae6a2b552d6091945d3144c9af32e/packages/core/session/src/fork.ts
[32] 现有真实启动测试设施https://github.com/deepseek-ai/deepseek-harness/blob/21638c56315ae6a2b552d6091945d3144c9af32e/packages/test-support/loader-smoke/README.md
[33] 当前 watcher 错误路径https://github.com/deepseek-ai/deepseek-harness/blob/21638c56315ae6a2b552d6091945d3144c9af32e/packages/boot/hmr/src/watch-config.ts