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.
@@ -0,0 +1,224 @@
1
+ // 多市场注册表(R4)—— ~/.dsh/armory/marketplaces.json 的读写层。
2
+ //
3
+ // 职责(设计文档 docs/system_design.md 8.3 / 类图 MarketRegistry):
4
+ // - 注册表文件不存在或损坏时,回落到**内置默认两条**:连接器源(与 lib/wb-market.js:27
5
+ // 的 CONNECTOR_ZIP_URL 保持同一现值,禁止在此发明第二处硬编码真相)与
6
+ // codebuddy-plugins-official 插件源;首读时把默认项落盘。
7
+ // - 合并投影:内置默认永远在场(文件内同 id 条目可覆盖其字段),用户自定义条目追加其后。
8
+ // - 增删改一律 staging + rename 原子替换(沿用 wbSkillInstall 的 staging 模式)。
9
+ //
10
+ // 纯数据层:不做任何网络请求,不依赖 Remote/UI。测试可通过 options.rootDir 或
11
+ // 环境变量 DSH_ARMORY_HOME 把存储根目录指到临时目录,绝不碰真实 ~/.dsh。
12
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
13
+ import { homedir } from "node:os";
14
+ import { join } from "node:path";
15
+
16
+ // 与 lib/wb-market.js:27 现值一致(改动连接器源时两处必须同步,注册表化接入后此值
17
+ // 仅作为「内置默认项」的数据来源,不再被 wb-market 直接消费)。
18
+ const CONNECTOR_ZIP_URL = "https://static.workbuddy.cn/connectors-config-v2/connectors-config.zip";
19
+
20
+ export const REGISTRY_VERSION = 1;
21
+
22
+ /** 存储根目录:显式 rootDir > 环境变量 > 真实 ~/.dsh/armory。 */
23
+ function armoryRoot(options = {}) {
24
+ return options.rootDir || process.env.DSH_ARMORY_HOME || join(homedir(), ".dsh", "armory");
25
+ }
26
+
27
+ /** 注册表文件绝对路径。 */
28
+ export function registryPath(options = {}) {
29
+ return join(armoryRoot(options), "marketplaces.json");
30
+ }
31
+
32
+ /**
33
+ * 内置默认市场条目(代码内声明,首读落盘)。顺序即 sourceFor 的优先序:
34
+ * 1. connectors → 现有 connectors-config.zip(WorkBuddy 连接器市场分发地址)
35
+ * 2. plugins → codebuddy-plugins-official(清单实测在 .codebuddy-plugin/marketplace.json)
36
+ */
37
+ export function builtinMarketplaces() {
38
+ return [
39
+ {
40
+ id: "workbuddy-connectors",
41
+ kind: "connectors",
42
+ manifestName: "workbuddy-connectors",
43
+ type: "zip",
44
+ source: { url: CONNECTOR_ZIP_URL, path: "" },
45
+ manifestPath: ".codebuddy-connector/connectors.json",
46
+ autoUpdate: false,
47
+ isBuiltIn: true,
48
+ description: "WorkBuddy 官方连接器市场(connectors-config.zip)",
49
+ addedAt: 0,
50
+ lastUpdated: 0,
51
+ },
52
+ {
53
+ id: "codebuddy-plugins-official",
54
+ kind: "plugins",
55
+ manifestName: "codebuddy-plugins-official",
56
+ type: "zip",
57
+ source: {
58
+ url: "https://download.codebuddy.cn/plugin-marketplace/codebuddy-plugins-official-4b9366ac-f7e5-4cef-b60a-99a5229ce2b2.zip",
59
+ path: "",
60
+ },
61
+ manifestPath: ".codebuddy-plugin/marketplace.json",
62
+ autoUpdate: false,
63
+ isBuiltIn: true,
64
+ description: "CodeBuddy 官方插件市场",
65
+ addedAt: 0,
66
+ lastUpdated: 0,
67
+ },
68
+ ];
69
+ }
70
+
71
+ const KINDS = new Set(["connectors", "plugins"]);
72
+
73
+ /** 字符串字段防御读取:非字符串一律回落默认值。 */
74
+ function asString(value, fallback = "") {
75
+ return typeof value === "string" ? value : fallback;
76
+ }
77
+
78
+ /** 布尔字段防御读取。 */
79
+ function asBool(value, fallback = false) {
80
+ return typeof value === "boolean" ? value : fallback;
81
+ }
82
+
83
+ /** 数字时间戳防御读取:非有限非负数一律回落 fallback。 */
84
+ function asTimestamp(value, fallback = 0) {
85
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
86
+ }
87
+
88
+ /**
89
+ * 把任意原始对象规范成一条完整的 MarketplaceEntry:缺失字段防御性补全
90
+ * (缺 addedAt/lastUpdated 补 0、缺 description 补空串等),畸形条目不炸调用方。
91
+ */
92
+ export function normalizeEntry(raw, { isBuiltIn = false } = {}) {
93
+ const source = raw && typeof raw.source === "object" && raw.source !== null ? raw.source : {};
94
+ return {
95
+ id: asString(raw?.id).trim(),
96
+ kind: KINDS.has(raw?.kind) ? raw.kind : "connectors",
97
+ manifestName: asString(raw?.manifestName, asString(raw?.id).trim()),
98
+ type: raw?.type === "directory" ? "directory" : "zip",
99
+ source: { url: asString(source.url), path: asString(source.path) },
100
+ manifestPath: asString(raw?.manifestPath),
101
+ autoUpdate: asBool(raw?.autoUpdate, false),
102
+ isBuiltIn: asBool(raw?.isBuiltIn, isBuiltIn),
103
+ description: asString(raw?.description),
104
+ addedAt: asTimestamp(raw?.addedAt, 0),
105
+ lastUpdated: asTimestamp(raw?.lastUpdated, 0),
106
+ };
107
+ }
108
+
109
+ /** 从磁盘读原始文档;不存在/损坏一律返回 null(由调用方决定回落语义)。 */
110
+ async function readRawDoc(path) {
111
+ try {
112
+ return JSON.parse(await readFile(path, "utf8"));
113
+ } catch {
114
+ return null; // ENOENT / ENOTDIR / JSON 解析失败同途处理
115
+ }
116
+ }
117
+
118
+ /** staging + rename 原子写:先写 `<file>.staging`,再删旧文件换入(Windows rename 不覆盖)。 */
119
+ async function writeAtomic(path, doc) {
120
+ await mkdir(armoryPathDirname(path), { recursive: true });
121
+ await writeFile(`${path}.staging`, JSON.stringify(doc, null, 2));
122
+ await rm(path, { force: true });
123
+ await rename(`${path}.staging`, path);
124
+ }
125
+
126
+ /** 拆出目录部分(避免为 dirname 多引一个依赖,路径既有 / 也有 \ 的 Windows 形态)。 */
127
+ function armoryPathDirname(path) {
128
+ const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
129
+ return cut > 0 ? path.slice(0, cut) : path;
130
+ }
131
+
132
+ /**
133
+ * 读注册表并返回合并投影:
134
+ * - 文件不存在 / 解析失败 / marketplaces 非数组 → 内置默认两条(并尽力落盘);
135
+ * - 文件内与内置默认同 id 的条目**覆盖**默认项字段;
136
+ * - 其余文件条目按原顺序追加在默认项之后。
137
+ * 返回 { version, marketplaces },永不抛错。
138
+ */
139
+ export async function readRegistry(options = {}) {
140
+ const path = registryPath(options);
141
+ const raw = await readRawDoc(path);
142
+ const fileEntries = Array.isArray(raw?.marketplaces)
143
+ ? raw.marketplaces.filter((entry) => entry && typeof entry === "object" && !Array.isArray(entry))
144
+ : null;
145
+
146
+ if (fileEntries === null) {
147
+ // 首读落盘:把内置默认写进文件,失败(只读盘等)不影响返回值。
148
+ const defaults = builtinMarketplaces();
149
+ try {
150
+ await writeAtomic(path, { version: REGISTRY_VERSION, marketplaces: defaults });
151
+ } catch {
152
+ /* 落盘失败不致命:下次读仍回落内置默认 */
153
+ }
154
+ return { version: REGISTRY_VERSION, marketplaces: defaults };
155
+ }
156
+
157
+ const normalized = fileEntries.map((entry) => normalizeEntry(entry));
158
+ const byId = new Map(normalized.map((entry) => [entry.id, entry]));
159
+ const merged = builtinMarketplaces().map(
160
+ (builtin) => (byId.has(builtin.id) ? byId.get(builtin.id) : builtin),
161
+ );
162
+ const builtinIds = new Set(builtinMarketplaces().map((entry) => entry.id));
163
+ for (const entry of normalized) {
164
+ if (!builtinIds.has(entry.id)) merged.push(entry);
165
+ }
166
+ const version = raw && typeof raw.version === "number" ? raw.version : REGISTRY_VERSION;
167
+ return { version, marketplaces: merged };
168
+ }
169
+
170
+ /**
171
+ * 新增或更新一条市场条目(按 id 定位)。新条目补 addedAt,任何写入都刷新 lastUpdated。
172
+ * 用户 upsert 内置同 id 条目即形成覆盖记录(删掉后内置默认回归)。
173
+ * 返回写入后的合并投影。
174
+ */
175
+ export async function upsertMarketplace(entry, options = {}) {
176
+ const normalized = normalizeEntry(entry);
177
+ if (!normalized.id) throw new Error("市场条目缺少 id,无法登记");
178
+ const path = registryPath(options);
179
+ const raw = await readRawDoc(path);
180
+ const list = Array.isArray(raw?.marketplaces)
181
+ ? raw.marketplaces.filter((item) => item && typeof item === "object" && !Array.isArray(item))
182
+ : [];
183
+ const now = Date.now();
184
+ const existingIndex = list.findIndex((item) => normalizeEntry(item).id === normalized.id);
185
+ if (existingIndex >= 0) {
186
+ const existing = normalizeEntry(list[existingIndex]);
187
+ list[existingIndex] = {
188
+ ...normalized,
189
+ addedAt: existing.addedAt || now,
190
+ lastUpdated: now,
191
+ };
192
+ } else {
193
+ list.push({ ...normalized, addedAt: normalized.addedAt || now, lastUpdated: now });
194
+ }
195
+ await writeAtomic(path, { version: REGISTRY_VERSION, marketplaces: list });
196
+ return readRegistry(options);
197
+ }
198
+
199
+ /**
200
+ * 按 id 删除一条**文件内**条目。内置默认不受影响:删除同 id 的覆盖记录后,
201
+ * 合并投影里内置默认回归。id 不存在时为无害空操作。
202
+ * 返回写入后的合并投影。
203
+ */
204
+ export async function removeMarketplace(id, options = {}) {
205
+ const target = String(id ?? "").trim();
206
+ if (!target) throw new Error("缺少要删除的市场 id");
207
+ const path = registryPath(options);
208
+ const raw = await readRawDoc(path);
209
+ if (!Array.isArray(raw?.marketplaces)) {
210
+ return readRegistry(options); // 文件本就没有用户条目,直接回落投影
211
+ }
212
+ const list = raw.marketplaces.filter(
213
+ (item) => !(item && typeof item === "object" && normalizeEntry(item).id === target),
214
+ );
215
+ await writeAtomic(path, { version: REGISTRY_VERSION, marketplaces: list });
216
+ return readRegistry(options);
217
+ }
218
+
219
+ /** 取某个 kind 的第一条市场源(合并投影序 = 内置默认优先);没有则返回 null。 */
220
+ export async function sourceFor(kind, options = {}) {
221
+ const wanted = String(kind ?? "").trim();
222
+ const { marketplaces } = await readRegistry(options);
223
+ return marketplaces.find((entry) => entry.kind === wanted) ?? null;
224
+ }
package/lib/whats-new.js CHANGED
@@ -288,4 +288,86 @@ export const WHATS_NEW = [
288
288
  },
