# dsh-update-copilot

[![License: MIT](https://img.shields.io/badge/license-MIT-0B7285?style=flat-square)](LICENSE)
[![DSH core](https://img.shields.io/badge/DSH-%3E%3D%200.1.0--rc.7-5B4CF0?style=flat-square)](https://www.npmjs.com/package/@deepseek-ai/dsh)
[![Zero build](https://img.shields.io/badge/zero--build-no%20bundler-2EA44F?style=flat-square)](lib)
[![GitHub stars](https://img.shields.io/github/stars/hezhongtang/dsh-update-copilot?style=flat-square&logo=github)](https://github.com/hezhongtang/dsh-update-copilot/stargazers)

**[DeepSeek Harness](https://www.npmjs.com/package/@deepseek-ai/dsh) 的更新助手：追踪 dsh 本体、官方 bundle 和所有已装插件——跨全部 profile 按包合并展示；插件更新带预检闸门一键执行，本体代执行更新（始终钉最新健康版本，全套兼容闸门，仅限可写的全局 npm 安装），补丁名体检捕捉被 dsh 升级静默丢弃的配置。**

<p align="center">
  <img src="assets/popup.png" width="480" alt="更新助手弹窗：「DeepSeek Harness 本体」卡片默认收起，插件跨 profile 合并进「可更新」分组（行内一键更新），「已最新」分组折叠。" />
</p>

[English](README.md) | 中文

## 为什么做这个

DSH 迭代很快，插件生态同样如此。每个 profile 通过 pnpm spec 安装插件——npm 版本号、GitHub commit 锁定、本地 `link:` 目录——每种通道各有各的过期方式。手动检查意味着挨个仓库跑一遍；无脑全自动升级则等于把环境交给第三方代码。

这个插件走中间路线：**全部检测、汇总变更、只更新你点过的东西。** 更新就是一键——按钮和动作之间没有确认仪式，且只会作用于该包显示出的合格 profile。DSH 本体是带闸门的操作（[ADR-0001](docs/adr/0001-core-update-execution.md)）：在可写的全局 npm 安装上，本体卡片可以在全套兼容闸门之后代执行更新——始终钉到雷达验证过的最新版本（绝不执行 dist-tag 字符串），解析出降级时除非显式 force 否则拒绝。其余安装形态（npx 等）保持「复制命令」行为，且本体绝不参与任何自动更新批次。

## 功能

| | |
|---|---|
| 🔭 **全量雷达** | 一次扫描覆盖 dsh 本体 + 官方 bundle（`dsh-base`、`dsh-web-app`）+ 所有 profile 的插件依赖 |
| 🔄 **双通道** | npm registry 版本（完整 semver 比较，含 prerelease）+ git 上游（pinned commit vs HEAD）。`link:`/`file:` 本地目录只列出、不探测、不管理 |
| 🤖 **Agent 工具** | `update_copilot_scan` / `update_copilot_preflight` / `update_copilot_update` / `update_copilot_core`——对 Agent 说一句「有没有更新」，得到有数据支撑的回答；扫描按包合并（一个包一行），自动推断的挂载关系只用于展示；官方包跟随 harness 安装（本体更新走 `update_copilot_core`），每个直接依赖仍按自身更新策略处理。`profile` 参数在 preflight/update 上可选：不传时 preflight 逐个评估所有装有该包的 profile，update 也只在那些合格 profile 中执行 |
| 🖥 **Web 界面** | 设置旁的侧栏入口（徽章在挂载时补齐、启动扫描后刷新）打开紧凑雷达弹窗，旁边就是**「⚡ 一键更新」快捷按钮**——不必打开任何视图，点击即按序更新所有落后且可自动更新的插件（运行中显示 `n/m` 进度并禁用，完成显示 ✓/✗ 反馈，失败或需重启时自动打开弹窗看明细）。弹窗承载完整雷达，共用同一折叠布局——「DeepSeek Harness 本体」卡片默认收起，插件拆成「可更新」「已最新」两组（已最新默认折叠、点击展开），自动推断的挂载关系始终跟随父行。插件跨 profile 合并成一行（每个已在的 profile 的当前 → 最新版本内联列出），挂载的包可在父行下展开披露，同时保留独立归属和更新操作——子项「更新」只更新子项，父项普通「更新」只更新父项，「更新 bundle」只处理过期且合格的目标（父项过期先更新父项，再按每条关系的 profile 范围更新过期的挂载子项）。点一次「更新」只在该包明确列出的合格 profile 中执行，工具栏「一键更新全部」按序跑完所有合格的落后包（在弹窗内勾选「点击按钮时自动更新」后，点侧栏按钮一发现有落后插件就自动开始这一轮，dsh 本体绝不参与这一自动批次）。所有变更操作共用一个 UI 操作锁；更新或刷新状态未结束时禁止刷新。更新过程通过 SSE 实时推送进度，直接渲染成每行进度条：插件通道跑 pnpm 的 NDJSON reporter，下载有真实字节级进度（已下载 / 总字节、当前包名、实测速度与剩余时间），解析与链接阶段报告包计数，总数确实未知的阶段用条纹不定态动画而不是编造百分比（只走 npm 的本体更新在拿到最终 summary 前就是不定态——npm 非 TTY 什么也不打印）；**更新从不静默**——无论这轮更新由谁发起（自动更新、一键更新快捷键、Agent 工具、另一个标签页），运行期间侧栏按钮的徽章会变成跳动的「更新中」圆点，弹窗常驻「正在更新：包名（profile）…」横幅（含当前阶段、字节 / 包计数、速度与剩余时间、百分比），所有更新按钮同步禁用，更新一结束列表自动刷新；无论这轮更新由谁发起，雷达里匹配的那一行都会内联同一根实时进度条（发起行走 SSE 流，其余座位经 2 秒轮询镜像），新一轮更新开始时该行残留的上一次结果自动清除——后台静默更新不会再和前台点击撞出「更新中」报错 |
| 🛡 **更新护栏** | 同源 POST + 显式 `confirm`、严格目标 allowlist、单并发锁、5 分钟超时；预检**硬拦截门**拒绝目标 dsh 确实会弄坏的更新（缺失 named export），显式 force 才可越过；npm/github 通道只走官方 `dsh plugin` CLI，`link:`/`file:` 本地目录与官方 `@deepseek-ai/*` 包一律拒绝——本地检出请在它自己的仓库里更新；**空包 dist-tag / husk 发布**（无入口、无 `dsh.bundle`、tarball 极小）在解析目标版本时直接跳过并拒绝安装 |
| 🩹 **补丁名体检** | dsh 的补丁装载器对 `cordis.patch.yml` 每条记录做严格名校验，钉住的包名不存在时**整条静默跳过（含 config）**——0.1.7-rc.2 把 `dsh-llm-deepseek` 改名为 `dsh-llm-deepseek-api-key`，用户自定义模型列表无声失效，唯一信号是一行 stderr。雷达现在把每个 profile 的补丁文件对照装载器全部解析来源（依赖树、声明的 bundles、两层 node_modules、bundle 补丁行）做体检，钉住已消失名字的条目以横幅呈现，附修复方式与逐 profile 验证命令（`dsh --profile <p> --dump-config 2>&1 >/dev/null | grep "mismatch\|not found"`）；核心更新闸门还会在升级前预警「钉住的 `@deepseek-ai/*` 名字不在目标版本包集中」（附疑似新名提示），更新成功后逐 profile 跑一次 post-flight `--dump-config`，把新装载器自己的跳过行收进同一组预警 |
| 🧯 **Host 导出检查** | 扫描第三方插件对 `@deepseek-ai/*` 的 named import，对照当前（以及本体有新版本时的目标）DSH host 包实际导出——「范围合法、导出没了」这类错误会在启动时整棵插件树挂掉。雷达本体卡片和插件行给出提醒，并附带可复制的 `cordis.patch.yml` 禁用片段与 `dsh plugin remove` 命令。DSH 已经起不来时，不经过 `dsh web`，直接跑 `node …/dsh-update-copilot/lib/cli.js` |
| 🧭 **升级卡 corridor + 分层预检** | 预检输出对齐社区三层失败签名（`link-time` / `mount-time` / `run-time`，外加 `storage` / `breaking-card`）：`layerFindings` 携带 action level、证据与修复建议。Release 说明与提交里的破坏性变更标记（`!:`、`BREAKING CHANGE`、移除/更名措辞）同样被扫描，以预警身份随门检返回。跨 host 版本会先走 oh-my-dsh 升级卡 corridor——**缺边即停**（没有 curated 卡的 hop 不会假装安全）；session 格式 V2→V3→V4 只向前迁移，跳变要求先快照。已知 run-time 契约坑（`prepareCall`、`Session.events`、`registerContinuableSetup` 等）做静态能力风险提示。 |
| 🩹 **更新后连带体检** | 更新成功后重新体检 profile 的全部插件成员，而不只是被更新的包——pnpm 会整树重写共享依赖，没被动过的兄弟插件也可能被连累。新增的 broken/missing 行会出现在更新结果里，并标注为连带损坏。 |
| ↩️ **更新历史与回滚** | 每次变更前，更新助手把已装版本、spec、pinned commit 和 bundle patch 原文快照进 `$DSH_HOME` 下自己的历史目录（每包保留 10 份，尽力而为）。回滚 = 以旧 target 走现有带确认的更新流程——行上有两段式回滚按钮。离线 CLI 可列出快照与手工回滚命令：`node …/lib/cli.js history [profile] [package]`。 |
| 🌐 **完整双语** | 所有面向用户的文案——弹窗、徽章、更新错误——跟随界面语言（中/英）；Agent 工具路径保留稳定英文标识 |

## 安装

```sh
# 从 npm 安装（推荐）
dsh plugin --profile web add dsh-update-copilot

# 或者直接从 GitHub 仓库安装
dsh plugin --profile web add github:hezhongtang/dsh-update-copilot
```

重启 `dsh web`，点击侧栏设置旁的**「更新助手」**入口。其他 profile 用法相同（`--profile <name>`）。

## 使用

### 问你的 Agent

> 「帮我看看插件有没有更新」

Agent 会调用 `update_copilot_scan`，对落后项跑 `update_copilot_preflight`、先呈现风险，然后等你拍板。更新工具在没有 `confirm: true` 时直接拒绝执行。

### 或者用弹窗

**设置旁的侧栏按钮**打开紧凑雷达弹窗（ESC 或点击遮罩关闭）。按钮右侧的**「⚡ 一键更新」**在点击时直接开跑（不经过弹窗）：按序更新所有落后且可自动更新的插件，运行中按钮显示 `n/m` 进度、徽章变跳动圆点，完成短暂显示 ✓/✗ 后恢复；失败或需重启时自动打开弹窗展示明细。在弹窗内勾选「点击按钮时自动更新」后，点侧栏按钮还会在发现有落后插件时立即自动开始「一键更新全部」，进度直接显示在弹窗里。

**更新过程全程可见**：无论由谁发起——侧栏自动更新、你点的行内更新、Agent 工具（`update_copilot_update`）、另一个浏览器标签页——弹窗没开时，侧栏按钮的徽章会变成跳动的圆点（悬停提示正在更新的包名）；弹窗打开时，顶部常驻一条「正在更新：包名（profile）…」横幅，跟随服务端实时阶段（解析依赖 / 下载中 / 链接依赖 / 重试中）、字节或包计数、实测速度、剩余时间与百分比。横幅出现期间所有变更操作（行内「更新」、「更新 bundle」、「一键更新全部」）都会禁用，避免后台更新与前台的点击抢同一把锁而撞出「正在更新中，请稍候」的报错。批量跑序（「一键更新全部」「更新 bundle」）行内直接呈现队列状态：正在执行的那行显示「更新中…」与实时进度条，还没轮到的那行按钮变成**「待更新」**（悬停提示当前项完成后自动开始），已跑过的行则保持原样，避免把旧扫描的「可更新」误读成未开始；更新结束后列表自动刷新出新版本。

**弹窗即完整雷达**：本体卡片的**单一「更新本体」**操作（始终取雷达验证过的最新版、绝不执行 dist-tag 字符串；在可写的全局 npm 安装上，两步确认按钮代跑带闸门的 `npm install -g` 并报告重启要求与回滚命令——npx 等其余形态仅展示命令）、全部已装插件跨 profile 合并成一行（每个 profile 的当前 → 最新版本内联展示）、可展开的挂载关系，以及每行一个**一键「更新」**按钮。子项行的「更新」只作用于子项，父项普通「更新」只作用于父项；「更新 bundle」跳过当前目标，在父项符合条件且过期时先运行父项，再按每条关系的 profile 范围依次运行过期的已选挂载子项，并报告进度与结果。每个包都带明确的合格 profile，不会按同名依赖盲目更新全部 profile。工具栏的**「一键更新全部」**仍是独立的全局操作，按序跑合格的落后包。更新过程由 SSE 实时推送到每行进度条。更新完成后，结果报告 `dsh` 重启要求，弹窗显示重启横幅。

### Agent 工具一览

| 工具 | 读/写 | 用途 |
|---|---|---|
| `update_copilot_scan` | 读 | 全量扫描：核心 + 所有 profile，按包合并（10 分钟缓存，`force` 强制刷新） |
| `update_copilot_update` | 写 | 执行一次**已确认**的更新——不传 `profile` 时只在该包明确列出的合格 profile 中执行；npm/github 通道走官方 `dsh plugin` CLI（瞬时失败自动重试——最多 3 次、指数退避加全抖动；版本不存在、鉴权被拒等确定性错误快速失败）。`link:`/`file:` 本地目录不由助手管理，请在它自己的 checkout 内更新。`target` 可回滚到确切的旧版本；`force: true` 覆盖预检硬拦截（仅在用户明确同意后传入） |
| `update_copilot_preflight` | 读 | 一个包的门检决策，不触发任何变更：按 profile 给出 ok / warning / blocked，附拦截证据（目标 dsh 缺失的导出）、host API 能力风险信号、release notes 与 commits 的破坏性变更信号 |
| `update_copilot_core` | 写 | 执行**已确认**的 DSH 本体更新。始终以最新健康发布版为目标，先解析成具体版本（绝不执行 dist-tag 字符串），解析出降级时无 `force` 拒绝。执行前过全套闸门：升级卡片走廊、session 格式存储边界、跨全部 profile 的逐插件导出证据；`force` 仅在用户同意后覆盖，证据保留在结果里。只在可写的全局 npm 安装上执行——npx / 不可溯源的启动会返回手工命令。结果必带 `requiresRestart` 与回滚命令 |

## 工作原理

每个依赖 spec 先分类到通道，每个通道有自己的比较方式。扫描结果按包跨 profile 合并：同一个包装在 web / headless / desktop，就只出现一行，携带每个 profile 的通道与版本，以及明确的合格更新 profile。每个 profile 内，活动 bundle 的包内 patch 若挂载至少两个生产依赖，会展示仅用于呈现的挂载关系；多个父包声明同一子项时，先选已验证子项更多的父包，再按包名确定归属。挂载关系不转移更新归属，每个直接依赖仍按自身策略更新；bundle 更新沿用所选关系的 profile 范围。`link:` 与 `file:` 本地依赖保持本地；官方 `@deepseek-ai/*` 包只报告，不能独立更新。

**什么才算一行插件：** profile manifest 里 `dsh.profile.bundles` 声明的包（宿主真正加载的那份清单），外加所有 `link:` / `file:` 本地开发检出——尚未激活也保持可见，外加活动 bundle 的已验证 patch 挂载子项——宿主正是通过 patch 的 insert 记录加载这些插件。manifest 里的其它普通依赖（顺手装进来的 CLI、服务端运行时等）不是 dsh 插件，也不会渲染成插件行；否则同一个 GitHub 仓库的真插件旁边会多出一个幽灵更新按钮。manifest 没有 bundle 清单时回退为展示全部依赖。

| 通道 | spec 示例 | 当前版本 | 最新版本 |
|---|---|---|---|
| npm | `^0.1.4` | 已装 `package.json` 的版本 | registry 全量文档中的最新版 |
| github | `github:owner/repo#sha` | `pnpm-lock.yaml` 锁定的 commit | GitHub API 查询的上游 HEAD |
| linked | `link:../my-plugin` | 已装 `package.json` 版本 | —（不探测；在其自身仓库内管理） |

npm 通道刻意不信任 `latest` dist-tag：monorepo 子包的这个 tag 常年滞后，会把实际比 tag 更新的安装误报为落后。版本比较采用完整 semver 优先级（含 prerelease），因此 `0.1.0-rc.6 > 0.1.0-rc.5`、`1.0.0 > 1.0.0-rc.1` 都成立。

更新通过严格校验的路径执行，绝不拼接 shell：npm/github 通道走 `dsh plugin --profile <p> add <target>`——和人手动输入的是同一条路径——目标字符串经过 allowlist 校验；`link:`/`file:` 本地目录绝不触碰。该子进程带 pnpm 的 NDJSON reporter（`--reporter=ndjson`）运行——它是非 TTY 下唯一携带字节级下载进度的输出，面板据此渲染确定性下载进度条；reporter 的错误事件会被重建成可读的 `pnpm: <code> <message>` 行并入捕获输出，确定性失败分类（`ERR_PNPM_NO_MATCHING_VERSION`、`E404`、鉴权被拒）因此继续快速失败。DSH 本体走独立的带闸门执行器：目标先解析成具体的钉定版本（绝不执行 tag 字符串），安装形态必须是可写的全局 npm 安装，且闸门——走廊、存储边界、跨全部 profile 的逐插件导出证据——通过后才 spawn `npm install -g @deepseek-ai/dsh@<版本>`；闸门同时产出咨询级预警（补丁钉住的名字目标版本不再发布），升级成功后逐 profile 的 post-flight `--dump-config` 探测把新装载器的跳过行并入同一组预警；运行中的进程永远不会是被更新的那个，因此变更必然报告重启要求与回滚命令。瞬时失败自动重试：最多共 3 次，间隔采用指数退避 + 全抖动（基准 1s、上限 8s），避免一批更新在网络恢复瞬间同时扎堆重试；确定性错误——包或版本不存在（`E404`、`ETARGET`）、鉴权被拒（`E401`/`403`）——跳过剩余次数快速失败。子项、父项和 bundle 操作都只按包行里明确列出的合格 profile 执行；「一键更新全部」仍是独立的全局操作，绝不因同名依赖而更新全部 profile。


## DSH 起不来时

第三方插件若 `import { 某个已删除的名字 } from '@deepseek-ai/…'`，loader 会让整棵 plugin tree 失败，更新助手的 Web 界面也出不来。这时在任意能跑 Node 的 shell 里：

```sh
node ~/.dsh/profiles/web/node_modules/dsh-update-copilot/lib/cli.js
# 本地 link 安装：
node /path/to/dsh-update-copilot/lib/cli.js
```

只对照**当前磁盘上的 DSH**（不查 npm、不预演目标版本），因此 DSH 起不来时也能跑。已不兼容时退出码为 1，并打印禁用 / 卸载命令。要从一次失败的更新中恢复，用 `history` 子命令列出更新前快照与手工回滚命令：

```sh
node ~/.dsh/profiles/web/node_modules/dsh-update-copilot/lib/cli.js history          # 所有 profile
node …/lib/cli.js history web my-plugin                                              # 单个包
```

## 安全性

- 变更路由有两个：`POST /dsh-update-copilot/update`（插件）与 `POST /dsh-update-copilot/update-core`（带闸门的本体更新）：强制同源且校验实际传输协议，必须显式 `confirm: true`，不信任转发协议请求头。TLS 终止代理可设置其公开 HTTP(S) origin 至 `DSH_UPDATE_COPILOT_PUBLIC_ORIGIN`；请求必须精确匹配，配置无效时拒绝所有请求。
- 官方 `@deepseek-ai/*` 包和 dsh 本体绝不自动更新；本体只在显式确认的动作下执行，且仅限可写的全局 npm 安装。
- 所有上游查询均为只读（`registry.npmjs.org`、`api.github.com`、`git ls-remote`）且带硬超时；单项查询失败只降级该项，不影响整体扫描。

## 限制

- 每次有变更的更新都需要重启 `dsh` 才生效：结果返回 `requiresRestart: true`，弹窗显示重启提示。不做进程内热重载。
- `link:`/`file:` 本地目录在雷达中列出但绝不探测、绝不更新：请在它自己的仓库内管理（git pull）。
- GitHub API 未认证时限流 60 次/小时——破坏性变更信号会优雅降级为无信号。
- 裸 `git+https://` spec 只报告、不提供比较通道。
- Host 导出检查是静态 named import：动态 `import()`、运行时才碰到的 API、以及 `import * as ns` 不会标出来。官方 `@deepseek-ai/*` 包不扫。目标版本只 pack 与 DSH 同版本线的 `@deepseek-ai/dsh-*`；pack 失败或独立版本的包（cordis、schemastery）跳过，不报假不兼容。
- 历史快照尽力而为——目录不可写时只是没有回滚建议，更新本身绝不会被阻断。

## 参与贡献

欢迎提 Issue 和 PR：[hezhongtang/dsh-update-copilot](https://github.com/hezhongtang/dsh-update-copilot)。代码库刻意保持小巧、零依赖——host 是纯 ESM，浏览器端是手写 CJS bundle，无需搭建任何构建环境。

## 许可

[MIT](LICENSE) © 2026 hezhongtang
