dsh-neoforge
面向 DeepSeek Harness(DSH)插件开发的类 NeoForge 标准 API 层:
- 语义化 source,先 seam 后 mixin:catalog 作者声明
event / service / view / mixin,后端自动选择;官方已有事件或服务方法时零补丁。 - Mixin 是可选子层:
dsh-neoforge/mixin通过createMixinLayer()挂载;运行期解析目标、保留旧快照、执行修改后的包装,卸载时恢复旧快照。 - 运行期目标全局独占:同一目标被多个第三方包 patch 时直接 loud error,不做隐式链式叠加。
- 事件总线复用官方 HMR:
ctx.on('vendor/action', handler)/ctx.neoforge.on(...)就是官方 Cordis 事件注册,fiber 卸载自动回收 listener。
语义 source
├─ event → 官方事件别名(零补丁)
├─ service → internal/service + 原型快照/恢复
├─ view → internal/get 消费方视图
├─ mixin → 运行期 resolve → descriptor 快照 → wrapper → 恢复
│
▼
{id}/before (ctx.bail) + {id} (ctx.emit)
│
▼
社区插件 ctx.on('vendor/action', …)与 dsh plugin add 相容
dsh-neoforge 现在同时是库和 bundle carrier:
dsh plugin --profile web add github:r05En1cU/dsh-neoforge- 安装后注入一个默认禁用的
dsh-neoforge行; - 默认不 mount,不会作为 plugin 启动;
- 其他插件可以直接
import from 'dsh-neoforge'; - 如果宿主想挂载基础服务,可在 profile overlay 启用:
- id: dsh-neoforge
disabled: false启用后根入口作为标准 function plugin 执行,等价于调用一次 getNeoForge(ctx),挂载 ctx.neoforge。
快速开始
1. catalog 作者:语义 source + 稳定事件
import { defineCatalog, defineEventPoint } from 'dsh-neoforge'
import type { NeoForgeEvent } from 'dsh-neoforge'
declare module '@deepseek-ai/cordis' {
interface Events {
'agent-preset/switch'(event: NeoForgeEvent<{ to: string }>): void
'agent-preset/switch/before'(event: NeoForgeEvent<{ to: string }>): void
}
}
export default defineCatalog({
plugin: '@deepseek-ai/dsh-agent',
versionRange: '>=0.0.0-0', // DSH 全系 rc:不能用 '^x'(不匹配预发布)
points: [
defineEventPoint({
id: 'agent-preset/switch',
requires: 'mutate',
source: {
kind: 'mixin',
target: {
module: '@deepseek-ai/dsh-agent',
versionRange: '>=0.0.0-0',
filePath: 'lib/index.js',
functionQuery: { className: 'AgentPresets', methodName: 'recompose', kind: 'Method' },
},
operation: 'around',
},
map: {
toEvent: (args) => ({ to: args[0] }),
applyEvent: (payload, args) => { args[0] = payload.to },
},
}),
],
})2. 装配 catalog
import { createNeoForge } from 'dsh-neoforge'
import { createMixinLayer } from 'dsh-neoforge/mixin'
import agentCatalog from './catalogs/agent.ts'
// catalog 含 source.kind === 'mixin' 时,必须先挂可选 mixin 层
ctx.plugin(createMixinLayer())
ctx.plugin(createNeoForge(agentCatalog))3. 社区插件:零 mixin 概念
export const name = 'my-preset-logger'
export function apply(ctx) {
ctx.on('agent-preset/switch/before', (event) => {
event.payload.to = 'code'
})
ctx.on('agent-preset/switch', (event) => {
console.log('switched to', event.result)
})
}运行期 Mixin:快照 → 修改后运行 → 恢复
每个 runtime-mixin 注册都执行:
1. Object.getOwnPropertyDescriptor(holder, key) 保存精确旧快照; 2. Object.defineProperty(holder, key, { ...snapshot, value: wrapper }); 3. 调用经 wrapper 执行 Advice 语义; 4. fiber 卸载时恢复旧快照 descriptor。
冲突策略:目标 (holder, key) 运行期全局独占。第二个第三方包尝试 patch 同一目标时注册直接抛错:
neoforge: runtime mixin "b" conflicts with "a" on Object.helper —
a runtime patch target is exclusive同一 mixin id 的同 owner 重注册视为 HMR/重放,卸载后目标恢复可再次注册。
运行期 Mixin 能做什么
| 目标 | 支持 | 机制 |
|---|---|---|
CJS exports.helper / 对象属性函数 | ✅ | 保存 descriptor,替换为 wrapper |
class 实例方法 ChatService.prototype._send | ✅ | className + methodName 定位 prototype,现有实例立即生效 |
| ESM class export 的实例方法 | ✅ | namespace 绑定只读,但 class.prototype 可变 |
| 尚未加载模块里的 class 方法 | ✅ | 监听官方 internal/service,服务注册时补丁 |
| ESM named export 模块级函数 | ❌ unavailable | namespace 绑定运行期不可写 |
#private、闭包、astQuery | ❌ unavailable | 运行期原理性不可达 |
不可达目标不会静默假装成功:ctx.neoforge.status() 明确报告 bound / pending / missing / unavailable / stale。
Advice:唯一拦截原语
before / after / around / replace 全部编译为同一个 around 形态:
type OperationPhases = {
before?(call): void
after?(call): unknown
around?(call, proceed): unknown
}事件后端和裸 ctx.mixinLayer.register 后端共享这一个操作分发器,因此 async settle、host policy、payload 映射只实现一次。
事件语义
| 织入 operation | {id}/before(ctx.bail) | {id}(ctx.emit) |
|---|---|---|
before | 可改 event.args | 无 |
after | 无 | event.result 可改并回流 |
around | 可改参;event.veto = true 跳过原方法 | settle 后携带 event.result |
replace | 完全接管;event.invoke() 才执行原方法 | 无 |
source: { kind: 'event' } 是纯观察别名,只有 {id},没有写回能力。
破坏性更新影响最小化
- 稳定事件面:事件名是 catalog 的公共契约,官方方法改名/改签名只改 catalog。
- 版本治理:
target.versionRange+ 自动读取目标包package.json(或注入readVersion)。 - 契约测试:
contractSuite(catalog, harness)—— 一次安装、一次调用、断言事件恰好一次。 - 默认观察、显式写入:
requires: 'mutate' | 'replace'是 review-listed 能力;宿主可ctx.intercept('neoforge', { allowMutate: false })降级为只读。
模块级函数 mixin:自定义事件层
functionName / expressionName 类型的 mixin source 会自动路由到 module-mixin 后端,它专门消费以下标准 Cordis 事件:
| 事件 | 语义 |
|---|---|
neoforge/module/load | 模块 handle 首次可用 → 解析并 patch |
neoforge/module/reload | 重新求值产生新 exports holder → 退役旧快照、patch 新 holder |
neoforge/module/unload | 模块句柄失效 → 恢复当前快照,回到 pending |
宿主 / loader / bundle 刷新器只需发布:
import { trackModule, reloadModule, untrackModule } from 'dsh-neoforge'
trackModule(ctx, {
id: '@pkg/lib/index.js', // 与 mixin target 的 module/filePath 对应
module: '@pkg',
filePath: 'lib/index.js',
exports: cjsExports, // 可变 exports holder
version: '1.2.0',
})
// HMR/重导入后
reloadModule(ctx, { ...same, exports: freshExports })
// 卸载
untrackModule(ctx, '@pkg/lib/index.js')事件派发是同步的,因此 reload 完成后旧 holder 已恢复、新 holder 已 patch。该层只对运行期可写的 CJS exports / class prototype 有效;ESM named export 绑定应改用官方事件或 service seam。
HMR 语义
- 下游监听器:官方
ctx.on,loader 卸载旧 fiber 时自动回收。 - catalog/neoforge 插件自身:注册是
ctx.effect;卸载恢复快照,重载重新注册。 - service 类目标:完整代际 HMR。
internal/service同步通知,新类 prototype 在同一窗口内完成retire(old) → attach(new)。 - 模块级 CJS 目标:无官方“模块被重新求值”事件,运行期无法自动感知;
getNeoForgeStatus(ctx)/status()会重新解析已绑定目标,发现新 exports holder 后自动退役旧快照、补丁新 holder。 - ESM named export /
#private/ 闭包:运行期不可达;dsh-neoforge 不再内置加载期桥,请改用官方事件、service seam,或让目标模块暴露可变句柄。
服务与治理
NeoForgeService 是标准 Cordis 服务(ctx.neoforge):
ctx.intercept('neoforge', { deny: ['vendor/unsafe-point'] })
ctx.intercept('neoforge', { allowMutate: false })
ctx.neoforge.register(catalog) // fiber-scoped 注册
ctx.mixinLayer.register(mixin, h) // 裸 mixin 运行期注册
ctx.neoforge.status() // 每个注入点的 source/后端/绑定/漂移诊断
ctx.neoforge.on(name, listener) // = ctx.on,官方 HMR 事件路径旧 catalog 的 tier/runtime/mixin 字段会 normalize 为语义 source,现有声明无需迁移。
统一 UI 服务:page → layer → slot → component
dsh-neoforge/ui 是一个纯 Cordis、无 Node builtin 的渲染无关 UI 层。插件只需 inject: ['ui']:
const store = ctx.ui.state({
id: 'feed',
init: () => ({ items: [] }),
actions: {
push(draft, item) { draft.items.push(item) },
},
})
ctx.ui.page({ id: 'workspace', title: 'Workspace' })
ctx.ui.layer({ id: 'webui', kind: 'react-dom' })
ctx.ui.slot({ id: 'workspace.sidebar', page: 'workspace' })
ctx.ui.component({
id: 'feed-panel',
slot: 'workspace.sidebar',
title: 'Feed',
state: store,
render: (h) => h('view', { direction: 'column' }, [
h('text', { value: `items: ${store.getSnapshot().items.length}` }),
h('button', { label: 'Refresh' }),
]),
})- component:返回统一 vnode,不直接依赖 React/Ink/RN;
- state:
getSnapshot / subscribe / select / actions,adapter 各自绑定框架; - adapter:webui adapter 把 vnode 转成 React 组件并注册到
ctx.slots;tui adapter 把 vnode 投影成文本 panel; - 组件注册、store、layer/slot 都是 fiber effect,HMR 自动回收。
内置 adapter:
import { createUiKit, webuiSlotsAdapter, tuiAdapter } from 'dsh-neoforge/ui'
ctx.plugin(createUiKit({
adapters: [
webuiSlotsAdapter({ createElement: React.createElement }),
tuiAdapter(),
],
}))tuiPanelAdapter():注册到 CodeWhale 风格的registerPanelsurface;tuiOverlayAdapter():注册到官方 dsh/cc-tui 的openOverlaysurface;tuiAdapter()/codewhaleTuiAdapter:自动选择上述两种 TUI surface。
dsh-neoforge/ui 同时内置了迁移自 CodeWhale 的 TUI 表面:
import { createTui } from 'dsh-neoforge/ui'
ctx.plugin(createTui({ placement: 'top', panel: 'tasks' }))
ctx.tui.registerPanel({
id: 'neoforge.status',
title: 'neoforge',
lines: () => ctx.neoforge.status().map((row) => `${row.id}: ${row.mounted.join(',')}`),
})
ctx.tui.setWorkSurface({
panel: 'tasks',
placement: 'top',
rows: [
{ id: 'patch', label: 'runtime mixin', status: 'done', tone: 'success' },
{ id: 'tui', label: 'migrate CodeWhale TUI', status: 'running', tone: 'live' },
],
})
ctx.tui.render({ width: 80, height: 24 }) // 确定性文本快照createTui 是幂等插件;ctx.tui 支持 registerPanel / openOverlay / setChrome / render,panel/overlay 都随调用 fiber 自动回收。createCodewhaleTui 是迁移别名。
Node 侧零依赖 ANSI host:
import { createTuiHost } from 'dsh-neoforge/tui-host'
ctx.plugin(createUiKit({ adapters: [tuiAdapter()] }))
ctx.plugin(createTui())
ctx.plugin(createTuiHost())
// q / ctrl+c 发出 'tui/host/exit-requested',launcher 负责 shutdownGUI/React Native 可以用同一套 SurfaceAdapter 约定接自己的 service。
WebUI / TUI:事件跨树
- TUI(cc-tui):Node 同树场景直接
ctx.on,无需额外层;组件注入仅在目标运行期可达时使用mixin。 - WebUI:浏览器是另一棵 Cordis 树,使用两个新入口:
// host 侧:把最新事件发布到官方 webserver exact route
import { createNeoForgeRelay } from 'dsh-neoforge'
ctx.plugin(createNeoForgeRelay({
path: '/neoforge/snapshot',
points: ['agent-preset/switch'],
}))// browser 侧:dsh-neoforge/client 轮询 relay 并 re-emit 同名 neoforge 事件
import { createNeoForgeClient } from 'dsh-neoforge/client'
ctx.plugin(createNeoForgeClient({
route: '/neoforge/snapshot',
points: ['agent-preset/switch'],
interval: 1500,
}))createNeoForgeClient 是浏览器安全入口,不 import 任何 Node builtin;事件到达浏览器树后,UI 注册仍走官方 ctx.slots / ctx.command,neoforge 只负责事件语义跨树。
开发
pnpm install # 同时执行 prepare 构建 dist/
pnpm run typecheck # tsc strict
pnpm test # 64 项:Advice + source + runtime/module mixin + WebUI relay/client + ctx.ui + CodeWhale TUI + HMR + policy
pnpm run build # dist/ ESM + d.ts要求 Node ≥ 22.19;ESM class 运行期补丁依赖当前 Node 的同步 require(esm)(Node 24 默认启用)。架构决策见 [docs/architecture.md](docs/architecture.md),完整调用文档见 [docs/usage.md](docs/usage.md),迁移技能见 [skills/dsh-neoforge-migrate/SKILL.md](skills/dsh-neoforge-migrate/SKILL.md)。