289
289
  ],
290
290
  },
291
+ {
292
+ version: '1.10.0',
293
+ items: [
294
+ {
295
+ text: '**英文界面真的能用了**:`t()` 框架从 1.9.x 起就写着「将来往字典合并译文即可,无需改调用点」,但字典实参一直是空的。现在填满 **720 条键**,覆盖源码里全部 685 条中文原文;语言按 `localStorage["dsh-armory.locale"]` → `navigator.language` → `zh-CN` 三级回落,宿主是英文的用户面板自动变英文。顺带修掉 `fmtInt` / `fmtTax` 把千分位写死 `en-US` 的问题',
296
+ where: '—(跟随宿主语言;想强制英文可设 localStorage 的 dsh-armory.locale)',
297
+ },
298
+ {
299
+ text: '**「明明选过仅侧栏入口,怎么又变回弹窗」根治了**:以前 `whatsNew.seen` 只存一个版本号字符串,升级后整个键被当作过期清掉,入口偏好位跟着一起归零。现在两个偏好各自结构化落盘(`whatsNew.state` / `updateHint.state`),读写都保留下旧裸键的回落(老用户升级不丢状态),升级后只有「弹窗展示」位随版本重置',
300
+ where: '更新提示弹窗 + 面板头部更新提示条',
301
+ },
302
+ {
303
+ text: '**连接诊断卡**:Server 详情页新增分区,直接列出这台服务器的连接状态(含会话槽)、会话复用数、空闲回收倒计时、上次回收耗时(超时标红)、配置是否已改动、同名项目冲突——数据来自早就注册、却从未被面板调用过的 `projectConnections`。状态不是 ready 时,卡片底部直接给「重连这台服务器」。按需拉一次不轮询,右上角可刷新',
304
+ where: 'MCP → 项目作用域 → 任一服务器详情',
305
+ },
306
+ {
307
+ text: '**teardown 残留看得见了**:先说明白一件事——**本插件不 spawn 任何 MCP 子进程**(`lib/` 里 `SIGTERM` / `SIGKILL` / `taskkill` 零命中),所以「插件自己按进程树 kill」在架构上不成立,那是宿主 mcp-client 的职责。这个版本做的是本侧可观测:记录 teardown 的耗时与结局(超过 5s 判卡住,只留最近 64 条、10 分钟过期),经既有的 `projectConnections` 行以 `teardown` 字段流出,**没有新增 RPC**;另附上游提案 `docs/host-proposal-mcp-teardown.md`',
308
+ where: '—(诊断数据见连接诊断卡的「上次回收」一行)',
309
+ },
310
+ {
311
+ text: '**导入带 `${input:xxx}` 的 VS Code 配置不再整份失败**:以前只要有一条引用交互输入,整份文件拒绝导入。现在逐条降级——`url` / `command` / `cwd` 这类核心字段含 input 的条目进「不支持」分组并附原因(去掉等于发明一个猜的值,属静默错误),而 `env.*` / `headers.*` / `args` 元素含 input 的只剔该项并给 warning,条目仍可导入。预览弹层按「可导入 / 不支持」分组呈现',
312
+ where: 'MCP → 导入 → 扫描来源(VS Code 配置)',
313
+ },
314
+ {
315
+ text: '**工具裁缝三处改善**:① 时间窗新增「今日」档,调参后看当天效果不用再切到 7 天;② Node < 22.15 时 zstd 统计全 0 会被误读成「没有调用」,现在这些数字渲染成 `—`(数据缺失),与真实 0 区分开;③ 面板新增「缓存占用:x MB / 32 条」一行,配「清空账本缓存」按钮,清完直接用返回报告刷新、不额外扫一遍',
316
+ where: '工具裁缝标签页',
317
+ },
318
+ {
319
+ text: '**专家详情不再只给看 4000 字**:persona 截断处加「展开全文(N 字符)/ 收起」,魔法数字提为 `PERSONA_PREVIEW_MAX`(已召唤 / 市场两个弹层都覆盖);quickPrompts 超过 3 条给「显示全部 N 条 / 收起」;卡片描述 48 字截断处补 `title`,悬停可看全文',
320
+ where: '专家 → 任一专家详情弹层',
321
+ },
322
+ {
323
+ text: '**认证方式有徽章、分页能跳页、只读看板说明白了**:① 内置 MCP 目录早就带 `auth` / `authNote` 元数据,现在按类型给色——需交互授权的(OAuth / OAuth-or-PAT)用警示色,只需填 key 的中性色,`none` 不占位;这两个字段只是展示元数据,绝不写进用户配置。② 三处分页统一改用同一个 `Pager` 组件,带首页/末页 + 页码跳转 + 「第 x / y 页 · 共 N 项」常驻计数。③ 技能看板顶部直接写明只读:宿主只提供 `listSkills` / `skillDetail`,要改内容请编辑 `SKILL.md`',
324
+ where: 'MCP → 内置目录 / 市场连接器 / 市场技能;技能标签页',
325
+ },
326
+ {
327
+ text: '**通用体验四件**:① 首屏数据未到时给轻骨架(`prefers-reduced-motion` 下停用 shimmer),不再「空白半秒像卡死」;② `FAB_SIZE` / `FAB_EDGE` 提到 CSS 之前并用插值,消除「改一处忘一处」;③ 9 处 `role="dialog"` 统一走 `useFocusTrap`——打开聚焦首个可交互元素、Tab 在框内循环、关闭时焦点还原到触发元素;④ 崩溃面板除 retry 外新增「复制错误详情」(`error.stack` + `componentStack` + 时间与 locale 组装入剪贴板),降低回报成本',
328
+ where: '—(面板任意位置;崩溃面板见出错时)',
329
+ },
330
+ {
331
+ text: '**测试从 13 个套件加到 18 个**,把「注释里承诺过、但没测试守得住」的契约都钉死:契约三处一致性、导入源降级结构、i18n 覆盖率双向差集(英文界面上冒中文正是这个缺口)、persona 阈值、裁缝默认窗口、崩溃详情可复制、teardown 残留诊断。每个套件都内置反向自测,证明断言不是恒真',
332
+ where: '—(开发者视角:`npm test`)',
333
+ },
334
+ ],
335
+ },
336
+ {
337
+ version: '2.0.0',
338
+ items: [
339
+ {
340
+ text: '**来了第三个市场源:插件页**:新开「插件」页,把三类来源并进一个可搜索、可筛选的目录——**官方**(DSH 宿主自带组件,随宿主分发、不可单独安装)、**CodeBuddy 官方**(ZIP 分发)、**DSH 社区**(`awesome-dsh-plugins` 社区清单,jsdelivr 走 CDN)。安装统一走宿主 `pluginManager` 拉取落盘,**装完需重启宿主才生效**;宿主没提供 `pluginManager` 时会明确报错而不是装作成功。条目没给 npm 包名 / 没有 repository 的,文案直说「暂不支持安装」「仅展示」,不做猜的安装',
341
+ where: '面板「插件」页',
342
+ },
343
+ {
344
+ text: '**需要 token 的连接器,现在安装时就让你填**:安装前自动扫出 `mcpServers` 里的 `${...}` 占位符,弹「填写 {连接器} 的凭据」逐槽填写后再装;留空表示稍后自行补全,装完还会提示「另有 N 个占位符未填写,配置里保留原样」。没有占位符的连接器直接放行,不多弹一层',
345
+ where: 'MCP 市场 → 任一带占位符的连接器 → 安装',
346
+ },
347
+ {
348
+ text: '**MCP OAuth 一键授权**:以前远端 MCP 只认 `requestInit.headers`、没有 authProvider,OAuth 根本装不上,只能手搓 token。现在按 RFC 9728 / 8414 / 7591 / 7636 / 8707 走完整流程:发现受保护资源与授权服务器 → 动态客户端注册 → PKCE(S256) → 开浏览器授权 → 拿 code 换 token → 写进 patch 的 headers,到期自动刷新。回调通道**三级降级**:本地 loopback 自动收(主流实现)→ 服务器只接受自定义 scheme 时粘贴整条回调地址 → 都不通就直接贴 Bearer token',
349
+ where: 'MCP 市场 → 返回 401 的连接器 → 授权',
350
+ },
351
+ {
352
+ text: '**安装状态账本 + 跨页「已安装」筛选**:新增 `~/.dsh/armory/install-state.json` 记录每个连接器 / 技能 / 插件是谁装的、来自哪个市场,市场页据此把「本插件装的」和「别处来的」分开,并支持**跨页**筛出已安装项(技能侧仍只筛当前页,两处文案已分别说明)。登记失败不会让安装失败,只补一句「已安装,但状态登记失败」',
353
+ where: '市场各页顶部筛选 / 已安装计数',
354
+ },
355
+ {
356
+ text: '**技能看板会分类了**:技能按 **编程开发 / 设计与创作 / 文档办公 / 效率与自动化 / 开发运维 / 未分类** 六类归置,网格与列表双视图可切,可按分类过滤;详情新增所属分类、来源目录、文件位置,并能一键「复制 SKILL.md 路径」「复制 @会话引用」',
357
+ where: '技能标签页',
358
+ },
359
+ {
360
+ text: '**社区目录秒开**:上次成功的社区清单镜像进浏览器 `localStorage`(7 天 / 4MB 上限,超配额就保留旧快照),首屏先渲染快照、再被真实结果静默替换,**0 网络 0 IPC**;源不可达时明确写「目录缓存已过期,显示的是本地缓存」,而不是给一个空列表',
361
+ where: '插件 → DSH 社区',
362
+ },
363
+ {
364
+ text: '**安装轻提示 + 注入规则只读展示**:安装过程挂一个不随面板卸载而消失的浮层(纯 DOM、只写 `textContent`,不走 `createPortal`,top 64px);连接器详情里解析 `connectors.json` 的 `auth_injection_rules`,标出哪些连接器依赖 WB 账号体系「代注入」——**DSH 不代注入,只解析展示**,两者边界在代码注释与 UI 上都写明',
365
+ where: '任意安装动作 / MCP → 连接器详情',
366
+ },
367
+ {
368
+ text: '**多市场注册表**:市场源不再硬编码,落到 `~/.dsh/armory/marketplaces.json`(缺失或损坏回落内置默认项,首读即落盘),支持增删改;写盘一律 staging + rename 原子替换,避免半截文件',
369
+ where: '—(`~/.dsh/armory/marketplaces.json`)',
370
+ },
371
+ ],
372
+ },
291
373
  ]
