# 卡片协议(Card Protocol)
> **想先把一个现成的 DSH 插件当卡片挂上去?** 先读 README 的
> [卡片能做什么 / 不能做什么](../README.zh.md#卡片能做什么--不能做什么) ——
> 那里写清了三扇门(作用域 / 模块 / 能力)与"能挂 ≠ 能显示"。
> 本文是**自己写卡片**时的接口协议。
## 概述
卡片是「连接级插件」——只在某条会话连接内生效,不注册到 DSH 全局 Loader。
模板是分发单位,实例是运行时单位。安装模板后可一键加到多条连接上。
## Manifest
放在卡片包 `package.json` 的 `dshCard` 字段:
```json
{
"name": "monitor-card",
"version": "1.0.0",
"main": "dist/index.js",
"dshCard": {
"id": "monitor-card",
"name": "监控卡片",
"requires": {
"read": ["preset_status", "session_log"],
"write": []
},
"events": ["preset_error", "session_idle"],
"ui": {
"icon": "assets/icon.svg",
"panel": "dist/panel.js"
}
}
}
```
### Manifest 字段说明
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | string | 卡片模板唯一标识(模板注册表 key) |
| `name` | string | 人类可读名称(面板显示) |
| `requires.read` | string[] | 读权限所需的 DSH 资源(如 `preset_status`) |
| `requires.write` | string[] | 写权限所需的 DSH 操作(如 `repair_preset`) |
| `events` | string[] | 卡片可订阅/发布的事件名列表 |
| `api` | number | **卡片所需的 CardAPI 版本**(缺省 `1`)。⚠️ 字段名是 **`api`**,不是 `apiVersion` —— 写错会**静默按 1 处理**(见 `src/card-host/card-api-version.ts`) |
| `ui.icon` | string | 图标资源相对路径(SVG) |
| `ui.panel` | string | 面板入口文件相对路径(默认 `dist/index.js`) |
## 入口
卡片入口导出两个函数:
```js
// dist/index.js
/**
* 卡片初始化:订阅事件、注册工具、设置状态。
* 在 CardHost.loadCard() 时调用。
* @param {CardAPI} api - 卡片受限接口
*/
export function apply(api) {
api.on('preset_error', (data) => {
api.log('preset error detected', data)
api.emit('health_changed', { health: 'red' })
})
api.on('session_idle', (data) => {
api.log('session idle', data)
api.emit('health_changed', { health: 'yellow' })
})
api.registerTool('get_status', async () => {
return { ok: true, uptime: process.uptime() }
})
}
/**
* 面板 UI 渲染(可选)。
* 浏览器端在 ConnectionPanel 展开卡片时调用。
* @param {HTMLElement} element - 挂载容器
* @param {CardAPI} api - 卡片受限接口
*/
export function mountPanel(element, api) {
element.innerHTML = '
监控中...
'
}
```
## CardAPI 完整接口
| 方法 | 签名 | 说明 |
|---|---|---|
| `on` | `(event: string, handler: (data) => void) => () => void` | 订阅本连接事件(命名空间 `conn::`),返回取消函数 |
| `emit` | `(event: string, data: unknown) => void` | 向本连接发布事件 |
| `registerTool` | `(name: string, fn: (params) => Promise) => void` | 注册工具(白名单协商后对端可调用) |
| `mountUI` | `(element: HTMLElement) => void` | 渲染卡片 UI 到面板容器 |
| `requestRemote` | `(method: string, params: unknown) => Promise` | 请求对端执行操作(30s 超时,不自动重试,白名单校验) |
| `log` | `(...args: unknown[]) => void` | 写日志(控制台 + 审计日志) |
| `send` | `(kind, text, options?) => SendGate` | 以某一端的身份**写一条连接消息记录**(≠ 投递,走 `messageLog`) |
| `read` | `(options?) => ConnectionMessage[]` | 读取本连接的消息记录 |
| `sendMessage` | `(text: string, options?: { urgency?: 'quiet'\|'normal'\|'urgent'\|'preempt'; kind?: 'say'\|'ask'\|'reply' }) => Promise<{ ok: boolean; via?: string; live?: boolean; code?: string; permanent?: boolean; reason?: string }>` | **卡片代用户向对端投递一条消息**(走宿主既有的会话投递路径;见下) |
### `sendMessage` 的语义与两个 fail-closed 前提
`urgency` 四档与**宿主侧连接消息同一套语义**:
`quiet` 只告知不唤醒 / `normal` 排队 / `urgent` 插话 / `preempt` 抢占插话。
⚠️ **未满足以下任一条即拒绝**(不静默、不降级):
1. 卡片 manifest 的 `requires.write` 必须声明 **`"send_message"`** ——
"卡片代用户对外说话"是**用户授权的能力**,不是默认权利;
2. `scope === 'both'` ⇒ **无法判定该对哪一端说话** ⇒ 拒绝(宁可不说,也不要对错的一端说)。
其余性质:
* **复用宿主既有投递路径** ⇒ preempt 的既有约束(默认关闭 / 需写权限 / 每连接 5 分钟 1 次 /
不满足自动退化为 urgent)**自动生效** —— 卡片不必也不应自己实现一套限流;
* **不重试**;失败以**结构化结果**返回,不抛异常;
* 失败结果带 **`code`**(`not-authorized` / `scope-ambiguous` / `no-peer-session` / `no-channel` / `threw`)
与 **`permanent`** —— 卡片据此判断"**该不该再试**",**不要去解析 `reason` 文本**;
* 收端会看到来源前缀 **`【卡片 · 】`** —— 别让接收方误判说话的人是谁;
* 每次尝试**写审计**(哪一档、实际 `via`、是否降级)。
### requestRemote 返回值
```ts
// 成功
{ ...对端返回数据 }
// 失败
{ error: 'timeout' } // 30s 超时
{ error: 'whitelist_violation' } // 方法不在白名单
{ error: 'not_available' } // 对端不可达
```
## 事件命名规范
所有事件在连接命名空间内:`conn::`
### 内置事件
| 事件 | 触发者 | 负载 | 说明 |
|---|---|---|---|
| `health_changed` | 卡片 | `{ health: 'green' \| 'yellow' \| 'red' }` | 卡片健康状态变更 |
| `permission_upgrade_requested` | 宿主 | `PermissionUpgradeRequest` | 权限升级请求发起 |
| `permission_upgrade_accepted` | 宿主 | `{ id, direction, from, to }` | 权限升级生效 |
| `permission_upgrade_rejected` | 宿主 | `{ id, rejectedBy }` | 权限升级被拒 |
| `permission_upgrade_expired` | 宿主 | `{ id }` | 权限升级超时过期 |
| `whitelist_negotiated` | 宿主 | `{ methods: string[] }` | 白名单协商完成 |
| `whitelist_add_requested` | 宿主 | `{ method, description }` | 白名单新增请求 |
| `whitelist_added` | 宿主 | `{ method }` | 白名单新增确认 |
## 权限模型
### 权限级别
| 级别 | 值 | 说明 |
|---|---|---|
| `read` | 0 | 只读,可观察对端状态 |
| `suggest` | 1 | 建议,可向对端发送建议但不强制执行 |
| `write` | 2 | 写入,可通过 requestRemote 在对端执行操作 |
### 升级规则
- **高→低(降级)**:直接生效,无需确认
- **低→高(升级)**:需双方确认(双端同意才生效),60s 超时自动过期
- 升级请求通过 `permission_upgrade_requested` 事件通知两端
- 确认后自动更新连接权限并广播 `permission_upgrade_accepted`
## 面板 UI
```js
export function mountPanel(element, api) {
element.innerHTML = '监控中...
'
}
```
面板 UI 在 ConnectionPanel 展开卡片行时渲染。`element` 是一个空的 `HTMLElement` 容器。
## 分发来源
### 安装位置:`$DSH_HOME/connection-cards/cards//`
**只装在这里,不碰 DSH 的 profile** —— 不跑 pnpm、不改 `dsh.profile.bundles`。
理由(用户原话):
> 下载到我们自己的目录里、在里面用 —— **这样不会破坏 DSH 的更新,
> 也不会被 DSH 的更新破坏**。
走 profile 安装会让我们和 DSH 共享依赖树、版本约束与准入检查,一旦 DSH 升级
或依赖冲突,**故障面会波及整个 DSH**。自己的目录则完全隔离,而且不受模块解析链
限制(路径自己算,绝对路径 `import()` 即可)。
### 面板内安装支持四种来源
| 来源 | 例子 | 实现 |
|:---|:---|:---|
| 本地目录 | `D:\my-cards\monitor-card` | 直接拷贝 |
| 本地 tgz | `D:\downloads\monitor-card-1.0.0.tgz` | 系统 `tar` 解压 |
| npm 包名 | `monitor-card` / `@scope/monitor-card` | 拉 registry **tarball**(一次 HTTPS GET) |
| HTTP tgz | `https://example.com/card.tgz` | 下载后解压 |
**npm 那条不引 pnpm** —— 为装一张卡片把包管理器拖进来不值得,
而且 pnpm 会改写 profile,那就破坏隔离性了。
### 安装时校验(先校验来源,再动目标目录)
拒绝以下情况,**且绝不删旧目录**(覆盖安装要先删旧的,所以这个次序是关键):
- 没有 `package.json`
- `package.json` 不是合法 JSON
- **没有 `dshCard` 字段**(这不是一张卡片包)
- **入口文件不存在**(`dshCard.entry` → `main` → `index.js` 逐级回退)
失败原因会原样回给面板,用户看得到"为什么没装上"。
### 内置
随宿主插件发布,位于本包的 `cards/` 目录(monitor-card、self-heal-card、goal-relay-card)。
**内置与已安装是两类来源**(`source: 'builtin' | 'installed'`):强制重扫时
只清 `installed` —— 内置卡片不可能"在磁盘上消失",清掉再重扫纯属浪费,
而且内置根目录万一临时读不到就会全没了。
## 安全约束
1. 卡片代码**绝不 import** 任何 `@deepseek-ai/*` 包
2. 所有 DSH 交互走中间适配层(DSHAdapter)
3. `requestRemote` 必须在对端声明的白名单方法内
4. 对端执行使用对端自身权限上下文,连接权限只决定能否发起请求
5. 所有 `requestRemote` 调用记录审计日志(`$DSH_HOME/connection-cards/audit.log`)
6. 白名单修改需双方确认(防提权)
## 官方示例卡片
| 卡片 | 功能 | 权限要求 | 事件 |
|---|---|---|---|
| monitor-card | 监控预设状态、会话空闲 | read | preset_error, session_idle |
| self-heal-card | 预设损坏时自动修复 | read + write | preset_error, preset_repaired |
| goal-relay-card | 在连接的会话间中继目标 | read + write | goal_updated, goal_completed |
---
## 实现状态
### 已实现(可用)
| 能力 | 入口 | 说明 |
|---|---|---|
| 模板发现 | `listTemplates()` / RPC `cards/templates` | 扫描两个根目录:内置(随插件发布的 `cards/`)+ 已安装(`$DSH_HOME/connection-cards/cards/`)。读每个包 `package.json` 的 `dshCard` 字段 |
| 装载到连接 | `loadCard()` / RPC `cards/load` | import 模块 → 建实例 → 挂到连接 → `apply(api)` → **立即落盘** |
| 卸载 | `unloadCard()` / RPC `cards/unload` | 从连接摘除 + 注销实例 + 落盘 |
| 重载 | `reloadCard()` / RPC `cards/reload` | 清模块缓存重新 import(改卡片代码后不用重启) |
| 面板渲染 | `renderCardPanel()` / RPC `cards/panel` | 见下方「面板渲染」 |
| 重启重放 | `restoreAll()`(宿主 apply 时自动调用) | 连接从 `connections.json` 恢复,但卡片 `apply()` 不会自动重跑 —— 不重放卡片就是「哑」的(事件订阅、工具注册全丢) |
| 崩溃隔离 | `sandbox.ts` | 卡片 import/apply 抛异常不会拖垮宿主 |
| **安装** | `installCard()` / RPC `cards/install` | 本地目录 / tgz / npm 包名 / HTTP 地址 → 装到我们自己的目录;先校验来源再动目标 |
| **卸载卡片包** | `uninstallCard()` / RPC `cards/uninstall` | 连同模板注册与模块缓存一起清掉 |
| **可见范围** | `setCardScope()` / RPC `cards/set-scope` | 两端 / 仅 A / 仅 B;装载时可选,装载后可改 |
| 面板 UI | 连接面板 → 展开连接 → 卡片区 | 列出可用模板一键添加;已装载的可改范围、「重载」「移除」;底部可安装新卡片 |
### 面板渲染:为什么在宿主侧跑
卡片模块 **import 在宿主进程**(`apply` 要订阅连接事件、注册工具,这些都在宿主侧),
但协议里 `mountPanel(element, api)` 收的是 `HTMLElement`,而宿主没有 DOM。
所以宿主提供一个**只支持 `innerHTML` 的 DOM 替身**(`card-host/dom-shim.ts`),
调用 `mountPanel` 后取回 HTML 交给浏览器注入。
**优先建议新卡片导出 `renderPanel(api): string`** —— 纯字符串,不需要 DOM 替身,语义更干净:
```js
export function renderPanel(api) {
return `状态:${api ? '正常' : '未知'}
`
}
```
**局限(务必知悉)**:`mountPanel` 里做真实 DOM 操作
(`appendChild`、`addEventListener`、`querySelector`)**不会生效** ——
替身只提供空实现。需要交互式面板的卡片必须导出 `renderPanel`,
或者等后续把卡片 UI 资源下发给浏览器执行。
### 未实现
> 下表这些项**尚未实现**。接口已就位,实现待补(路线图见 README 的「路线图(规划中)」一节)。
| 能力 | 状态 |
|---|---|
| 卡片面板的浏览器侧执行 | 未实现(见上方局限:面板 HTML 在宿主侧渲染后注入) |
| `requestRemote` | **接口已就位、实现待补**:宿主没有 `ctx.remote`,目前永远返回 `not_available`。白名单校验与审计日志已就绪,缺的是真正能打到对端会话的执行通道 |
| `repairPreset` | **接口已就位、实现待补**:未接真实修复逻辑 |
### 手动安装一张卡片
```powershell
# 卡片目录需含 package.json(带 dshCard 字段)与入口 JS
Copy-Item -Recurse .\my-card "$env:USERPROFILE\.dsh\connection-cards\cards\my-card"
```
宿主下次扫描模板时即可在面板里看到它(`scanTemplates()` 幂等,重载插件即刷新)。