dsh-armory 1.9.4 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +26 -8
- package/README.md +39 -16
- package/docs/chunk-split-feasibility.md +153 -0
- package/docs/host-proposal-mcp-teardown.md +184 -0
- package/docs/host-proposal-skill-toggle.md +166 -0
- package/docs/mcp-market-design.md +62 -0
- package/docs/prd-awesome-dsh-plugins.md +105 -0
- package/docs/prd-workbuddy-parity.md +128 -0
- package/docs/system-design-awesome.md +269 -0
- package/docs/system_design.md +530 -0
- package/docs/workbuddy-market-gap-analysis.md +197 -0
- package/lib/client.js +4507 -271
- package/lib/expert-market.js +13 -2
- package/lib/index.js +395 -9
- package/lib/mcp-catalog.js +104 -0
- package/lib/mcp-config.js +10 -1
- package/lib/mcp-import-sources.js +154 -14
- package/lib/mcp-oauth.js +806 -0
- package/lib/tool-ledger.js +119 -0
- package/lib/typert.js +26 -0
- package/lib/wb-auth-rules.js +128 -0
- package/lib/wb-awesome-market.js +453 -0
- package/lib/wb-install-state.js +151 -0
- package/lib/wb-market.js +232 -8
- package/lib/wb-plugin-market.js +291 -0
- package/lib/wb-registry.js +224 -0
- package/lib/whats-new.js +82 -0
- package/lib/workspace-runtime.js +133 -4
- package/package.json +10 -9
package/README.en.md
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
[中文](README.md) | English
|
|
4
4
|
|
|
5
|
-
> **The all-in-one control board for DeepSeek Harness**: MCP servers · Skills · Experts · tool-usage ledger ·
|
|
6
|
-
> This project is a fork of [Imzl-zl/dsh-mcp-manager-ui](https://github.com/Imzl-zl/dsh-mcp-manager-ui) (MIT),
|
|
7
|
-
> upgraded from a pure MCP panel into a **
|
|
5
|
+
> **The all-in-one control board for DeepSeek Harness**: MCP servers · Skills · Experts · Plugins · tool-usage ledger · four markets (WorkBuddy connectors ×316 / curated built-ins ×47 / SkillHub skills ×190k+ / WorkBuddy experts ×429, plus CodeBuddy official plugins and the DSH community list), with browsing, search, one-click install and one-click summon.
|
|
6
|
+
> This project is a fork of [Imzl-zl/dsh-mcp-manager-ui](https://github.com/Imzl-zl/dsh-mcp-manager-ui) (MIT, the original covered MCP only); this repo is [2026heshao/dsh-armory](https://github.com/2026heshao/dsh-armory),
|
|
7
|
+
> upgraded from a pure MCP panel into a **MCP + Skills + Experts + Plugins + Markets + Tool Tailor** board.
|
|
8
|
+
>
|
|
9
|
+
> **New in v2.0.0**: a Plugins page with three sources, one-click MCP OAuth authorization, credential prompts before install, an install-state ledger, and six categories for the skill board. See the [changelog](.github/release-notes/v2.0.0.md).
|
|
8
10
|
|
|
9
11
|
<p align="center">
|
|
10
12
|
<a href="https://github.com/2026heshao/dsh-armory/stargazers"><img src="https://img.shields.io/github/stars/2026heshao/dsh-armory?style=social" alt="stars"></a>
|
|
@@ -35,17 +37,19 @@ Restart DSH, then open **Skills · MCP · Experts** from the global panel list a
|
|
|
35
37
|
| Installing skills means hunting for `SKILL.md` repos everywhere | **Direct markets**: SkillHub (190k+ skills), WorkBuddy connectors (316), 47 first-party-verified built-ins — search, filter, one-click install with per-file sha256 verification |
|
|
36
38
|
| Every MCP's tool descriptions eat context tokens; zero-use tools sit there for free | **Tool Tailor**: scans local session logs for call counts / errors / latency per tool; prune dead tools with one click |
|
|
37
39
|
| Migrating from Claude/Cursor/VS Code means copying config by hand | Detects local configs from 11 clients and imports them with a preview |
|
|
40
|
+
| OAuth-protected or key-protected servers and plugins just fail, with no hint why | **Plugin market** (CodeBuddy official + DSH community list) with per-entry source and installability, **one-click MCP OAuth** (RFC 9728 → 8414 → 7591 → 7636 PKCE → 8707, three callback fallbacks ending at "paste a token"), and a **credential prompt before install** that fills `${...}` placeholders so the server works on first connect |
|
|
38
41
|
|
|
39
42
|
|
|
40
|
-
A **unified board** for DeepSeek Harness (Web and desktop):
|
|
43
|
+
A **unified board** for DeepSeek Harness (Web and desktop): six top-level tabs — **MCP**, **Skill**, **Expert**, **Plugins**, **Tool Tailor** and **Market** — reachable from the global panel list at the top of the sidebar or the floating button. The panel floats above the session — **it never interrupts an ongoing conversation**.
|
|
41
44
|
|
|
42
45
|
| Tab | Manages |
|
|
43
46
|
|---|---|
|
|
44
47
|
| **MCP** | MCP servers: connection status, tool list, enable/disable, import, market install |
|
|
45
|
-
| **Skill** | Skills: enable/disable of `SKILL.md` folders, source directories, session audit scan |
|
|
48
|
+
| **Skill** | Skills: enable/disable of `SKILL.md` folders, source directories, session audit scan, six categories (dev / design & creative / docs & office / productivity / ops / uncategorised), grid or list view |
|
|
46
49
|
| **Expert** | WorkBuddy expert directory (429 experts / 15 industries, including 53 expert **teams**): browse, search, and "summon" one into a new session as an independent agent preset. Team experts summon with their full roster — member personas are materialised locally and the lead is handed a runtime bridge that maps WorkBuddy's `TeamCreate` / `Agent(name=…)` onto DSH's `subagent` |
|
|
50
|
+
| **Plugins** | Three sources in one searchable directory: DSH built-ins (shipped with the host, display-only), CodeBuddy official (ZIP distribution, manifest parsed from `marketplace.json` / `plugin.json`), DSH community (`awesome-dsh-plugins`). Install goes through the host's `pluginManager.installBundle` and **needs a host restart to take effect** |
|
|
47
51
|
| **Tool Tailor** | Per-tool call counts, error rates, latency and context-token cost from local session logs; dead tools shown inline and prunable. Shows **all tools with their real call counts by default** — toggle "zero-call only (N)" to narrow. A persistent "showing N/M tools" read-out makes the filter state unmistakable |
|
|
48
|
-
| **Market** |
|
|
52
|
+
| **Market** | Four sub-tabs: WorkBuddy connectors (316), curated built-ins (47), SkillHub skills (190k+), WorkBuddy experts (429) — browse, search, one-click install. Installed state is remembered across pages in `~/.dsh/armory/install-state.json` |
|
|
49
53
|
|
|
50
54
|
> The Skill panel reads DSH's custom skill directory (default `~/.agents/skills`): each skill is a folder with a `SKILL.md`,
|
|
51
55
|
> whose frontmatter declares the invocation policy (`disable-model-invocation` / `user-invocable`).
|
|
@@ -53,7 +57,7 @@ A **unified board** for DeepSeek Harness (Web and desktop): five top-level tabs
|
|
|
53
57
|
|
|
54
58
|
## Screenshots
|
|
55
59
|
|
|
56
|
-
One shot per board tab (captured on the Windows desktop app, light theme):
|
|
60
|
+
One shot per main board tab (captured on the Windows desktop app, light theme). The v2.0.0 Plugins page and the OAuth flow are not pictured yet:
|
|
57
61
|
|
|
58
62
|
### MCP — global board
|
|
59
63
|
|
|
@@ -116,6 +120,20 @@ Experts (WorkBuddy expert center, 429 experts / 15 industries; category filter +
|
|
|
116
120
|
- **Most-recently-used first**: entries you just used float to the top with a "recent" badge, the rest keep their original order; the recency pool holds 40 entries
|
|
117
121
|
- **Experts attach / swap / detach at any time**: click one to take over the current session (including one already mid-conversation), click another to replace it, click the chip's × to drop it. It affects **only that session** — no tool catalog changes, nothing persisted
|
|
118
122
|
- **What you see is what's active**: once an expert is attached, the session header shows the expert that is **actually in effect** (the host's "preset chosen at session creation" label steps aside while an expert is attached); the header badge, the composer chip and the menu stay in sync within the same frame after attach / swap / detach
|
|
123
|
+
- **Plugins page (new in 2.0.0)**: three sources in one searchable, filterable directory, each entry labelled with its source and whether it can be installed —
|
|
124
|
+
- **Built-in**: components shipped with the DSH host; display-only
|
|
125
|
+
- **CodeBuddy official**: ZIP distribution, manifests parsed from `.codebuddy-plugin/marketplace.json` and `external_plugins/<name>/.codebuddy-plugin/plugin.json` (`MAX_ZIP_BYTES = 64MB`, ETag-cached)
|
|
126
|
+
- **DSH community**: the `awesome-dsh-plugins` list (a single bare-JSON file, 5000+ entries, jsdelivr primary → fastly fallback), slimmed to `SLIM_MAX_BYTES = 3MB` before caching
|
|
127
|
+
- **Installing** goes through the host's `pluginManager.installBundle`, and **the host must be restarted** for the plugin to load. A missing `pluginManager`, an entry with no npm package name, and an entry with no repository each produce a specific message instead of failing silently
|
|
128
|
+
- **One-click MCP OAuth (new in 2.0.0)**: `@deepseek-ai/dsh-mcp-client` accepts only `requestInit.headers` and has no authProvider, so OAuth has to complete **before** install — this plugin runs the flow and writes `authorization: Bearer <token>` into the patch headers, then refreshes it —
|
|
129
|
+
- Implements **RFC 9728** (protected-resource metadata) → **RFC 8414** (authorization-server metadata) → **RFC 7591** (dynamic client registration) → **RFC 7636 PKCE** (S256 only) → **RFC 8707** (`resource` parameter)
|
|
130
|
+
- **Three callback fallbacks**: a local `127.0.0.1:<random port>/callback` captures the code automatically (the common case) → when the registered client only accepts a custom scheme, it asks for the full callback URL to be pasted → otherwise it falls back to "paste an access token"
|
|
131
|
+
- A 401 from the server pops the "Authorize {server}" dialog and opens the browser for you
|
|
132
|
+
- **Credential prompts before install (new in 2.0.0)**: scans `mcpServers` for `${...}` placeholders, opens "Fill in {connector}'s credentials", and installs with them; leaving a field blank means "I'll do it later" and the panel says "N placeholders left as-is". Connectors with no placeholders install straight through
|
|
133
|
+
- **Install-state ledger (new in 2.0.0)**: `~/.dsh/armory/install-state.json` (`INSTALL_STATE_VERSION = 1`) records each connector / skill / plugin's `source {marketplace, url, kind}`, so the market can filter installed entries **across pages** and distinguishes "installed by this plugin" from "configured by hand elsewhere". A failed registration never blocks the install
|
|
134
|
+
- **Skill board categories (new in 2.0.0)**: skills are grouped into **dev / design & creation / docs & office / productivity & automation / ops / uncategorised**, with grid and list views, a category filter and a persistent "showing N/M skills" line. Details add the category, source directory and file location, with one-click "copy SKILL.md path" and "copy @session reference". The board stays read-only (the host only exposes `listSkills` / `skillDetail`)
|
|
135
|
+
- **Marketplace registry (new in 2.0.0)**: market sources are no longer hard-coded — they live in `~/.dsh/armory/marketplaces.json` (`REGISTRY_VERSION = 1`), fall back to the built-in defaults when missing or corrupt, persist on first read, and can be added / edited / removed. Writes are staging + atomic rename
|
|
136
|
+
- **Instant community list (new in 2.0.0)**: the last successful slim list is mirrored into `localStorage` (7 days / 4MB cap, older snapshot kept on quota errors), so the first paint renders the snapshot before the real result silently replaces it (zero network, zero IPC). When no source is reachable it says "showing the local cache" instead of an empty list
|
|
119
137
|
- Import MCP configuration from other clients installed on this machine: Claude Code, Codex (`config.toml`), OpenCode (including `.jsonc`), pi, Claude Desktop, Cursor, Windsurf, VS Code, Gemini CLI, Roo Code, and the cross-tool shared config. Everything is previewed before writing, with "merge" and "replace" modes
|
|
120
138
|
- Follows the DSH dark/light theme and adapts to narrow/mobile widths
|
|
121
139
|
- Update notice and **one-click upgrade**: a dismissible banner when a newer release exists (at most one check per day; disable with `DSH_ARMORY_DISABLE_UPDATE_CHECK`). Nothing happens until you press Upgrade — it **never auto-updates**; the upgrade itself is executed by DSH's official pluginManager, which handles version checks and rollback. Local dev copies installed via `link:` / `file:` / `github:` get no button (it would replace your link with an npm package)
|
|
@@ -199,7 +217,7 @@ No. Uninstalling the plugin leaves existing `@deepseek-ai/dsh-mcp-client` rows i
|
|
|
199
217
|
|
|
200
218
|
| Item | Verified |
|
|
201
219
|
|---|---|
|
|
202
|
-
| DeepSeek Harness | `0.1.5-rc.1` and above (verified through `0.2.
|
|
220
|
+
| DeepSeek Harness | `0.1.5-rc.1` and above (verified through `0.2.1-rc.2`, DSH 44.7.0) |
|
|
203
221
|
| Node.js | whatever runtime DSH ships/supports |
|
|
204
222
|
| Platform | Windows; Linux/macOS share the same DSH Web contracts |
|
|
205
223
|
|
package/README.md
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
中文 | [English](README.en.md)
|
|
4
4
|
|
|
5
|
-
> **DeepSeek Harness 的一体化管理看板**:MCP 服务器 · 技能 · 专家 · 工具使用账本 ·
|
|
5
|
+
> **DeepSeek Harness 的一体化管理看板**:MCP 服务器 · 技能 · 专家 · 插件 · 工具使用账本 · 四大市场(WorkBuddy 连接器 316 个 / 内置精选 47 个 / SkillHub 技能 19 万+ / WorkBuddy 专家 429 位 / CodeBuddy 官方插件 + DSH 社区清单),浏览、搜索、一键安装、一键召唤。
|
|
6
6
|
> 本项目 fork 并改写自 [Imzl-zl/dsh-mcp-manager-ui](https://github.com/Imzl-zl/dsh-mcp-manager-ui)(MIT,原项目仅 MCP 管理),现仓库为 [2026heshao/dsh-armory](https://github.com/2026heshao/dsh-armory),
|
|
7
|
-
> 从纯 MCP 管理面板升级为 **MCP + 技能 + 专家 + 市场 + 工具账本**
|
|
7
|
+
> 从纯 MCP 管理面板升级为 **MCP + 技能 + 专家 + 插件 + 市场 + 工具账本** 看板。
|
|
8
|
+
>
|
|
9
|
+
> **v2.0.0 起**:新增「插件」页(三个来源)、MCP OAuth 一键授权、安装凭据引导、安装状态账本、技能看板六大分类。详见 [更新日志](.github/release-notes/v2.0.0.md)。
|
|
8
10
|
|
|
9
11
|
<p align="center">
|
|
10
12
|
<a href="https://github.com/2026heshao/dsh-armory/stargazers"><img src="https://img.shields.io/github/stars/2026heshao/dsh-armory?style=social" alt="stars"></a>
|
|
@@ -35,10 +37,13 @@ dsh plugin --profile web add github:2026heshao/dsh-armory
|
|
|
35
37
|
| 装技能要去各处翻 `SKILL.md` 仓库 | **市场直连**:SkillHub 19 万+ 技能、WorkBuddy 316 个连接器、47 个一手核实的内置服务,搜索/筛选/一键安装,逐文件 sha256 校验 |
|
|
36
38
|
| 每个 MCP 的工具描述都在吃上下文 token,零调用的工具白白占位 | **工具裁缝**:扫描本机会话日志统计每个工具的调用/错误/延迟,死工具清单一键禁用 |
|
|
37
39
|
| 从 Claude/Cursor/VS Code 迁移要逐条抄配置 | 自动检出 11 种客户端的本机配置,预览后一键导入 |
|
|
40
|
+
| 插件散在宿主自带、CodeBuddy 官方、社区清单三处,格式与可装性各不相同 | **插件页**:三个来源并进一个可搜索目录并标明可装性,安装统一走宿主 `pluginManager`,装完明确提示需重启 |
|
|
41
|
+
| 远端 MCP 要 OAuth,而官方 client 只认静态 headers,只能手搓 token | **一键授权**:按 RFC 9728/8414/7591/7636/8707 走完发现 → 注册 → PKCE → 回调 → 换 token,回调通道三级降级(loopback / 粘贴回调地址 / 手贴 token),到期自动刷新 |
|
|
42
|
+
| 装过的连接器、插件,过几天忘了是从哪来的 | **安装状态账本**:`~/.dsh/armory/install-state.json` 记录每条的来源市场,市场页可跨页筛出已安装项,并把「本插件装的」与「别处手动配的」分开 |
|
|
38
43
|
|
|
39
44
|
## 界面预览
|
|
40
45
|
|
|
41
|
-
|
|
46
|
+
看板五个主要分类页各一张(Windows 桌面端实拍,浅色主题)。2.0.0 新增的「插件」页与 OAuth 授权流程暂无截图:
|
|
42
47
|
|
|
43
48
|
### MCP — 全局管理面板
|
|
44
49
|
|
|
@@ -106,6 +111,20 @@ dsh plugin --profile web add github:2026heshao/dsh-armory
|
|
|
106
111
|
- **最近使用置顶**:刚用过的条目自动排到最前并带「最近」标记,其余保持原有顺序;最近使用记录池 40 条
|
|
107
112
|
- **专家随时挂 / 换 / 摘**:点一个专家即接管当前会话(包括已经聊了一半的),再点别的直接替换,点 chip 的 × 立刻取消;**只影响这一个会话**,不动工具目录、不写任何持久化配置
|
|
108
113
|
- **显示与实效一致**:挂专家后,会话标题栏显示的始终是**当前真正生效**的专家(宿主那条「会话创建时的预设」标签在挂载期间自动让位);三处显示(标题栏徽章、输入框 chip、菜单)挂/换/摘后同帧同步
|
|
114
|
+
- **插件市场(2.0.0 新增)**:新增「插件」页,把三类来源并进一个可搜索、可筛选的目录,每条标明来源与可装性——
|
|
115
|
+
- **官方**:DSH 宿主自带组件,随宿主分发,不可单独安装(仅展示)
|
|
116
|
+
- **CodeBuddy 官方**:ZIP 分发,解析 `.codebuddy-plugin/marketplace.json` 与 `external_plugins/<name>/.codebuddy-plugin/plugin.json`(`MAX_ZIP_BYTES = 64MB`,ETag 命中不重下)
|
|
117
|
+
- **DSH 社区**:`awesome-dsh-plugins` 社区清单(单文件裸 JSON、五千余条,jsdelivr 主源 → fastly 备源),拉取后瘦身到 `SLIM_MAX_BYTES = 3MB` 再落缓存
|
|
118
|
+
- **安装**:统一走宿主 `pluginManager.installBundle` 拉取落盘,**装完需重启 DSH 宿主才生效**;宿主无 `pluginManager` / 条目无 npm 包名 / 无 repository 三种情况各自给出明确文案,不静默失败
|
|
119
|
+
- **MCP OAuth 一键授权(2.0.0 新增)**:官方 `@deepseek-ai/dsh-mcp-client` 的 transport 只认 `requestInit.headers`、没有 authProvider,所以 OAuth 必须在**安装前**由本插件跑完,把 `authorization: Bearer <token>` 静态写进 patch 的 headers,此后由本模块负责刷新——
|
|
120
|
+
- 按 **RFC 9728**(受保护资源元数据)→ **RFC 8414**(授权服务器元数据)→ **RFC 7591**(动态客户端注册)→ **RFC 7636 PKCE**(只发 S256)→ **RFC 8707**(`resource` 参数)逐条实现
|
|
121
|
+
- 回调通道**三级降级**:本地 `127.0.0.1:<随机端口>/callback` 自动收 code(主流实现)→ DCR 只接受自定义 scheme 时提示「粘贴完整回调地址」→ 都不通则直接「粘贴访问令牌」
|
|
122
|
+
- 服务器返回 401 时自动弹出「授权 {服务器}」对话框并打开浏览器,无需用户先去找入口
|
|
123
|
+
- **安装凭据引导(2.0.0 新增)**:安装前扫 `mcpServers` 里的 `${...}` 占位符,弹「填写 {连接器} 的凭据」逐槽填写后「带凭据安装」;留空表示稍后自行补全,装完提示「另有 N 个占位符未填写,配置里保留原样」;无占位符的连接器直接放行
|
|
124
|
+
- **安装状态账本(2.0.0 新增)**:`~/.dsh/armory/install-state.json`(`INSTALL_STATE_VERSION = 1`)记录每条连接器 / 技能 / 插件的 `source {marketplace, url, kind}`,市场页据此支持**跨页**筛出已安装项(技能侧仍只筛当前页,两处文案分别说明),并把「本插件装的」与「别处手动配的」分开;登记失败不影响安装,只补一句提示
|
|
125
|
+
- **技能看板分类(2.0.0 新增)**:技能按 **编程开发 / 设计与创作 / 文档办公 / 效率与自动化 / 开发运维 / 未分类** 六类归置,网格与列表双视图可切、可按分类过滤,常驻「显示 N / M 个 skill」;详情新增所属分类、来源目录、文件位置,可一键「复制 SKILL.md 路径」「复制 @会话引用」。看板仍是只读的(宿主只提供 `listSkills` / `skillDetail`)
|
|
126
|
+
- **多市场注册表(2.0.0 新增)**:市场源不再硬编码,落 `~/.dsh/armory/marketplaces.json`(`REGISTRY_VERSION = 1`),缺失或损坏回落内置默认项、首读即落盘,支持增删改;写盘一律 staging + rename 原子替换
|
|
127
|
+
- **社区目录秒开(2.0.0 新增)**:上次成功的精简清单镜像进浏览器 `localStorage`(7 天 / 4MB 上限,超配额保留旧快照),首屏先渲染快照再被真实结果静默替换(0 网络 0 IPC);源不可达时明说「显示的是本地缓存」而非给空列表
|
|
109
128
|
|
|
110
129
|
## 安装
|
|
111
130
|
|
|
@@ -213,7 +232,7 @@ dsh plugin --profile web remove dsh-armory
|
|
|
213
232
|
|
|
214
233
|
| 项目 | 已验证 |
|
|
215
234
|
|---|---|
|
|
216
|
-
| DeepSeek Harness | `0.1.5-rc.1` 及以上(已验证至 `0.2.
|
|
235
|
+
| DeepSeek Harness | `0.1.5-rc.1` 及以上(已验证至 `0.2.1-rc.2`,DSH 44.7.0) |
|
|
217
236
|
| Node.js | DSH 自带/支持的运行时 |
|
|
218
237
|
| 平台 | Windows;Linux/macOS 使用同一 DSH Web 契约 |
|
|
219
238
|
|
|
@@ -222,27 +241,31 @@ dsh plugin --profile web remove dsh-armory
|
|
|
222
241
|
## 开发
|
|
223
242
|
|
|
224
243
|
```sh
|
|
225
|
-
npm install --no-save yaml@2
|
|
226
|
-
npm test # 全
|
|
244
|
+
npm install --no-save yaml@2 eslint@9 # 测试与风格检查的唯一裸依赖(运行依赖全部由宿主提供)
|
|
245
|
+
npm test # 全 31 套件(与 CI / 发版门槛同一条)
|
|
246
|
+
npm run lint # ESLint:no-undef / no-unused-vars / no-empty 等
|
|
227
247
|
npm run test:e2e # 可选:用宿主补丁算法验证专家预设进入组合树
|
|
228
248
|
dsh --profile web --dump-config # 看 profile 的有效配置
|
|
229
249
|
dsh web # 起 Web 做真实操作验证
|
|
230
250
|
```
|
|
231
251
|
|
|
232
|
-
`npm test` 跑的是 `test/run-all.mjs`,依次执行
|
|
252
|
+
`npm test` 跑的是 `test/run-all.mjs`,依次执行 **31** 个独立套件(各自是独立 node 进程,任一非零退出即整体失败)。套件是显式白名单而非 `node --test` 自动发现——`test/` 下还有带副作用的迁移脚本与需要宿主模块的端到端脚本,自动发现会误跑。
|
|
233
253
|
|
|
234
|
-
| 套件 | 覆盖 |
|
|
235
|
-
| --- | --- |
|
|
236
|
-
|
|
|
237
|
-
| `
|
|
238
|
-
| `
|
|
239
|
-
| `team-bridge
|
|
240
|
-
| `verify-install-info
|
|
241
|
-
| `
|
|
254
|
+
| 分组 | 套件 | 覆盖 |
|
|
255
|
+
| --- | --- | --- |
|
|
256
|
+
| 账本与配置层 | `tool-ledger` `mcp-config` | 工具账本的 zstd/缓存/尺寸闸门;MCP 配置归一化与 patch 往返 |
|
|
257
|
+
| 契约与入口 | `contract-consistency` `client-entry` `host-import-sources` | 面板可调方法必须在 typert 声明 / client 契约数组 / 宿主方法集三处同时存在;三个入口「全关」不可表达;VS Code `${input:}` 降级 |
|
|
258
|
+
| 客户端质量 | `client-i18n` `i18n-coverage` `error-boundary-copy` `persona-truncate` `tailor-window` | 中文皆包 `t()`;en-US 字典双向差集;崩溃详情可复制;persona 4000 预览阈值;工具裁缝时间窗四档 |
|
|
259
|
+
| 专家与团队 | `run-expert-manual` `session-persona` `team-bridge` `expert-card-actions` `recent-sort-regression` | 专家市场写链(真网目录 + 人设 + patch 往返,33 条断言);会话级挂/换/摘;团队桥接;卡片按钮真值表;「最近使用」排序 |
|
|
260
|
+
| 升级与安装真机 | `upgrade-plan` `verify-install-info` `verify-host-installinfo` `teardown-residue` | 一键升级判据(本地安装 / 来源未知一律不写);真 profile 数据下的来源判定(profile 不存在自行 SKIP);teardown 残留诊断 |
|
|
261
|
+
| 市场与安装链(2.0.0) | `wb-registry` `wb-install-state` `wb-auth-rules` `wb-connector-meta` `wb-plugin-market` `wb-awesome-market` `wb-install-flow` `wb-update-hint` `auth-gate-regression` `install-toast` | 多市场注册表原子写;安装状态与来源锁;令牌槽扫描与注入规则;连接器 R1/R2/R3 投影;插件清单嵌套解析;awesome 过滤/瘦身/304/双源切换;令牌替换写链;R9 更新探测五枚举;装前 401 鉴权闸;顶部安装浮层跨卸载可见 |
|
|
262
|
+
| 回归护栏 | `readme-images` `bugfix-armory` | README 配图不回退(引用可解析 + `screenshots.json` 与 README 一致 + 旧图名不得复活);市场入口强制选中与看板页重复让位 |
|
|
242
263
|
|
|
243
264
|
本机存在 `~/.dsh/profiles/desktop/cordis.patch.yml` 时,`run-expert-manual.mjs` 会追加一组真实 profile 往返断言,没有则记 `SKIP`。
|
|
244
265
|
|
|
245
|
-
|
|
266
|
+
> `auth-gate-regression.mjs` 的被测对象是**运行时副本**(`~/.dsh/profiles/desktop/node_modules/dsh-armory/lib/`),开工前做 SHA256 哈希预检,不符即中止。
|
|
267
|
+
|
|
268
|
+
CI(`.github/workflows/ci.yml`)与发版门槛(`.github/workflows/release.yml`)跑同两条命令:`npm run lint` + `npm test`,两处口径不许分叉。`release.yml` 另外校验 tag / `package.json` / `lib/whats-new.js` / `.github/release-notes/v<version>.md` 四者版本一致,才 `npm publish` 并建 GitHub Release;发版流程见 [安装与升级](docs/installation.md#发布流程维护者)。
|
|
246
269
|
|
|
247
270
|
> 本机若没有 `yaml`,`npm test` 会以 `ERR_MODULE_NOT_FOUND` 退出——`test/loader.mjs` 会先试标准解析,失败才回退开发机的 profile 副本(`~/.dsh/profiles/desktop/node_modules`),所以干净环境必须先装依赖。
|
|
248
271
|
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# 6.5 客户端拆包(chunk)可行性核实报告
|
|
2
|
+
|
|
3
|
+
> 结论先行:**技术上可行,已由上游源码验证;但按当前体积测算,收益应重新定义为"降低并行开发冲突面",而不是"减小首屏体积"。**
|
|
4
|
+
> 核实人:主理人 Qi | 时间:2026-10-19 | 对应清单项:§6.5(OPT-23)
|
|
5
|
+
|
|
6
|
+
## 1. 上游协议核实(源码级,非推测)
|
|
7
|
+
|
|
8
|
+
核实对象:`@deepseek-ai/dsh-client-modules` 的浏览器半侧实现
|
|
9
|
+
路径:`D:\桌面\AI Port\DSH plugins\_research\asar-extract\out\dsh\node_modules\@deepseek-ai\dsh-client-modules\lib\client.js`(40247 B)
|
|
10
|
+
|
|
11
|
+
### 1.1 chunk 文件名的硬约束
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
const CLIENT_CHUNK = /^client\.[A-Za-z0-9][A-Za-z0-9._-]*\.js$/;
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
注册时校验(源码原文):
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
if (registration.chunk !== void 0 && !CLIENT_CHUNK.test(registration.chunk))
|
|
21
|
+
throw new Error(`client-modules: invalid package-local chunk ${JSON.stringify(registration.chunk)}`);
|
|
22
|
+
const id = registration.chunk === void 0 ? ownerId : chunkId(ownerId, registration.chunk);
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**含义**:文件名必须形如 `client.<name>.js`,`<name>` 首字符须为字母或数字,其后可含 `[A-Za-z0-9._-]`。
|
|
26
|
+
→ 本插件可用:`client.tailor.js`、`client.expert.js`、`client.market.js`、`client.skill-board.js`、`client.mcp-manager.js`、`client.whats-new.js`、`client.i18n-en.js`、`client.styles.js`。
|
|
27
|
+
|
|
28
|
+
### 1.2 chunk URL 的派生规则
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
function chunkUrl(row, fileName, rev) {
|
|
32
|
+
const url = atRevision(row.url, rev);
|
|
33
|
+
const resourceStart = url.indexOf("/??");
|
|
34
|
+
const revisionStart = url.indexOf("&rev=", resourceStart + 3);
|
|
35
|
+
if ((resourceStart < 0 || revisionStart < 0 ? void 0
|
|
36
|
+
: url.slice(resourceStart + 3, revisionStart)) !== `${row.id}/client.js`)
|
|
37
|
+
throw new Error(`client-modules: cannot resolve chunk ${JSON.stringify(fileName)} from bundle URL ${url}`);
|
|
38
|
+
return `${url.slice(0, resourceStart)}/${row.id}/${fileName}?${url.slice(revisionStart + 1)}`;
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**含义**:chunk 从 owner bundle 的 URL 派生,路径为 `<base>/<pluginId>/<chunkFile>?rev=<rev>`。
|
|
43
|
+
chunk 必须与 owner **同目录、同 revision**,没有独立的版本协商。这是"同一次发布"的硬约束。
|
|
44
|
+
|
|
45
|
+
### 1.3 加载与注册流程
|
|
46
|
+
|
|
47
|
+
```js
|
|
48
|
+
async importChunk(ownerId, fileName) {
|
|
49
|
+
const id = chunkId(ownerId, fileName);
|
|
50
|
+
const existing = this.loadCache.get(id);
|
|
51
|
+
if (existing !== void 0) return existing.exports;
|
|
52
|
+
if (!this.factories.has(id)) {
|
|
53
|
+
const generation = this.generations.get(ownerId) ?? 0;
|
|
54
|
+
const row = this.graphRows.get(ownerId);
|
|
55
|
+
if (row === void 0) throw new Error(`client-modules: chunk owner "${ownerId}" is not a boot graph entry`);
|
|
56
|
+
const url = chunkUrl(row, fileName, ...);
|
|
57
|
+
let transport = this.pendingArrival.get(url);
|
|
58
|
+
if (transport === void 0) {
|
|
59
|
+
transport = this.loadBundle(url).finally(() => { this.pendingArrival.delete(url); });
|
|
60
|
+
this.pendingArrival.set(url, transport);
|
|
61
|
+
}
|
|
62
|
+
await transport;
|
|
63
|
+
// generation 变了 = 期间发生过 HMR,丢弃这次结果重来
|
|
64
|
+
if ((this.generations.get(ownerId) ?? 0) !== generation) {
|
|
65
|
+
this.factories.delete(id);
|
|
66
|
+
this.loadCache.delete(id);
|
|
67
|
+
return await this.importChunk(ownerId, fileName);
|
|
68
|
+
}
|
|
69
|
+
if (!this.factories.has(id))
|
|
70
|
+
throw new Error(`client-modules: bundle ${url} loaded without registering "${id}" via __ModuleLoader__.load`);
|
|
71
|
+
}
|
|
72
|
+
return this.materialize(id, ownerId).exports;
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**要点**:
|
|
77
|
+
- 同一个 chunk 的并发请求共享一次传输(`pendingArrival`)。
|
|
78
|
+
- HMR 期间(generation 变化)会丢弃结果并自动重试——**对开发友好**。
|
|
79
|
+
- chunk 必须自己调用 `window.__ModuleLoader__.load({ id: '<ownerId>', chunk: '<fileName>', factory })` 完成注册,否则抛错。
|
|
80
|
+
|
|
81
|
+
### 1.4 是否能在 chunk 里 require 别的相对文件
|
|
82
|
+
|
|
83
|
+
**不能。** 上游 README(中文版)原文:
|
|
84
|
+
|
|
85
|
+
> 经 tsdown 拆分的源码 `import()` 会编译为 `require.async("./client.<name>.js")`;只有执行该表达式时,对应的带版本同级脚本才会到达。
|
|
86
|
+
|
|
87
|
+
且 chunk 只能从 owner 派生 URL,`require.async` 的目标必须是**同级 chunk**,不能是任意相对路径的 ESM 模块。
|
|
88
|
+
→ 这印证了 `lib/client.js:43` 原注释的判断"不能相对 import"对 ES import 成立;但 chunk 通道本身是可行的。
|
|
89
|
+
|
|
90
|
+
### 1.5 同级 chunk 之间能否互相 require
|
|
91
|
+
|
|
92
|
+
从 `importChunk(ownerId, fileName)` 的实现看,`require.async` 由 owner 的 factory 闭包提供(ownerId 固定),因此 **chunk 之间可以互相 `require.async`**,只要都注册在同一 ownerId 下。
|
|
93
|
+
但为避免"chunk 依赖 chunk"的加载顺序地狱,建议仍遵循架构师的不变式:**共享代码只由基座注入**(通过 `mount(surface)` 的参数传递)。
|
|
94
|
+
|
|
95
|
+
## 2. 本插件当前体积实测
|
|
96
|
+
|
|
97
|
+
文件:`lib/client.js`,**506177 B / 7229 行**
|
|
98
|
+
|
|
99
|
+
| 分区 | 字节 | 占比 |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| `EN_US_MESSAGES` 字典(行 58 起) | 34901 | 6.9% |
|
|
102
|
+
| `MCP_CSS` 样式表 | 94956 | 18.8% |
|
|
103
|
+
| 服务器详情 | 12968 | 2.6% |
|
|
104
|
+
| 新增/编辑弹窗 | 14686 | 2.9% |
|
|
105
|
+
| JSON 导入弹窗 | 11465 | 2.3% |
|
|
106
|
+
| MCP 主面板(McpTab) | 27304 | 5.4% |
|
|
107
|
+
| 工具裁缝(Tool Tailor) | 18818 | 3.7% |
|
|
108
|
+
| Skill 看板 | 11510 | 2.3% |
|
|
109
|
+
| 专家 | 33316 | 6.6% |
|
|
110
|
+
| 输入框召唤菜单 | 22112 | 4.4% |
|
|
111
|
+
| 市场面板 | 32073 | 6.3% |
|
|
112
|
+
| 命令面板(Ctrl+K) | 18193 | 3.6% |
|
|
113
|
+
| RPC 契约表 | 14845 | 2.9% |
|
|
114
|
+
|
|
115
|
+
**七个面板合计约 163326 B(32.3%)**;CSS + 字典合计 129857 B(25.7%)。
|
|
116
|
+
|
|
117
|
+
### 2.1 「100KB 基座」目标的重算
|
|
118
|
+
|
|
119
|
+
若拆出七个面板 chunk(163326 B)+ CSS(94956 B)+ 字典(34901 B),基座剩余约 **213KB**(含契约表、i18n 骨架、共享原子件、入口装配)。距离「100KB」仍差一倍。
|
|
120
|
+
|
|
121
|
+
**这不是拆分方式的问题,而是本插件确实有这么多必要的装配与契约代码。** 强行压到 100KB 只能靠再拆契约表与共享原子件,收益递减且会破坏 chunk 的独立性。
|
|
122
|
+
|
|
123
|
+
### 2.2 拆分真正带来的收益
|
|
124
|
+
|
|
125
|
+
1. **首屏只加载基座**:面板 chunk 按需拉取。用户打开看板时通常只看一个 tab,其余六个面板的 163KB 不必传输与解析。
|
|
126
|
+
2. **CSS 随面板走**:面板私有样式跟着 chunk 加载,基座只留主题令牌与共用原子件。当前 94956 B 的 CSS 大部分是面板私有规则。
|
|
127
|
+
3. **并行开发冲突面**(这是本次实施的真正动机):拆开后,不同面板的改动落在不同文件,不再排队等同一个 500KB 文件的写锁。
|
|
128
|
+
|
|
129
|
+
### 2.3 拆分的主要风险
|
|
130
|
+
|
|
131
|
+
| 风险 | 说明 | 缓解 |
|
|
132
|
+
|---|---|---|
|
|
133
|
+
| chunk 顶层不能有副作用 | 上游明确"模块副作用在物化时运行";CSS 注入若放 chunk 顶层,注册时不会执行 | 每个 chunk 导出 `{ mount, css }`,由基座统一注入 css |
|
|
134
|
+
| 基座必须提供全部共享符号 | chunk 无法 require 别处,只能从 `mount(surface)` 拿 | 在 `docs/chunks.md` 里钉死 surface 契约 |
|
|
135
|
+
| 无 git,回退成本高 | 一次拆错要手工还原 | 每个面板搬迁前建 `_archive` 快照 + 跑一次 run-all |
|
|
136
|
+
| 测试锚点会失效 | `client-i18n.mjs` 词法扫描假定单文件 | 先把扫描器改成多文件聚合,再动拆分 |
|
|
137
|
+
| HMR 期间反复加载 | generation 变化会重试 | 上游已处理,无需额外逻辑 |
|
|
138
|
+
|
|
139
|
+
## 3. 建议的实施顺序(若决定动手)
|
|
140
|
+
|
|
141
|
+
1. **先扩测试**:`test/client-i18n.mjs` 改成多文件扫描(glob `lib/client*.js`),`test/client-chunks.mjs` 新建用于校验 chunk 协议(文件名正则、注册形状、顶层无副作用)。
|
|
142
|
+
2. **提取基座 surface**:把 `React2`/`h`/`t`/`call`/`injectCss`/UI 原子件/常量/hooks 收进一个显式的 surface 对象,主 bundle 先按"自己用自己"跑通(不改任何面板,只改装配方式)。
|
|
143
|
+
3. **先搬一个低风险面板验证通道**:建议 `client.whats-new.js`(约 5KB,依赖最少)。跑通后 run-all 全绿,再继续。
|
|
144
|
+
4. **逐面板搬迁**,每搬一个建快照 + 跑测试。
|
|
145
|
+
5. **最后搬 CSS**:面板私有样式随各自 chunk 走,基座只留令牌与共用件。
|
|
146
|
+
|
|
147
|
+
## 4. 结论与建议
|
|
148
|
+
|
|
149
|
+
- **可行性**:确认可行,协议已由上游源码验证,无未知风险。
|
|
150
|
+
- **是否本次做**:这是一次**纯结构性重构**,不改任何用户可见行为。当前 18 项功能优化已全部落地且测试全绿,此时动拆包会把已验证的稳定状态重新置于风险中。
|
|
151
|
+
- **建议**:作为**独立的 v1.11.0 版本**推进,不与本次 v1.10.0 混在一起发。本次发版在清单里把 6.5 标注为「已核实可行,排入 v1.11.0」。
|
|
152
|
+
|
|
153
|
+
若决定本次就做,最小可行路径是第 3 节的第 1–3 步(测试改造 + surface 提取 + 搬一个面板),可在不动其余面板的前提下验证通道,风险可控。
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# 上游提案:stdio MCP transport 的 teardown 上界
|
|
2
|
+
|
|
3
|
+
这份文档回答一个问题:**Windows 上「MCP 子进程回收卡住」这件事,根因在哪一层的哪一行,该由谁修、修成什么样。**
|
|
4
|
+
|
|
5
|
+
它是给 `@modelcontextprotocol/client`(下文简称上游)维护者的技术提案,不是本插件的使用说明。日常使用请看 [README](../README.md) 与 [项目 MCP 生命周期](project-mcp-lifecycle.md)。
|
|
6
|
+
|
|
7
|
+
- [问题陈述](#问题陈述)
|
|
8
|
+
- [上游现状(贴证据)](#上游现状贴证据)
|
|
9
|
+
- [可复现条件](#可复现条件)
|
|
10
|
+
- [建议方案](#建议方案按优先级)
|
|
11
|
+
- [对本项目的影响](#对本项目的影响)
|
|
12
|
+
- [临时缓解(本侧已做)](#临时缓解本侧已做)
|
|
13
|
+
|
|
14
|
+
## 问题陈述
|
|
15
|
+
|
|
16
|
+
Windows 上,stdio 传输的 MCP server 常常**自己再拉起子进程**:`npx` / `uvx` 这类包装器本身就是「一个 JS 进程去起真正的 server」,很多 server 内部还会再 spawn 语言运行时、helper、语言服务器。这些**孙进程默认继承父进程的标准句柄**,包括上游建的那两根管道(`stdin` / `stdout`)。
|
|
17
|
+
|
|
18
|
+
于是出现下面这条因果链:
|
|
19
|
+
|
|
20
|
+
1. 宿主决定回收某条连接,调 `transport.close()`。
|
|
21
|
+
2. `close()` 结束子进程的 `stdin` 并等 `'close'` 事件。
|
|
22
|
+
3. `'close'` 的语义不是「进程结束了」,而是「**进程结束了、且它的全部 stdio 流都关闭了**」。
|
|
23
|
+
4. 孙进程仍持有那两根管道的写端/读端,流就关不掉,`'close'` 永远不来。
|
|
24
|
+
5. 回收方一直在等。宿主侧看到的是「这条连接在释放中」,但没有上界、没有进度、没有错误——只是不动了。
|
|
25
|
+
|
|
26
|
+
后果不是「慢一点」:插件卸载、会话销毁、配置热更新全部挂在这一步后面。用户能观察到的现象是**面板停止响应、改配置不生效、退出宿主时进程残留**,而日志里一条错误都没有——因为根本没有错误,只有一个永远 pending 的 promise。
|
|
27
|
+
|
|
28
|
+
## 上游现状(贴证据)
|
|
29
|
+
|
|
30
|
+
证据版本:`@modelcontextprotocol/client@2.0.0`,`dist/stdio.mjs`。
|
|
31
|
+
|
|
32
|
+
### 1. `spawn` 没有建进程组
|
|
33
|
+
|
|
34
|
+
`dist/stdio.mjs:68-81`:
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
this._process = spawn(this._serverParams.command, this._serverParams.args ?? [], {
|
|
38
|
+
env: { ...getDefaultEnvironment(), ...this._serverParams.env },
|
|
39
|
+
stdio: ["pipe", "pipe", this._serverParams.stderr ?? "inherit"],
|
|
40
|
+
shell: false,
|
|
41
|
+
windowsHide: process.platform === "win32",
|
|
42
|
+
cwd: this._serverParams.cwd
|
|
43
|
+
});
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`detached` 未设置(即 `false`)。在 POSIX 上这意味着子进程与宿主同进程组,`process.kill(-pid, sig)` 会连同宿主一起打中,所以**想按进程组回收也没有条件**;在 Windows 上则意味着没有任何可用于 `taskkill /T` 的归属标记被记录下来。
|
|
47
|
+
|
|
48
|
+
### 2. `close()` 等的是 `'close'`,且三段式只管直接子进程
|
|
49
|
+
|
|
50
|
+
`dist/stdio.mjs:175-199`:
|
|
51
|
+
|
|
52
|
+
```js
|
|
53
|
+
async close() {
|
|
54
|
+
if (this._process) {
|
|
55
|
+
const processToClose = this._process;
|
|
56
|
+
this._process = void 0;
|
|
57
|
+
const closePromise = new Promise((resolve) => {
|
|
58
|
+
processToClose.once("close", () => { resolve(); });
|
|
59
|
+
});
|
|
60
|
+
try { processToClose.stdin?.end(); } catch {}
|
|
61
|
+
await Promise.race([closePromise, new Promise((resolve) => setTimeout(resolve, 2e3).unref())]);
|
|
62
|
+
if (processToClose.exitCode === null) {
|
|
63
|
+
try { processToClose.kill("SIGTERM"); } catch {}
|
|
64
|
+
await Promise.race([closePromise, new Promise((resolve) => setTimeout(resolve, 2e3).unref())]);
|
|
65
|
+
}
|
|
66
|
+
if (processToClose.exitCode === null) try {
|
|
67
|
+
processToClose.kill("SIGKILL");
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
this._readBuffer.clear();
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
两个具体问题:
|
|
75
|
+
|
|
76
|
+
- **等待条件选错了。** 三段都在等 `closePromise`,而它绑的是 `'close'`。孙进程持管道时,即使直接子进程已被 SIGKILL,`'close'` 依然可能不来。第三段之后**没有 await**,函数直接 `return` ——看起来是「兜底返回」,实际效果是把一个仍在 pending 的 `close` 事件留在身后,而 `_process` 已经被置为 `undefined`,后续没有任何路径再回收它。
|
|
77
|
+
- **SIGKILL 只覆盖一个 pid。** `proc.kill("SIGKILL")` 打的是直接子进程;孙进程不在信号可达范围内(POSIX 需负 pid 打进程组,Windows 需 `taskkill /T`)。最需要被杀掉的那批进程恰好一个都没被打中。
|
|
78
|
+
|
|
79
|
+
### 3. 上游已经写出了正确解法,却只给内部探针用
|
|
80
|
+
|
|
81
|
+
`dist/stdio.mjs:140-174`,原文注释:
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
/**
|
|
85
|
+
* Reap a disposable probe sibling (see the version-negotiation sibling
|
|
86
|
+
* flow): signal-first teardown awaiting process `exit` — never the `close`
|
|
87
|
+
* event, so a helper process holding the child's stdio pipes can never
|
|
88
|
+
* block disposal. Not part of the public transport lifecycle.
|
|
89
|
+
*
|
|
90
|
+
* @internal
|
|
91
|
+
*/
|
|
92
|
+
async _dispose() {
|
|
93
|
+
...
|
|
94
|
+
const exited = new Promise((resolve) => proc.once("exit", () => resolve()));
|
|
95
|
+
...
|
|
96
|
+
await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 1e3).unref())]);
|
|
97
|
+
if (proc.exitCode === null && proc.signalCode === null) try {
|
|
98
|
+
proc.kill("SIGKILL");
|
|
99
|
+
} catch {}
|
|
100
|
+
await exited;
|
|
101
|
+
...
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
注释原文那句是关键:
|
|
106
|
+
|
|
107
|
+
> **"awaiting process `exit` — never the `close` event, so a helper process holding the child's stdio pipes can never block disposal"**
|
|
108
|
+
|
|
109
|
+
也就是说,**上游作者完全知道**「孙进程持有管道会让 `close` 永远不触发」,并且针对探针兄弟进程已经改用 `'exit'`、还做了「不设上界的 `await exited` 收尾」。但常规生命周期路径 `close()` 走的仍是 `'close'`。这不是设计取舍,是两条路径没对齐——`_dispose()` 的注释里 "Not part of the public transport lifecycle" 也侧面承认了这一点。
|
|
110
|
+
|
|
111
|
+
## 可复现条件
|
|
112
|
+
|
|
113
|
+
同时满足以下条件即会触发(可据此判断某个用户的现场是否属于此类):
|
|
114
|
+
|
|
115
|
+
1. **`command` 是包装器**:`npx`、`uvx`、`bunx`、`pnpm dlx`、`docker exec`、`cmd /c`、`.bat`/`.cmd` shim(Windows 上 npm 全局安装的 CLI 全是这一种)。
|
|
116
|
+
2. **server 进程内部再 spawn 至少一个子进程**,且该子进程**继承 stdout/stderr**(Node 的 `spawn` 默认 `stdio: "inherit"`/`"pipe"` 时子侧持有句柄;Python 的 `subprocess.Popen` 默认同样继承)。
|
|
117
|
+
3. **该孙进程生命周期长于父进程**:语言服务器、watcher、索引器、常驻 helper,或干脆是忘记 `unref`/忘记退出的失控进程。
|
|
118
|
+
4. 父进程被回收(`close()` / `SIGTERM` / 崩溃)——此时孙进程成为孤儿但仍持有管道句柄。
|
|
119
|
+
|
|
120
|
+
反过来,以下情况**不会**触发,不必怀疑本例:server 是单进程(如纯 Node 的 `node dist/index.js< /dev/null`、无内部 spawn 的 Python 脚本)、或孙进程启动时显式 `stdio: "ignore"` / `detached: true` 并自己管理生命周期。
|
|
121
|
+
|
|
122
|
+
判定手法:回收卡住时对目标 pid 跑一次 `wmic process where "ParentProcessId=<pid>" get ProcessId,CommandLine`(或 PowerShell `Get-CimInstance Win32_Process -Filter "ParentProcessId=<pid>"`)。有存活子进程且它们的父已经不在 → 就是本例。POSIX 上用 `ps -o pid,ppid,pgid,stat -g <pgid>` 看进程组里的残留。
|
|
123
|
+
|
|
124
|
+
## 建议方案(按优先级)
|
|
125
|
+
|
|
126
|
+
### 方案 A(推荐,最小改动):`close()` 与 `_dispose()` 对齐
|
|
127
|
+
|
|
128
|
+
三处改动,全部在上游内部,不改变公开 API 形状:
|
|
129
|
+
|
|
130
|
+
1. **把等待条件从 `'close'` 换成 `'exit'`**(与 `_dispose()` 完全一致)。`'exit'` 的语义是「本进程已终止」,不依赖任何流被关闭,因此孙进程持管道不再是阻塞条件。
|
|
131
|
+
2. **把 SIGKILL 阶段扩展为「按进程组」。** 这需要 spawn 时加 `detached: true`:POSIX 下子进程自成一组,用 `process.kill(-child.pid, signal)` 打整组;Windows 下 `detached: true` 让子进程成为新进程组的根,配合 `taskkill /T /F /PID <pid>` 才能连带孙进程。
|
|
132
|
+
3. **给最后的 `await exited` 加上上界**(`_dispose()` 现在这行是无上界的)。一组进程全杀光也可能因权限/句柄原因一直不 exit,收尾必须能失败返回,并把结果(`timedOut: true`)交给调用方。
|
|
133
|
+
|
|
134
|
+
代价:`detached: true` 是**行为变更**——POSIX 下子进程默认不再与宿主共享进程组,宿主的 Ctrl-C / 终端信号不再自动传给 MCP server。对 MCP 这类由宿主全权管理的子进程,这通常是想要的(宿主本来就有 `close()`),但对「依赖宿主信号传播来退出」的现有集成是破坏性的。**因此建议至少把 (2) 放进一个 opt-in 选项**(见方案 B),而 (1)(3) 可以直接改:它们只把「无限等待」变成「有界等待」,不改变任何成功路径的行为。
|
|
135
|
+
|
|
136
|
+
### 方案 B(推荐与 A 并行):`detached` 做成 transport 选项
|
|
137
|
+
|
|
138
|
+
`StdioServerParameters` 加一个字段(名字供参考):
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
/** 让 MCP 子进程自成进程组,使 close() 能按组回收孙进程。默认 false(保持现状)。 */
|
|
142
|
+
detached?: boolean;
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
默认关闭即**零破坏性**:现有集成的行为一字不变,需要的宿主显式开启。开启后上游在 `close()` 中按平台选择 `process.kill(-pid, ...)` / `taskkill /T /F /PID`。
|
|
146
|
+
|
|
147
|
+
代价:多一个配置项要写文档、要写跨平台分支;Windows 的 `taskkill` 是外部进程,需处理「命令不存在」「拒绝访问」「进程已退出(退出码 128)」等噪音。相对收益(真正能杀掉进程树)这个代价是值得的。
|
|
148
|
+
|
|
149
|
+
### 方案 C(保底,成本最低):至少把 `child.pid` 暴露出来
|
|
150
|
+
|
|
151
|
+
即使前两条都不做,也建议把直接子进程的 pid 通过公开 API 给出,例如:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
get pid(): number | undefined;
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
理由:判定「是连接卡住还是宿主卡住」需要 pid(见上一节判定手法);有了 pid,宿主或插件可以自行做进程树回收、写诊断、在面板上显示「这条连接对应 pid 1234」。上游什么都不用改变,只是**把已有的内部事实变成可观察事实**。代价几乎为零(一个 getter + 生命周期上的 `undefined` 时机说明)。
|
|
158
|
+
|
|
159
|
+
> 三条方案的取舍一句话:**A 修正确性,B 保兼容,C 保可观测。** A 里 (1)(3) 无破坏性、可直接进;(2) 有破坏性、建议走 B 的开关。
|
|
160
|
+
|
|
161
|
+
## 对本项目的影响
|
|
162
|
+
|
|
163
|
+
`dsh-armory`(本插件)在 MCP 子进程这件事上是**纯观察者**:
|
|
164
|
+
|
|
165
|
+
- 它**从不 spawn 任何 MCP 子进程**。对 `lib/` 全目录 grep `SIGTERM | SIGKILL | taskkill | kill(` —— **零命中**。
|
|
166
|
+
- 子进程由宿主的 `@deepseek-ai/dsh-scope` 与 `@deepseek-ai/dsh-mcp-client` 拥有。插件侧只拿到 `scopeMod.createScope(ctx, scopeKey)` 返回的 `scoped` 句柄,回收时调 `scoped.dispose()`。
|
|
167
|
+
- 因此插件**没有 pid、没有 ChildProcess 对象、没有可 kill 的句柄**。在本插件内实现「taskkill 进程树」在技术上不可能——不是没做,是无从下手。
|
|
168
|
+
|
|
169
|
+
结论:本插件无法自己解决这个问题,只能等上游。上游采纳**任一条**(A 的 (1)(3)、B、C)本插件都直接受益:
|
|
170
|
+
|
|
171
|
+
- 采纳 A(1)(3):`scoped.dispose()` 从「可能永不返回」变成「有界返回」,本插件的 `retireSharedConnection` 天然跟上,无需改代码。
|
|
172
|
+
- 采纳 B:宿主可在建连时开启 `detached`,插件侧零改动。
|
|
173
|
+
- 采纳 C:本插件的诊断视图能从「已释放 12.3s 仍在等」升级为「pid 1234 已释放 12.3s 仍在等」,用户可直接去任务管理器处理。
|
|
174
|
+
|
|
175
|
+
## 临时缓解(本侧已做)
|
|
176
|
+
|
|
177
|
+
在上游改动落地前,本插件做了一件事:**把「卡住」从沉默变成可见事实**。这不是修复,是让人能做决定。
|
|
178
|
+
|
|
179
|
+
- `lib/workspace-runtime.js` 新增常量 `TEARDOWN_STUCK_MS = 5_000`,与既有的 `DEAD_CONNECTION_AGE_MS = 5_000` 对齐——同一份连接在同一条时间尺度上被判定「已死」与「回收卡住」,两个数字不同会让排障时对不上号。
|
|
180
|
+
- 每次回收在模块级记录表里留一条 `{ wsPath, serverName, startedAt, settledAt, elapsedMs, settled, stuck, error }`,按 app root 分桶、每 connKey 只留最近一条(`TEARDOWN_RECORD_MAX = 64` 兜底)。**回收永不返回时,记录里仍有它的出生时间**,于是「已经等了多久」永远算得出来。
|
|
181
|
+
- 面板行新增 `teardown` 字段(`null` 或上述读数的投影),于是界面能显示「这条连接已释放 12.3s 仍在等」而不是干等。超龄(`TEARDOWN_RECORD_STALE_MS = 600_000`)的旧结果不再送出——10 分钟前的回收结果对「现在卡不卡」没有解释力。
|
|
182
|
+
- 该字段是**纯增量**:沿用已注册的 `dsh-armory#mcpManager/projectConnections` 契约(其 result codec 透传任意结构),不改契约、不改 `lib/typert.js`、不改 `lib/index.js`。
|
|
183
|
+
|
|
184
|
+
**用户的正确处置**:面板上看到某条连接 `stuck: true` 且 `elapsedMs` 持续增长时,不要再等——重启宿主。残留的孙进程需要手工清理(Windows:任务管理器按父 pid 找;POSIX:`ps -g <pgid>` 后整组 `kill`)。这份文档的目的就是让这条手工路径最终能被上游的改动取代。
|