# 扩展插件开发

扩展插件是**独立的 DSH 插件(npm 包)**,只提供**差异化能力** —— 目前是改写一条
bash 命令。装好后出现在「设置 → 路由 → 扩展」,每个一个开关。

**dsh-router 核心只做管理面,不做拦截。** 扩展插件自己挂监听、自己裁决、自己短路
(2026-09 重构,见下)。

数据目录是 `<profile>/data/`,**不跟进程 cwd 跑**——从任何目录启动 `dsh web`
读到的都是同一份配置(见 `src/data-dir.ts`)。

参考实现:[dsh-router-ext-rtk](https://github.com/CARVIN94/dsh-router-ext-rtk)
(把命令改写成 `rtk <cmd>` 压缩输出)。

## 分工:管理归核心,执行归插件

| | 谁负责 |
|---|---|
| 持有 `router.ext` 注册表(发现) | 核心 |
| 面板渲染、开关交互、`/router/api/ext` | 核心 |
| **开关状态 + 插件数据落盘**(`router.extStore` → `<dataDir>/ext.json`) | **核心** |
| 开启自检(不可用时拒绝开启,面板开关禁用) | 核心 |
| **挂 `tools/execute` 拦截** | **插件** |
| **按 enabled / ready 裁决、命中则短路执行** | **插件** |
| **怎么改写一条命令** | **插件**(私有,不在契约里) |
| **自己是否可用**(`getState().ready`) | **插件** |

**插件不自己 file IO。** 落盘位置由核心用 `dataDirOf(ctx.baseUrl)` 锚定,插件经
`router.extStore` service 读写(`isEnabled` / `setEnabled` / `readData` / `writeData`),
同一文件、同一次原子写。插件无状态。

### 为什么拦截从核心挪到插件

`tools/execute` 是任何插件都能自己挂的 around-dispatch waterfall(插件 ctx 不在 agent
scope 下就能收到全部派发)。核心代挂在**只有一个消费者**时是纯亏损:为留 40 行委派
逻辑,付了共享表 + inject 广播 + 契约两处同步的代价。核心归位成管理面后,扩展插件与
dsh-router 只在**注册表 + 存储**两处耦合。

> ponytail: 天花板 —— 核心代挂原本顺带解决「多个扩展插件各自挂监听会互相踩 / 顺序
> 不可控」。这个保护现在没了。目前只有 rtk 一家消费,无所谓;**第二家 ext 出现时要
> 重新收敛顺序**(升级路径:核心暴露一个按顺序委派的共享工具方法,而不是收回拦截)。

## 契约

注册到 `router.ext` 表的对象是**纯声明 + 状态**:

```ts
interface RouterExt {
  readonly id: string            // 唯一 id(注册键,如 'rtk')
  readonly name: string          // 面板显示名(如 'RTK')
  readonly description?: string  // 面板内容区说明
  getState(): ExtState           // 运行时事实
  dispose?(): void
}

// 只报「运行时事实」,不报开关
interface ExtState {
  ready: boolean    // 运行时是否就绪;false 时即使开启也不生效
  detail?: string   // 不就绪时的说明,面板红字显示
}
```

**没有 `rewrite`。** 怎么改命令是插件的实现细节,核心不感知、不调用。

插件经 `router.extStore` 读写(核心 provide):

```ts
interface ExtStoreService {
  isEnabled(id: string): boolean
  setEnabled(id: string, enabled: boolean): void
  readData<T = unknown>(id: string): T | undefined   // 插件自己的数据抽屉
  writeData(id: string, value: unknown): void
}
```

落盘形状(`<dataDir>/ext.json`):

```json
{ "rtk": { "enabled": true, "data": { "...插件自己的东西..." } } }
```

**插件没有 `setEnabled` 的职责,也不自己存开关。** 开关由核心持久化(默认关),
插件是被调用方:自己问 `isEnabled`、自己按 ready 裁决。

### `getState()`:只报运行时事实

- `ready: true` 才能生效;`false` 表示运行时不可用(如没装 rtk)。核心据此:
  - 面板开关**禁用**,点不开
  - 即使绕过面板直连 API 开启,核心也**拒 409** 并带上 `detail`
  - 面板内容区用红字显示 `detail`
- **不报 `enabled`** —— 开关在核心
- 建议在插件**启动时**就探测一次(而不是等第一次拦截),让面板首屏就有状态

> 旧版本插件曾自己把开关写在 `enhance.json`。现在核心读 `ext.json`,
> 历史上开着的会在首次启动时自动迁移过去(旧文件保留不删)。

## 注册:共享聚合表

cordis 每个 service name 只允许一个插件 `provide`,所以扩展插件**不要自己
`provide('router.ext')`** —— 核心已持有空表,插件 `inject` 等它出现后追加进同一个
live 对象,再广播一次 `internal/service` 触发核心重扫。**与加载顺序无关**。

```ts
import type { Context } from '@deepseek-ai/cordis'
import { currentExts, currentExtStore } from './contract.ts'

export const name = 'my-ext'

export function apply(ctx: Context): void {
  ctx.inject(['router.ext'], (sctx) => {
    const exts = currentExts(sctx)
    if (!exts) return undefined
    if (exts['my-ext']) return undefined // 已注册

    const ext = createMyExt()
    exts['my-ext'] = ext
    ctx.emit('internal/service', 'router.ext', exts)

    // 执行面:自己挂拦截。开关问核心存储,ready 问自己。
    const store = currentExtStore(sctx)
    const unmount = mountMyIntercept(ctx, () => store?.isEnabled('my-ext') === true, () => ext.getState().ready)

    return () => {          // 卸载清理
      unmount()
      ext.dispose?.()
      delete exts['my-ext']
    }
  })
}
```

`currentExts` / `currentExtStore` 帮你从 context 取服务(`ctx.get(...)` 或 `ctx.router.*`)。
契约在扩展包里**自含一份副本**(不能 import dsh-router 的 src,否则安装期要拉整个
路由核心)——改契约必须**两处同步**,鸭子类型,tsc 抓不到跨仓漂移。

## 插件包结构

最小结构(照 `dsh-router-ext-rtk`):

```
src/
  index.ts       插件入口,经 router.ext 注册 + 自挂拦截
  intercept.ts   拦截实现(挂 tools/execute、裁决、短路)—— 无状态,不存开关
  rtk.ts         扩展器实现(改写 + 探活)—— 无状态
  contract.ts    router.ext 契约副本(与核心同步)
  *.test.ts      测试
package.json     需声明 dsh.bundle.patch,否则不会被加入 profile bundles
cordis.patch.yml
tsdown.config.ts
```

> 插件**不需要数据目录**:开关与数据在核心的 `<dataDir>/ext.json`,
> 经 `router.extStore` 读写。别自己开文件。

`package.json` 关键项:

```json
{
  "main": "lib/index.js",
  "dsh": { "bundle": { "patch": "./cordis.patch.yml" } },
  "peerDependencies": { "@deepseek-ai/cordis": "^4.0.1" }
}
```

## 自挂拦截:两个坑(原在核心,现归插件,必须守住)

### `ctx.tools.get(name)` 必须带 agent scope

bash 工具注册在 **agent scope** 里,查工具必须传 `exec.agent`:

```ts
tools.get('bash', exec.agent)  // ✅
tools.get('bash')              // ❌ 只查全局视图,查不到 → 静默走原样,从不改写
```

症状极具迷惑性:wrapper 被触发、command 是正常字符串、开关 enabled + ready 齐全,
但从不改写。这条知识原先在核心一处,现在每个自挂拦截的插件各守一份 —— 抄漏了就是
「开关全开但从不生效」(已在 `dsh-router-ext-rtk/src/intercept.ts` 文件头 + 测试锁定)。

### 改写发生在一次已授权的调用内

核心改为插件自挂后语义不变:`tools/execute` 拦截时 sandbox 审批 / guard 等前置已在
该调用的 prepare 阶段完成。改写只是换命令字符串,**不绕过任何审批**。

## 自检与降级:失败一律放行

扩展器是**增强**,不是门禁。任何一步出问题都必须退回原样执行:

- 命令不是非空字符串 → 放行
- 开关未开 / 插件未就绪 → 放行
- 不命中改写 → 放行
- 拿不到 bash 工具 → 放行
- 执行抛错 → 转成 error envelope,不向外抛

命令**永不因扩展而失败**。

## 验证

判定代理是否真的生效(以 RTK 为例):

```bash
# 1) 看 API:扩展器 enabled / ready 是否都为 true
curl -s http://127.0.0.1:3080/router/api/ext

# 2) 跑一条有等价改写的命令,看输出是否变成压缩格式
ls                 # 被改写时是 rtk 树形格式,不是原生列表

# 3) rtk 自己的统计(rtk rewrite 查询本身不计入)
rtk gain
```

`tools/execute` 是 scope-filtered 事件,但只要插件 context 不在某个 agent scope 下
(`scopeOf(ctx) === undefined`),就能收到所有 agent 的派发——这是 hook 插件的常规用法。

## 加载顺序

核心 `apply` 里同步 `ctx.provide('router.ext', {})` 与 `ctx.provide('router.extStore', ...)`,
扩展插件 `inject` 等它。所以:

- 先装核心、后装插件 → `provide` 时插件的 `inject` 回调被触发
- 先装插件、后装核心 → 插件 `inject` 挂起,核心 `provide` 后触发

两条路径都能注册成功,顺序无关。拦截在 `inject` 回调内挂起(注册 → 拦截),所以存储
就绪前不会有半开的拦截窗口。卸载时插件的清理函数从表里删掉自己的键并注销监听。
