# dsh-update-notifier

[English](README.md) | 中文

每小时——或用 `/check-updates` 随时手动触发——检查已安装的
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)（dsh）
插件有无新版本，并用一个**一键审批气泡**让你决定是否升级。

dsh 本身不会告诉你某个已安装插件已经出了新版，只能靠自己记得去 `npm view`。
本插件补上这一环：每小时把每个已安装插件和注册表比对一次，发现更新时直接在会话里
弹出一个真正的提问气泡，问你要升级哪些。勾选之后由智能体当着你的面执行升级。
它**绝不会擅自升级任何东西**。

## 行为

每小时（可配置）对每个支撑已加载插件的 npm 包：

- **查询注册表的 `latest`**，与 profile 的 `node_modules` 中已安装的版本比对。
  版本比较严格遵循 semver——预发布版本排序正确，绝不建议降级；不符合严格
  `X.Y.Z` 格式的版本会被跳过，而不是猜测其含义。
- **用一个气泡询问。** 所有更新的版本会汇总成一个多选提问，由 dsh 自己渲染
  （`dsh-chrome 0.1.2 -> 0.1.3`，每个插件一个选项），挂在你最近活跃的会话上。
  它不占用模型轮次、不消耗 token，断线重连后还会重放。
- **把勾选的交给智能体**：以一条后续消息指示它执行

  ```sh
  pnpm --dir <profileDir> add <pkg>@<latest> ...
  ```

  智能体会立即被唤醒，你可以直接看到升级过程。搭配
  [`dsh-hot-reload`](https://github.com/stuarthu/dsh-hot-reload)
  使用，新版本无需重启 dsh 即可生效。

## `/check-updates`

不想等下一个整点？在会话里输入 `/check-updates`：

```
/check-updates
check-updates · 2 updates — see the question above (7 plugins checked)
```

它会立即针对你输入命令的那个会话跑一轮完整检查，并在注册表查询结束的那一刻就
结算这条命令——它弹出的气泡就是一个普通气泡，你可以随时回答，刷新页面也不会丢。
如果当前根本无法弹出气泡，结算文本里会直接给出 `pnpm ... add` 命令，而不是只
告诉你"做不到"。

查询不到的包（网络故障、超时、5xx、限流，或响应里根本没有版本号）会在结算文本中
单独计数，而不会被当成"已是最新"；如果**所有**包都查不到，这条命令会以错误结算，
而不是报告"没有更新"。两个数字相加即为本轮总数：`5 plugins checked, 2 unreachable`
表示一共查了 7 个。
取消这条命令会同时中止检查，因此被你取消的检查不会过一会儿又弹出气泡。

手动检查会**忽略下面的拒绝记忆**：既然是你主动问的，就把完整列表给你，包括你此前
未勾选的版本。它不会打乱定时检查的节奏；如果已有气泡未回答、或已有检查正在进行，
它会带着原因拒绝执行。

只要 dsh 组合了命令注册表，该命令就会出现——`@deepseek-ai/dsh-base` 就会组合它，
因此基于它的 profile 都能用 `/check-updates`。命令通过 `ctx.inject` 注册，所以
没有命令注册表的 headless profile 依然能加载本插件并照常执行定时检查。

## 只问一次

同一个版本只会问你**一次**（指定时检查；上面的命令可以越过这条规则）：

- **未勾选的视为拒绝**，记录在 `<profileDir>/.dsh-update-notifier.json` 中，
  **跨重启**永不再提，直到注册表的 `latest` 变成另一个仍然高于你已安装版本的
  版本为止。删除该文件即可重新开始询问。
- **已批准的不会记录**：升级成功后它自然就是当前版本；升级失败的话，重启后重新
  提醒才是合理的。
- **你从未回答过的气泡**（会话结束、dsh 停止）不算拒绝——下次仍会询问。

尚无活跃会话时，提醒会挂起：在你下一条消息之后立即重试，下一轮定时检查也会重试。
如果根本没有任何界面能渲染气泡——headless profile，或者虽然组合了 `userQuestions`
服务但没有任何 UI 向它注册 provider——提醒同样会挂起，并且会把可用更新和确切的
升级命令一并写入日志，因为这种情况下你再发消息也不会有气泡出现。

## 安装

```sh
dsh plugin --profile web add dsh-update-notifier
```

然后重启一次 dsh（bundle patch 层在启动时加载）。适用于**任何 profile**——把
`web` 换成你实际使用的 profile 即可，它检查的就是自己被加载进的那个 profile。
需要 Node 18+（依赖全局 `fetch`），任何 dsh 宿主都已满足。

## 兼容性

基于 **dsh `0.1.0-rc.6`**（Node 22 / 24）构建并测试。它只使用宿主的公开服务，
因此某项缺失时只会降级而不会崩溃：

| 宿主接口 | 用途 |
|---|---|
| `loader.entries()` | 找出哪些包支撑着已加载的插件 |
| `userQuestions.ask()` | 渲染审批气泡 |
| `userQuestions.provider` | 没有 UI 注册渲染时，不承诺会有气泡 |
| `agents.get(sessionId)` | 解析会话对应的活跃智能体 |
| `agent.followup()` | 唤醒该智能体执行升级 |
| `session/event`（`user/message`） | 追踪最近活跃的会话 |
| `agent/disposed` | 撤回会话已销毁的气泡 |
| `commands.register()` | 注册 `/check-updates` 命令 |
| `agents.roots()` | 避免承诺一个 `ask()` 会拒绝的气泡 |

`userQuestions` 和 `commands` 都是用到时才解析的，而不是声明为依赖，因此在两者
都不存在的 headless profile 中，插件依然能正常加载。

## 退出检查

不希望被提示升级的插件，可以在自己的 `package.json` 中声明：

```json
{ "dsh": { "updateNotifier": false } }
```

对于不由你掌控的插件，用下面的 `exclude` 让它闭嘴。

## 配置

设置在 profile 的 `cordis.patch.yml` 里 `update-notifier` 那一行上：

| 键 | 默认值 | 含义 |
|---|---|---|
| `interval` | `3600000` | 检查间隔毫秒数（下限钳制为 60000） |
| `initialDelay` | `10000` | 启动后首次检查前的延迟毫秒数 |
| `registry` | `https://registry.npmjs.org` | 要查询的 npm 兼容注册表 |
| `exclude` | `[]` | 永不检查/提及的包名列表 |
| `fetchTimeout` | `10000` | 单个注册表请求超时，毫秒（下限 1000） |
| `profileDir` | 自动 | profile 目录的绝对路径（省略时从 loader base URL 自动探测） |

## 安全

- **未经点击不会有任何升级。** 本插件从不自行启动包管理器，只负责组装气泡，
  以及在你批准后生成那条由智能体当着你的面执行的指令。
- **注册表的自由文本不会进入 UI 或模型。** 进入气泡和智能体消息的，只有来自你
  本地 profile 的包名，以及通过 `/^[0-9A-Za-z.+-]{1,64}$/` 校验的版本号——绝不
  包含从注册表取回的描述、README 或变更日志。
- 后续消息的 `source.kind` 为 `"plugin"`，明确列出已批准的包，并指示智能体不得
  改动其他任何内容。

## 局限 —— 请务必阅读

- **它只负责询问，不负责验证。** 你批准之后，升级就交给智能体执行了。失败会显示
  在会话里，但本插件既不会重试，也不会事后确认新版本是否真的装上了。
- **只能看到从注册表安装的插件。** 从本地检出 link 过来的插件，或 dsh 内置的
  插件，在 profile 的 `node_modules` 下没有对应的包，会被静默跳过——没有气泡，
  也没有日志。
- **只查询 `latest` dist-tag。** 不支持固定版本范围、`next`/`beta` 通道，也无法
  把某个包压在某个大版本上；对于永远不想升级的插件，请使用 `exclude`。
- **同一时间只有一个气泡。** 在它等待回答期间，定时检查会跳过，`/check-updates`
  也会以"已有升级提问未回答"为由拒绝执行；这期间发布的新版本，会在你回答之后的
  第一轮检查中被发现。
- **拒绝是针对那个确切版本的**，而不是"到此为止的所有版本"：如果注册表的
  `latest` 变成另一个仍然高于你当前版本的版本——包括 unpublish 之后降下来的
  版本——你还是会被再次询问。
- 气泡需要一个 dsh 能挂载提问的会话。注册表故障、loader 状态异常、状态文件不可
  写，都只会降级为日志；只有两种情况会让插件直接停用——上下文里没有插件 loader，
  以及定位不到 profile 目录——此时日志中会明确说明是哪一种。

## 许可

[MIT](LICENSE) © Stuart Hu