@@ -172,6 +172,10 @@ async function createSharedConnection(ctx, wsPath, server, clientConfig, configF
172
172
  // 每次挂载都拆掉重建(那会每个会话创建都白起一次子进程)。再要求连接已存在一段时间,
173
173
  // 避免把"刚建连、还在握手"误判成终态。
174
174
  const DEAD_CONNECTION_AGE_MS = 5_000;
175
+ // OPT-15:teardown 上界没有硬手段(本插件不持有 MCP 子进程句柄,无法 kill 进程树),
176
+ // 但「卡住」必须可见。取 5s 与 DEAD_CONNECTION_AGE_MS 对齐:同一份连接在同一条时间尺度
177
+ // 上被判定「已死」与「回收卡住」,两个数字不同会让排障时对不上号。
178
+ const TEARDOWN_STUCK_MS = 5_000;
175
179
  function isDeadConnection(ctx, entry) {
176
180
  if (entry.fiber?.state !== FIBER_ACTIVE) return false;
177
181
  if (entry.hadTools !== true) return false;
@@ -298,6 +302,106 @@ function releaseSharedConnection(ctx, entry) {
298
302
  armIdleRetire(ctx, entry);
299
303
  return undefined;
300
304
  }
305
+
306
+ // ---------- OPT-15:teardown 上界可观测化 ----------
307
+ // 为什么只能「观测」而不能「回收」:本模块从不 spawn 任何 MCP 子进程 —— 子进程归宿主的
308
+ // dsh-scope / dsh-mcp-client 所有,插件侧只拿到 `scoped` 句柄,没有 pid、没有 ChildProcess,
309
+ // 因此没有可发信号的句柄(lib/ 下 grep 信号名与 Windows 的进程树结束命令,只命中本段注释)。
310
+ // 上游 @modelcontextprotocol/client 的 stdio transport 里,`close()` 等的是子进程的 `'close'`
311
+ // 事件;孙进程(npx/uvx 包装器、server 内部的 helper)继承并持有 stdio 管道时,`'close'`
312
+ // 永不触发,回收就永久挂在等待上。既无法从这侧打断,就只能把「已经等了多久、有没有超阈、
313
+ // 上次花了多久」变成可见事实,让人自己决定重启宿主。上游提案见 docs/host-proposal-mcp-teardown.md。
314
+ //
315
+ // OPT-15:每次 teardown 的耗时落档,按 app root 分桶,桶内按 connKey 只留最近一条。
316
+ // 只留一条的理由:面板要回答的是「这条连接现在还卡不卡」,不是审计历史;留全量会让
317
+ // 长时间运行的宿主把这本账涨成内存泄漏。上限 TEARDOWN_RECORD_MAX 兜底异常路径。
318
+ const TEARDOWN_RECORD_MAX = 64;
319
+ // OPT-15:「上次回收花了 x ms」只有在它还解释得了现在时才算信息。一条 10 分钟前的回收结果
320
+ // 对「现在卡不卡」没有解释力,留着反而误导(面板摆出一个 12.3s 的读数,读的人会以为那是
321
+ // 刚才发生的事)—— 超过这个年龄就不送了,宁可给 null。
322
+ const TEARDOWN_RECORD_STALE_MS = 600_000;
323
+ const teardownRecords = new WeakMap(); // appRoot -> Map<connKey, record>
324
+ // record 形状(面板契约的一部分,字段名不要改):
325
+ // { wsPath, serverName, startedAt, settledAt, elapsedMs, settled, stuck, error }
326
+ // 未落定时 elapsedMs / stuck 存的是「出生那一刻」的值(0 / false),真正的读数由读取方
327
+ // 现算 —— 存盘值一落地就开始过期,把过期值当事实送出去比不送更糟。
328
+ function beginTeardownRecord(ctx, entry, startedAt) {
329
+ const bucket = bucketOf(teardownRecords, ctx);
330
+ const record = {
331
+ wsPath: entry.wsPath,
332
+ serverName: entry.serverName,
333
+ startedAt,
334
+ settledAt: null,
335
+ elapsedMs: 0,
336
+ settled: false,
337
+ stuck: false,
338
+ error: "",
339
+ };
340
+ // 同一个 connKey 又来一轮(重建后又被拆):覆盖上一轮,仍是「只留最近一条」。
341
+ bucket.delete(entry.key);
342
+ bucket.set(entry.key, record);
343
+ pruneTeardownRecords(bucket);
344
+ return record;
345
+ }
346
+ // OPT-15:落定时改这一条。**绝不**参与返回值与错误传播 —— retireSharedConnection 的
347
+ // `.catch(onError)` 依旧把错误吞成正常 resolve,记录表只是旁路观察者。
348
+ function settleTeardownRecord(ctx, entry, record, message) {
349
+ const bucket = teardownRecords.get(appRoot(ctx));
350
+ // 已被同一 connKey 的下一轮覆盖(上一轮的 teardown 迟到才落定):不动新记录 —— 那一条
351
+ // 描述的是**还在跑**的那一轮,被旧结果改写就会把「正在卡住」显示成「已释放」。
352
+ if (!bucket || bucket.get(entry.key) !== record) return;
353
+ // 先落定者为准。同步抛错时 `beginDispose` 的 catch 会先记下真实错误,随后那条
354
+ // `Promise.resolve(undefined).then(settleOk, ...)` 还会再进来一次并带着空消息 —— 若不拦住,
355
+ // 「同步失败」就会被改写成「干净释放」,错误信息凭空消失。
356
+ if (record.settled) return;
357
+ const settledAt = Date.now();
358
+ record.settledAt = settledAt;
359
+ record.elapsedMs = Math.max(0, settledAt - record.startedAt);
360
+ record.settled = true;
361
+ // 落定之后 stuck 记的是「这一趟有没有卡过」,而不是「此刻还卡不卡」。
362
+ record.stuck = record.elapsedMs >= TEARDOWN_STUCK_MS;
363
+ record.error = message ? String(message) : "";
364
+ }
365
+ // OPT-15:只读取记录,不建桶 —— 读一条不存在的记录不该产生副作用(调用方是只读诊断视图)。
366
+ function teardownRecordFor(ctx, key) {
367
+ const bucket = teardownRecords.get(appRoot(ctx));
368
+ return (bucket && bucket.get(key)) || null;
369
+ }
370
+ function pruneTeardownRecords(bucket) {
371
+ while (bucket.size > TEARDOWN_RECORD_MAX) {
372
+ // 淘汰 startedAt 最旧的一条。桶只在这个量级,线性扫描比维护第二份有序结构更便宜。
373
+ let oldestKey;
374
+ let oldestAt = Infinity;
375
+ for (const [key, record] of bucket) {
376
+ if (record.startedAt <= oldestAt) { oldestAt = record.startedAt; oldestKey = key; }
377
+ }
378
+ if (oldestKey === undefined) return;
379
+ bucket.delete(oldestKey);
380
+ }
381
+ }
382
+ // OPT-15:记录表是纯旁路观察者 —— 它自己出任何问题都不许改变 teardown 的返回值或错误传播。
383
+ // 一次 record 写入失败(弱表已被回收、ctx 畸形等)最坏只是面板少一行读数,绝不能升级成
384
+ // 「原本能正常释放的连接现在释放失败」。所以每次写都套这一层。
385
+ function observeTeardown(write) {
386
+ try { return write(); } catch { return null; }
387
+ }
388
+ // OPT-15:一条记录 → 面板行上的 `teardown` 字段。两种读法分开给,因为它们的语义不同:
389
+ // live —— 还在跑,必须用**现算**的 elapsedMs 重判 stuck(存盘值一落地就过期);
390
+ // final —— 已落定,用存盘终值;超龄则整条不送(见 TEARDOWN_RECORD_STALE_MS)。
391
+ // 共用这一个出处,面板才不会「释放中说 0ms、释放后说 12s」两套说法。
392
+ function teardownLiveView(record, now) {
393
+ if (!record || record.settled) return null;
394
+ const elapsedMs = Math.max(0, now - record.startedAt);
395
+ return { startedAt: record.startedAt, elapsedMs, settled: false, stuck: elapsedMs >= TEARDOWN_STUCK_MS, error: record.error };
396
+ }
397
+ // OPT-15:已落定记录才回答「上次回收花了多久」。未落定的记录在这里**不送**:ready 行描述的是
398
+ // 眼下这条连接,把一个不知道会不会结束的旧 teardown 挂在它身上,读的人会以为刚刚的释放出了问题。
399
+ function teardownFinalView(record, now) {
400
+ if (!record || !record.settled) return null;
401
+ if (now - (record.settledAt ?? record.startedAt) > TEARDOWN_RECORD_STALE_MS) return null;
402
+ return { startedAt: record.startedAt, elapsedMs: record.elapsedMs, settled: true, stuck: record.stuck, error: record.error };
403
+ }
404
+
301
405
  // 立刻销毁一份连接:「不再有效」(配置删除/改动、面板重建、插件卸载),而不是「暂时没人用」。
302
406
  // 返回 teardown promise,让调用方能把它交回 cordis:会话销毁/插件卸载不会早于 MCP 子进程关闭完成。
303
407
  function retireSharedConnection(ctx, entry) {
@@ -308,17 +412,28 @@ function retireSharedConnection(ctx, entry) {
308
412
  entry.released = true;
309
413
  const bucket = sharedConnBucket(ctx);
310
414
  const cell = bucket.get(entry.key);
311
- const onError = (error) => { ctx.logger?.error?.(`mcp-manager: 项目 MCP ${entry.serverName} 连接释放失败: ${error?.message ?? error}`); };
415
+ // OPT-15:旁路观察。先落一条「未落定」的记录 —— 即使这趟 teardown 永远不返回,记录表里
416
+ // 也留着它的出生时间,面板才有东西可现算。记录表只写不读回,不影响下面任何分支的返回值。
417
+ const record = observeTeardown(() => beginTeardownRecord(ctx, entry, Date.now()));
418
+ const settleOk = (value) => { observeTeardown(() => settleTeardownRecord(ctx, entry, record, "")); return value; };
419
+ const onError = (error) => {
420
+ // 同步抛错(beginDispose 的 catch)与异步失败走同一个出口,于是两条返回路径都被记到。
421
+ observeTeardown(() => settleTeardownRecord(ctx, entry, record, error?.message ?? error));
422
+ ctx.logger?.error?.(`mcp-manager: 项目 MCP ${entry.serverName} 连接释放失败: ${error?.message ?? error}`);
423
+ };
312
424
  // scoped.dispose() 已由 dsh-scope 的 `disposing ??=` 与 cordis 的 runner.epoch 双重幂等守护。
313
425
  const beginDispose = () => { try { return entry.scoped.dispose(); } catch (error) { onError(error); return undefined; } };
426
+ // 用 `.then(settleOk, onError)` 而不是 `.finally(settleOk)`:`.finally` 会把错误原样穿过去,
427
+ // 而原代码的语义是 `.catch(onError)` —— 错误在 onError 处就被吞成正常 resolve了。保持原样。
428
+ const disposal = () => Promise.resolve(beginDispose()).then(settleOk, onError);
314
429
  if (!cell || cell.entry !== entry) {
315
430
  // 该 entry 已不在桶内(HMR/异常路径):直接释放,不留占位。
316
- return Promise.resolve(beginDispose()).catch(onError);
431
+ return disposal();
317
432
  }
318
433
  // 转为「释放中」占位:teardown 完成前不删除,期间的 acquire 会等待后重建。
319
434
  cell.entry = undefined;
320
435
  cell.pending = undefined;
321
- cell.disposing = Promise.resolve(beginDispose()).catch(onError).finally(() => {
436
+ cell.disposing = disposal().finally(() => {
322
437
  if (bucket.get(entry.key) === cell) bucket.delete(entry.key);
323
438
  });
324
439
  return cell.disposing;
@@ -617,6 +732,8 @@ function workspaceScopeErrorOf(wsPath, serverName) {
617
732
  async function projectConnectionsView(ctx) {
618
733
  const bucket = sharedConnections.get(appRoot(ctx));
619
734
  if (!bucket) return [];
735
+ // OPT-15:一次视图用一个时间基准,同一屏里的「已释放 12.3s」不会各行差几十毫秒。
736
+ const now = Date.now();
620
737
  const rows = [];
621
738
  for (const [key, cell] of [...bucket]) {
622
739
  const separator = key.indexOf("\u0000");
@@ -628,10 +745,19 @@ async function projectConnectionsView(ctx) {
628
745
  sessions: sessionSlotCount(ctx, key),
629
746
  duplicateOwners: duplicateServerNameOwners(ctx, wsPath, serverName),
630
747
  };
748
+ // OPT-15:teardown 读数只从记录表取(只读,不 acquire/release/await teardown —— 本视图的
749
+ // 只读性质不变)。记录表里没有就是 null,不为了「看起来完整」编一个零值。三条取值路径:
750
+ // 释放中 → 「还在跑」的现算读数;未落定记录缺失(已被下一轮覆盖)则退到已落定终值。
751
+ // 已就绪 → 只要已落定终值(「上次回收花了 x ms」),未落定的旧记录不该挂在它身上。
752
+ // 其余 → null(没有可归属于这条连接 teardown 的事实)。
753
+ const record = teardownRecordFor(ctx, key);
754
+ const teardown = cell.disposing
755
+ ? (teardownLiveView(record, now) ?? teardownFinalView(record, now))
756
+ : cell.entry ? teardownFinalView(record, now) : null;
631
757
  const entry = cell.entry;
632
758
  if (!entry) {
633
759
  // 还没有 entry:要么在建连,要么在释放。两者都没有连接态可读,configStale 无从判定。
634
- rows.push({ ...base, state: cell.disposing ? "disposing" : "connecting", refs: 0, toolCount: 0, fiberState: undefined, configStale: null, configError: "", scopeError: workspaceScopeErrorOf(wsPath, serverName) });
760
+ rows.push({ ...base, state: cell.disposing ? "disposing" : "connecting", refs: 0, toolCount: 0, fiberState: undefined, configStale: null, configError: "", scopeError: workspaceScopeErrorOf(wsPath, serverName), teardown });
635
761
  continue;
636
762
  }
637
763
  // configStale 与 toolCount 走面板那条同一个判定(workspaceConnectionStatus),不另写一套
@@ -652,6 +778,9 @@ async function projectConnectionsView(ctx) {
652
778
  configStale: status ? status.configStale : null,
653
779
  configError: current.error,
654
780
  scopeError: status ? status.scopeError : workspaceScopeErrorOf(wsPath, serverName),
781
+ // OPT-15:已就绪的连接仍会带上一趟 teardown 的终值 —— 面板能说「上次回收花了 x ms」;
782
+ // 超龄的终值由 teardownViewOf 折成 null(见 TEARDOWN_RECORD_STALE_MS)。
783
+ teardown,
655
784
  });
656
785
  }
657
786
  return rows;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-armory",
3
- "version": "1.9.4",
4
- "description": "Unified Skill + MCP + Expert board for DeepSeek Harness (desktop app and Web GUI) — global panel page, JSON import, WorkBuddy connector & SkillHub & Expert market installs, tool-usage tailor, and profile-backed Host Remote management.",
3
+ "version": "2.0.0",
4
+ "description": "Unified Skill + MCP + Expert + Plugin board for DeepSeek Harness (desktop app and Web GUI) — global panel page, JSON import, WorkBuddy connector & SkillHub & Expert & CodeBuddy plugin & awesome-dsh-plugins market installs, MCP OAuth authorization, tool-usage tailor, and profile-backed Host Remote management.",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/2026heshao/dsh-armory.git"
@@ -20,6 +20,7 @@
20
20
  "test:expert": "node ./test/run-expert-manual.mjs",
21
21
  "test:upgrade": "node ./test/upgrade-plan.mjs",
22
22
  "test:install": "node ./test/verify-install-info.mjs",
23
+ "test:host": "node ./test/verify-host-installinfo.mjs",
23
24
  "test:e2e": "node ./test/run-e2e.mjs",
24
25
  "lint": "eslint .",
25
26
  "format": "prettier --write \"lib/**/*.js\" \"test/**/*.mjs\" \"*.mjs\" \"*.json\"",
@@ -48,13 +49,13 @@
48
49
  "peerDependencies": {
49
50
  "@deepseek-ai/cordis": "~4.0.2",
50
51
  "@deepseek-ai/cordis-plugin-loader": "^1.0.3",
51
- "@deepseek-ai/dsh-agent": ">=0.1.5-rc.1 <0.2.0",
52
- "@deepseek-ai/dsh-agent-loop": ">=0.1.5-rc.1 <0.2.0",
53
- "@deepseek-ai/dsh-atomic-write": ">=0.1.5-rc.1 <0.2.0",
54
- "@deepseek-ai/dsh-mcp-client": ">=0.1.5-rc.1 <0.2.0",
55
- "@deepseek-ai/dsh-scope": ">=0.1.5-rc.1 <0.2.0",
56
- "@deepseek-ai/dsh-tools": ">=0.1.5-rc.1 <0.2.0",
57
- "@deepseek-ai/dsh-typert-protocol": ">=0.1.5-rc.1 <0.2.0"
52
+ "@deepseek-ai/dsh-agent": ">=0.1.5-rc.1",
53
+ "@deepseek-ai/dsh-agent-loop": ">=0.1.5-rc.1",
54
+ "@deepseek-ai/dsh-atomic-write": ">=0.1.5-rc.1",
55
+ "@deepseek-ai/dsh-mcp-client": ">=0.1.5-rc.1",
56
+ "@deepseek-ai/dsh-scope": ">=0.1.5-rc.1",
57
+ "@deepseek-ai/dsh-tools": ">=0.1.5-rc.1",
58
+ "@deepseek-ai/dsh-typert-protocol": ">=0.1.5-rc.1"
58
59
  },
59
60
  "devDependencies": {
60
61
  "@deepseek-ai/cordis": "~4.0.2",