dsh-armory 2.4.4 → 2.4.5

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 CHANGED
@@ -112,6 +112,8 @@ Experts (WorkBuddy expert center, 429 experts / 15 industries; category filter +
112
112
  - **WB Market (direct installs)**: a third board tab with two markets in one place —
113
113
  - **Connectors (MCP)**: reads the official WorkBuddy distribution ZIP (`static.workbuddy.cn`, the same source the WorkBuddy desktop app uses) on demand — 316 connectors, 242 installable (mostly streamable-http), while 74 CLI-based or WorkBuddy-token-dependent ones are honestly labeled "view only" with the reason. Installing = merging the connector's `mcpServers` into your MCP config through the same pipeline as "Import JSON" (admission, duplicate skip, entry-id reservation), in either global or project scope. The ZIP is ETag-cached under `~/.dsh/wb-market/` and decompressed with Node's built-in zlib — **zero new runtime dependencies**. Before installing, a connector that pins a local executable is checked first: a missing file or unsupported platform is blocked with the exact path instead of being written and then losing its tools after ten reconnect attempts (issue #3). Cards report the **real connection state** — connected / needs authorization / not connected — and a 401/403 offers Re-authorize instead of a reconnect that cannot help; details show the authorization expiry and can refresh it, writing the new credential back to the config
114
114
  - **Community MCP (official Registry, new in 2.4.4)**: switch the Connectors tab to community entries from the official MCP Registry, with no WorkBuddy account; keyword search, cursor pagination, an "only directly runnable (npx / uvx)" filter, details with the launch command, args, environment variables and runnability, and "Add MCP" that only prefills the form and writes nothing until you confirm. Community entries are not reviewed by the Armory, and a permanent notice says so; the data layer accepts only active, runnable entries and https repository links
115
+ - **The community badge shows the size of the whole catalog (since 2.4.5)**: the Community MCP badge on the Connectors tab stays on screen and shows the **total number of catalog entries**, not how many rows this query read. The official Registry returns no total, so the panel counts page by page in the background (up to 12 pages per session, progress kept locally so the next visit resumes): an exact number once it reaches the end, an "N+" lower bound while counting, and "..." before it starts. The toolbar still shows the rows read by the current query, and both tooltips spell out which measure you are looking at
116
+ - **MCP entries you add now start on Windows (2.4.5)**: the host spawns stdio servers without a shell, while `npx` / `npm` on Windows are `.cmd` wrappers, so a bare spawn exits with `ENOENT` in milliseconds (the panel just shows "Connection closed" until the reconnect budget runs out). The single write boundary rewrites a bare `npx` / `npm` into the absolute `node.exe` on that machine plus the CLI entry script, and injects a writable npm cache directory (an existing `npm_config_cache` setting is never overwritten). Non-Windows commands, absolute paths, `uvx`, `${VAR}` and `!!js` are passed through untouched. **Trade-off**: the rewritten command is specific to that machine, so moving the config elsewhere means adding the connector again, and every rewrite is reported in the install notice
115
117
  - **Skills (SkillHub)**: reads the public SkillHub API (`api.skillhub.cn`, 190k+ skills) with keyword search, four sort orders, and pagination; details include publisher verification and security-scan status. Installing = downloading file-by-file, verifying each against the manifest's sha256, and writing into `~/.agents/skills/` (the same discovery scope as the official skill subsystem) — effective immediately. Installed skills show an "installed" badge, support one-click uninstall, and reinstalling updates them. After an install or uninstall the **Skill** tab's count badge and board list realign with the new set immediately — no tab switch or panel reopen needed. Downloads sorting is now done **within the current page** (the upstream `sortBy=downloads` hangs), while the other sorts still go remote; each request gives up after 15s and reports a localized error
116
118
  - Skills belong to their respective authors. Both upstreams are third-party interfaces whose fields may change; the panel carries a persistent note saying so
117
119
  - **Experts (direct, 1.9.0)** — a dedicated board tab (the sidebar entry is renamed **Skills · MCP · Experts**):
package/README.md CHANGED
@@ -103,6 +103,8 @@ dsh plugin --profile web add github:2026heshao/dsh-armory
103
103
  - **WB 市场(直连安装,1.8.0 新增)**:看板第四个分类,两个市场一处浏览——
104
104
  - **连接器(MCP)**:直读 WorkBuddy 官方分发 ZIP(`static.workbuddy.cn`,与 WorkBuddy 桌面端同一数据源),316 个连接器按需解析;可安装的 242 个(streamable-http 为主),CLI 型 / 需 WorkBuddy token 注入的 74 个如实标注「仅展示」并给出原因。安装 = 把该连接器的 `mcpServers` 合并写入 MCP 配置,与「导入 JSON」同一链路(准入、同名跳过、entry id 预留一致),全局与项目作用域都可选;ZIP 按 ETag 缓存在 `~/.dsh/wb-market/`,解压用 Node 内置 zlib,**零新增运行时依赖**。安装前预检本地客户端型连接器写死的本机程序——缺文件 / 平台不符直接拦住并说明缺哪个路径,不再写进配置后重连十次注销工具(issue #3);卡片状态按**真实连接态**区分「已连接 / 需授权 / 未连接」,401/403 就地给「重新授权」而不是让你反复重连,详情可看授权有效期并「刷新授权」把新凭据写回配置
105
105
  - **社区 MCP(官方 Registry,2.4.4 新增)**:连接器页可切到官方 MCP Registry 的社区条目,不依赖 WorkBuddy 账号;关键词搜索 + 游标分页 + 「只看可直接运行(npx / uvx)」,详情给出启动命令、参数、环境变量与可运行性,点「加入 MCP」只预填表单、确认后才写盘。社区条目未经军械库审核,来源行与详情常驻声明;数据层只接受 active 且可运行的条目,仓库链接仅放行 https
106
+ - **社区角标显示全库总数(2.4.5 起)**:连接器页「社区 MCP」角标常驻,显示的是**社区目录全库条目总数**而不是本次读到的条数——官方 Registry 不返回总数,插件在后台逐页统计(每次会话最多 12 页,进度存本机、下次打开续爬):数到底是精确值,没数到底是「N+」下界,还没开爬是「…」;工具条继续显示本次查询的「已读取 N 条」,两处口径在悬浮说明里写清
107
+ - **Windows 上加入的 MCP 不再必然连不上(2.4.5)**:宿主起 stdio 服务不走 shell,而 `npx` / `npm` 在 Windows 上是 `.cmd` 壳,裸 spawn 直接 `ENOENT` 秒退(面板表现为「连接已关闭」且重连耗尽后永久失败)。写入配置的唯一边界会把裸 `npx` / `npm` 归成本机绝对 `node.exe` + 入口 JS,并注入可写的 npm 缓存目录(你配过 `npm_config_cache` 就不动);非 Windows、绝对路径、`uvx`、`${VAR}`、`!!js` 原样放行。**副作用**:归一后的命令是本机专属路径,换机导入需重新「加入 MCP」;改动会如实写进安装提示
106
108
  - **技能(SkillHub)**:直读 SkillHub 公开 API(`api.skillhub.cn`,19 万+ 技能),支持搜索(`keyword`)、排序(下载/安装/星标/评分)、分页;详情含发布方认证与安全扫描状态。安装 = 逐文件下载并按清单 sha256 校验后写入 `~/.agents/skills/`(与官方 skill 子系统同一发现范围),装完即刻生效;已装的显示「已安装」,可一键卸载、重装即更新。下载量排序在**当前页内**本地完成(上游 `sortBy=downloads` 现在会挂起),其余排序照常走远程;单次请求 15 秒超时,超时文案已中文化
107
109
  - 技能版权归原作者所有;两个均为第三方接口,字段可能随上游改版变化(面板顶部有常驻说明)
108
110
  - **专家直连(1.9.0 新增,体验对齐 WorkBuddy 专家中心)**:看板独立分类「专家」(侧栏入口同步更名「技能 · MCP · 专家」)——
@@ -292,7 +294,7 @@ dsh plugin --profile web remove dsh-armory
292
294
 
293
295
  ```sh
294
296
  npm install --no-save yaml@2 eslint@9 # 测试与风格检查的唯一裸依赖(运行依赖全部由宿主提供)
295
- npm test # 全 65 套件(与 CI / 发版门槛同一条)
297
+ npm test # 全 67 套件(与 CI / 发版门槛同一条)
296
298
  npm run lint # ESLint:no-undef / no-unused-vars / no-empty 等
297
299
  npm run test:e2e # 可选:用宿主补丁算法验证专家预设进入组合树
298
300
  dsh --profile web --dump-config # 看 profile 的有效配置
@@ -311,6 +313,7 @@ dsh web # 起 Web 做真实操作验证
311
313
  | 自定义套件(2.2.0) | `suites-registry` `suites-client` | 整集合覆盖 / 幂等 / 拒绝与截断两态;id 归属契约(保存后不分裂)、引用套件展开为多个 `/name`、三级写通道降级 |
312
314
  | 技能集合(2.2.0) | `skill-remove` `skill-refresh` | 卸载来源白名单与路径边界(根自身 / `..` 穿越 / 跨来源根 / 符号链接只删链接);装 / 卸后角标与看板实时刷新、乱序回包守卫 |
313
315
  | 社区 MCP(2.4.4) | `mcp-community` `community-pager` `community-detail` | Registry 映射 / active 与可运行过滤 / 游标 / 缓存重算 serverName / 4xx 与 5xx 分流;已读取总数与游标栈;详情字段、焦点与「加入 MCP」动作 |
316
+ | Windows 启动器与全库计数(2.4.5) | `win32-stdio-launch` `community-count` | 裸 `npx` / `npm` 归一(探测可注入、非 win32 不介入、幂等、降级如实报错);有界续爬 / 游标链 / 失败不清进度 / 统计口径 == 列表口径 / 常驻徽标三态 |
314
317
  | 技能禁用(2.4.4) | `skill-disable` | frontmatter 手术(重复键收敛 / CRLF / 空块 / 无闭行);来源白名单、根包含、realpath、BOM 与无合法 frontmatter 拒绝;幂等重放不写盘 |
315
318
  | 连接器体检(2.4.4) | `connector-auth-health` `wb-connector-local` | 真实连接态 / 已安装但未连接 / 需授权(401/403)/ 安装闸 / 授权状态;本机固定依赖 found / missing / platform 与安装前拦截 |
316
319
  | 项目 MCP 与系统打开(2.4.4) | `workspace-runtime` `system-open-spawn-guard` | 会话登记不依赖配置文件存在、对齐并入存活但未登记的会话;headless 缺 `xdg-open` 不拖走进程(含旧写法必崩的反向自测) |
package/lib/client.js CHANGED
@@ -1181,6 +1181,13 @@ const EN_US_MESSAGES = {
1181
1181
  "读取授权状态失败": "Couldn't read authorization status",
1182
1182
  "正在重连…": "Reconnecting…",
1183
1183
  "已请求重连": "Reconnect requested",
1184
+ "重连失败": "Reconnect failed",
1185
+ // 顶部进度浮层(安装 / 加入 MCP / 重连共用一枚浮层,分组只换措辞)。
1186
+ "正在处理 {0} 个 MCP 任务…": "Handling {0} MCP task(s)…",
1187
+ "MCP 任务已结束": "MCP tasks finished",
1188
+ "写入配置中…": "Writing config…",
1189
+ "已加入 · 宿主自动连接中": "Added · the host is connecting",
1190
+ "加入失败": "Add failed",
1184
1191
  "正在刷新授权…": "Refreshing authorization…",
1185
1192
  "{0}:授权已刷新,正在把新凭据写回配置…": "{0}: authorization refreshed, writing the new credential back to the config…",
1186
1193
  "刷新授权失败": "Couldn't refresh authorization",
@@ -1229,6 +1236,14 @@ const EN_US_MESSAGES = {
1229
1236
  "正在加载…": "Loading…",
1230
1237
  "已经到底了": "No more results",
1231
1238
  "第 {0} 页": "Page {0}",
1239
+ // 社区目录全库总数(徽标常驻):官方 Registry 不返回总数,只能逐页统计,所以分「共 N 条」
1240
+ // 与「至少 N 条」两种口径,别让下界被读成上限。
1241
+ "正在后台统计社区目录条目数,稍后自动显示…": "Counting the community catalog in the background — it shows up shortly…",
1242
+ "社区目录共 {0} 条(逐页统计;官方 Registry 不返回总数)": "The community catalog holds {0} entries (counted page by page; the official Registry returns no total)",
1243
+ "社区目录至少 {0} 条,仍在后台续爬统计(官方 Registry 不返回总数)": "The community catalog holds at least {0} entries - still counting in the background (the official Registry returns no total)",
1244
+ "本次查询已逐页读取的条数;上方徽标显示的是社区目录全库总数": "Entries read page by page for this query; the badge above shows the whole community catalog",
1245
+ "WorkBuddy 官方目录里的连接器总数": "Total connectors in the official WorkBuddy catalog",
1246
+ "正在读取连接器列表…": "Loading the connector list…",
1232
1247
  "加入 MCP": "Add MCP",
1233
1248
  "已加入": "Added",
1234
1249
  "已加入 ": "Added ",
@@ -1420,6 +1435,58 @@ function writeAwesomeProbeAt(value) {
1420
1435
  try { localStorage.setItem(AWESOME_PROBE_AT_KEY, String(value)); } catch { /* 隐私模式:退化为不节流 */ }
1421
1436
  }
1422
1437
 
1438
+ // ── 社区目录「全库总数」:连接器页那枚常驻徽标上的数 ─────────────────────────
1439
+ // 为什么不是直接问上游要:官方 Registry 不给总数——实测 `metadata.count` 就是本页条数
1440
+ // (limit=1 时 1、limit=100 时 100),`/v0/servers/count` 与 `/v0/registry/stats` 都是
1441
+ // 404,`limit>100` 直接 422。要数出来只能顺游标一页页爬,而全库实测 ≥13,000 条
1442
+ // (130 页仍未到底)、单页 2.4–8s,一次爬完要十几分钟和约 9MB,绝不能挡在用户面前。
1443
+ // 所以做成「有界 + 可续爬」:每次打开面板最多数 COMMUNITY_COUNT_PAGE_BUDGET 页,进度落
1444
+ // 在这里,下次接着数;数到底显示精确值,没到底显示下界「N+」。
1445
+ // 为什么值得为它写持久化:徽标原先显示的是「已读取 98 条」,用户会读成"社区一共只有 98 个"
1446
+ // (2026-10-30 用户反馈)。一个会慢慢长对的「1200+」比一个看起来像总数的「98」诚实得多。
1447
+ const COMMUNITY_COUNT_KEY = KEY_PREFIX + '.communityCount.v1';
1448
+ /** 数据层单块页数(lib/mcp-community.js 的 COMMUNITY_COUNT_CHUNK_PAGES 同值,靠测试钉住)。 */
1449
+ const COMMUNITY_COUNT_CHUNK_PAGES = 3;
1450
+ /** 一次面板会话的页数预算:4 块 × 3 页 = 12 页 = 1200 条,约 0.9MB。 */
1451
+ const COMMUNITY_COUNT_PAGE_BUDGET = 12;
1452
+ /** 空记录:还没数过。徽标显示「…」而不是消失——数字常驻才不会被当成"这一页就是全部"。 */
1453
+ const emptyCommunityCount = () => ({ counted: 0, cursor: '', done: false, at: 0 });
1454
+
1455
+ function readCommunityCount() {
1456
+ try {
1457
+ const raw = localStorage.getItem(COMMUNITY_COUNT_KEY);
1458
+ if (!raw) return null;
1459
+ const parsed = JSON.parse(raw);
1460
+ const counted = Number(parsed?.counted);
1461
+ if (!Number.isFinite(counted) || counted < 0) return null;
1462
+ return {
1463
+ counted: Math.floor(counted),
1464
+ cursor: typeof parsed.cursor === 'string' ? parsed.cursor : '',
1465
+ done: parsed.done === true,
1466
+ at: Number(parsed.at) || 0,
1467
+ };
1468
+ } catch { return null; }
1469
+ }
1470
+
1471
+ function writeCommunityCount(record) {
1472
+ // 只存四个标量(约 120 字节),不进快照那套 4.5MB 预算。
1473
+ try { localStorage.setItem(COMMUNITY_COUNT_KEY, JSON.stringify(record)); } catch { /* 隐私模式:下次进面板重数 */ }
1474
+ }
1475
+
1476
+ /** 徽标文本:没数过 / 数为 0 且没到底 → 「…」;数到底 → 精确值;否则 → 下界「N+」。 */
1477
+ function communityCountBadgeText(record) {
1478
+ if (!record) return '…';
1479
+ if (record.done) return String(record.counted);
1480
+ return record.counted > 0 ? `${record.counted}+` : '…';
1481
+ }
1482
+
1483
+ /** 徽标 tooltip:把「这个数是什么口径」写清楚,别让 1200+ 被读成"只能看到 1200 个"。 */
1484
+ function communityCountBadgeTitle(record) {
1485
+ if (!record || !record.counted) return t('正在后台统计社区目录条目数,稍后自动显示…');
1486
+ if (record.done) return t('社区目录共 {0} 条(逐页统计;官方 Registry 不返回总数)', [String(record.counted)]);
1487
+ return t('社区目录至少 {0} 条,仍在后台续爬统计(官方 Registry 不返回总数)', [String(record.counted)]);
1488
+ }
1489
+
1423
1490
  // ── 安装轻提示:挂在 document.body 上的常驻浮层 ─────────────────────────────
1424
1491
  // 安装进度此前只存在组件本地 state(busy / busyLabel):用户一切到别的面板或窗口,
1425
1492
  // 组件卸载 → 进度消失 → 看起来像「安装被取消了」。这个浮层直接挂 document.body,
@@ -1428,10 +1495,42 @@ function writeAwesomeProbeAt(value) {
1428
1495
  // 写插件名,不拼 innerHTML。
1429
1496
  const INSTALL_TOAST_ID = KEY_PREFIX + '-install-toast';
1430
1497
  const INSTALL_TOAST_CSS_ID = KEY_PREFIX + '-install-toast-css';
1431
- // name → { name, phase: 'running' | 'ok' | 'fail', at }
1498
+ // key → { name, phase: 'running' | 'ok' | 'fail', at, group, labels }
1499
+ // plugin 组的 key 就是插件名:isInstallRunning(名字) 靠它禁用「安装」按钮,这个约定不能改;
1500
+ // MCP 组的 key 带 'mcp:<动作>:<名称>' 前缀,既不会被同名插件顶掉一行,也不参与按钮禁用。
1432
1501
  const installTasks = new Map();
1433
1502
  const installTimers = new Map();
1434
1503
  let installToastNode = null;
1504
+ /** 浮层分组:决定标题措辞。 */
1505
+ const PROGRESS_GROUP_PLUGIN = 'plugin';
1506
+ const PROGRESS_GROUP_MCP = 'mcp';
1507
+ // 状态文案一律写成「渲染时才调 t()」的函数:语言在模块加载之后还可能切换,
1508
+ // 提前求值会把首次加载那一刻的 locale 钉进常量里(顺带也满足全文件中文包裹不变式)。
1509
+ const PLUGIN_TASK_LABELS = {
1510
+ running: () => t('安装中…'),
1511
+ ok: () => t('已安装'),
1512
+ fail: () => t('安装失败'),
1513
+ };
1514
+ const MCP_ADD_TASK_LABELS = {
1515
+ running: () => t('写入配置中…'),
1516
+ ok: () => t('已加入 · 宿主自动连接中'),
1517
+ fail: () => t('加入失败'),
1518
+ };
1519
+ const MCP_RECONNECT_TASK_LABELS = {
1520
+ running: () => t('正在重连…'),
1521
+ ok: () => t('已请求重连'),
1522
+ fail: () => t('重连失败'),
1523
+ };
1524
+ const PROGRESS_TITLES = {
1525
+ [PROGRESS_GROUP_PLUGIN]: {
1526
+ running: (count) => t('正在安装 {0} 个插件…', [count]),
1527
+ done: () => t('安装任务已结束'),
1528
+ },
1529
+ [PROGRESS_GROUP_MCP]: {
1530
+ running: (count) => t('正在处理 {0} 个 MCP 任务…', [count]),
1531
+ done: () => t('MCP 任务已结束'),
1532
+ },
1533
+ };
1435
1534
  function installToastCss() {
1436
1535
  if (typeof document === 'undefined' || document.getElementById(INSTALL_TOAST_CSS_ID)) return;
1437
1536
  const style = document.createElement('style');
@@ -1439,17 +1538,25 @@ function installToastCss() {
1439
1538
  style.textContent = [
1440
1539
  '.dsh-mcp-install-toast{position:fixed;top:64px;left:50%;transform:translate(-50%,-14px);z-index:2147483000;',
1441
1540
  'display:none;flex-direction:column;gap:6px;min-width:220px;max-width:min(420px,calc(100vw - 32px));',
1442
- 'padding:10px 14px 11px;border-radius:12px;pointer-events:none;',
1443
- 'background:var(--dsw-alias-bg-elevated,rgba(32,33,36,.92));color:var(--dsw-alias-text-primary,#f3f4f6);',
1444
- 'border:1px solid var(--dsw-alias-border-subtle,rgba(255,255,255,.12));',
1445
- 'box-shadow:0 10px 30px rgba(0,0,0,.28);backdrop-filter:blur(14px) saturate(160%);',
1541
+ 'padding:10px 14px 11px;border-radius:12px;pointer-events:none;text-align:start;',
1542
+ // 与底部 .dsh-mcp-toast 同一对自持色(--mcp-tip-*):宿主 token 里
1543
+ // --dsw-alias-bg-elevated / --dsw-alias-text-primary 本皮根本没定义,全靠这里的 fallback
1544
+ // 撑着;一旦宿主基础样式给其中一个赋值,就会重演「深蓝字压深蓝底」。fallback 值与
1545
+ // --mcp-tip-bg/--mcp-tip-fg 同色,两处浮层从此共用一套对比度已实测的色面。
1546
+ 'background:var(--mcp-tip-bg,rgba(23,38,63,.93));color:var(--mcp-tip-fg,#f4f7fd);',
1547
+ 'border:1px solid var(--mcp-tip-line,rgba(255,255,255,.18));',
1548
+ 'box-shadow:var(--mcp-tip-shadow,0 10px 30px rgba(0,0,0,.30));backdrop-filter:blur(14px) saturate(160%);',
1446
1549
  'font-size:12px;line-height:1.45;opacity:0;transition:opacity .18s ease,transform .18s ease}',
1447
1550
  '.dsh-mcp-install-toast.on{display:flex;opacity:1;transform:translate(-50%,0)}',
1448
1551
  '.dsh-mcp-install-toast-title{font-weight:600;font-size:12px;letter-spacing:.01em}',
1449
1552
  '.dsh-mcp-install-toast-row{display:flex;align-items:center;gap:8px;min-width:0}',
1450
- '.dsh-mcp-install-toast-dot{flex:0 0 auto;width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-accent-primary,#4d9fff);animation:dsh-mcp-install-pulse 1.1s ease-in-out infinite}',
1451
- '.dsh-mcp-install-toast-row[data-phase="ok"] .dsh-mcp-install-toast-dot{animation:none;background:var(--dsw-alias-status-success,#35c46a)}',
1452
- '.dsh-mcp-install-toast-row[data-phase="fail"] .dsh-mcp-install-toast-dot{animation:none;background:var(--dsw-alias-status-danger,#f0616b)}',
1553
+ // 状态圆点同样走自持色:宿主 --dsw-alias-accent-primary / -status-success / -status-danger
1554
+ // 三个键在本皮都不存在(皮肤用的是 state-*-primary 命名),旧写法一直吃 fallback。
1555
+ // 7px 的点属非文本信息,但它是「进行中/成功/失败」的唯一视觉差异,必须在深色面上站得住
1556
+ // (#7fb2ff / --mcp-tip-ok / --mcp-tip-err 对 #17263f 实测 6.7:1 以上)。
1557
+ '.dsh-mcp-install-toast-dot{flex:0 0 auto;width:7px;height:7px;border-radius:50%;background:#7fb2ff;animation:dsh-mcp-install-pulse 1.1s ease-in-out infinite}',
1558
+ '.dsh-mcp-install-toast-row[data-phase="ok"] .dsh-mcp-install-toast-dot{animation:none;background:var(--mcp-tip-ok,#7ee0a1)}',
1559
+ '.dsh-mcp-install-toast-row[data-phase="fail"] .dsh-mcp-install-toast-dot{animation:none;background:var(--mcp-tip-err,#ff8a8a)}',
1453
1560
  '.dsh-mcp-install-toast-name{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
1454
1561
  '.dsh-mcp-install-toast-status{flex:0 0 auto;opacity:.72;font-variant-numeric:tabular-nums}',
1455
1562
  '@keyframes dsh-mcp-install-pulse{0%,100%{opacity:.35;transform:scale(.85)}50%{opacity:1;transform:scale(1)}}',
@@ -1472,16 +1579,28 @@ function ensureInstallToast() {
1472
1579
  installToastNode = node;
1473
1580
  return node;
1474
1581
  }
1582
+ /** 行内状态文案:取该任务所属组的 labels,按当前 phase 调对应的 t() 函数。 */
1583
+ function taskStatusLabel(task) {
1584
+ const label = (task.labels || PLUGIN_TASK_LABELS)[task.phase];
1585
+ return typeof label === 'function' ? label() : '';
1586
+ }
1475
1587
  function renderInstallToast() {
1476
1588
  const node = ensureInstallToast();
1477
1589
  if (!node) return;
1478
1590
  const tasks = [...installTasks.values()];
1479
1591
  node.replaceChildren();
1480
1592
  if (!tasks.length) { node.classList.remove('on'); return; }
1481
- const running = tasks.filter((task) => task.phase === 'running').length;
1593
+ const runningTasks = tasks.filter((task) => task.phase === 'running');
1594
+ // 标题按「正在跑的那一组」措辞;两组同时在跑(装插件的同时点 MCP 重连)很罕见,
1595
+ // 取第一条进行中的分组就够,不为它另设一套文案。
1596
+ const group = (runningTasks[0] || tasks[0]).group || PROGRESS_GROUP_PLUGIN;
1597
+ const titles = PROGRESS_TITLES[group] || PROGRESS_TITLES[PROGRESS_GROUP_PLUGIN];
1598
+ // 计数只数「标题所指的这一组」:两组同时在跑时说「正在安装 2 个插件」而其中一个是 MCP
1599
+ // 就是假计数。另一组的任务不消失,仍在下面的行里逐条列出。
1600
+ const groupRunning = runningTasks.filter((task) => (task.group || PROGRESS_GROUP_PLUGIN) === group).length;
1482
1601
  const title = document.createElement('div');
1483
1602
  title.className = 'dsh-mcp-install-toast-title';
1484
- title.textContent = running ? t('正在安装 {0} 个插件…', [String(running)]) : t('安装任务已结束');
1603
+ title.textContent = groupRunning ? titles.running(String(groupRunning)) : titles.done();
1485
1604
  node.appendChild(title);
1486
1605
  for (const task of tasks.slice(0, 3)) {
1487
1606
  const row = document.createElement('div');
@@ -1494,37 +1613,53 @@ function renderInstallToast() {
1494
1613
  name.textContent = task.name;
1495
1614
  const status = document.createElement('span');
1496
1615
  status.className = 'dsh-mcp-install-toast-status';
1497
- status.textContent = task.phase === 'running' ? t('安装中…') : task.phase === 'ok' ? t('已安装') : t('安装失败');
1616
+ status.textContent = t(taskStatusLabel(task));
1498
1617
  row.appendChild(dot); row.appendChild(name); row.appendChild(status);
1499
1618
  node.appendChild(row);
1500
1619
  }
1501
1620
  node.classList.add('on');
1502
1621
  }
1503
- function installToastStart(name) {
1504
- const key = String(name || '');
1505
- if (!key) return;
1506
- const pending = installTimers.get(key);
1507
- if (pending) { clearTimeout(pending); installTimers.delete(key); }
1508
- installTasks.set(key, { name: key, phase: 'running', at: Date.now() });
1622
+ /**
1623
+ * 通用进度浮层入口:key 唯一标识一个在跑的任务(重复开装顶掉同一行,不叠两行)。
1624
+ * 挂的是 document.body,面板卸载、切标签页、切窗口都还在——这正是它存在的全部理由。
1625
+ */
1626
+ function progressToastStart(key, name, group, labels) {
1627
+ const taskKey = String(key || '');
1628
+ if (!taskKey) return;
1629
+ const pending = installTimers.get(taskKey);
1630
+ if (pending) { clearTimeout(pending); installTimers.delete(taskKey); }
1631
+ installTasks.set(taskKey, {
1632
+ name: String(name || taskKey),
1633
+ phase: 'running',
1634
+ at: Date.now(),
1635
+ group: group || PROGRESS_GROUP_PLUGIN,
1636
+ labels: labels || PLUGIN_TASK_LABELS,
1637
+ });
1509
1638
  renderInstallToast();
1510
1639
  }
1511
- function installToastFinish(name, phase) {
1512
- const key = String(name || '');
1513
- const task = installTasks.get(key);
1640
+ function progressToastFinish(key, phase) {
1641
+ const taskKey = String(key || '');
1642
+ const task = installTasks.get(taskKey);
1514
1643
  if (!task) return;
1515
1644
  task.phase = phase;
1516
1645
  task.at = Date.now();
1517
1646
  renderInstallToast();
1518
- // 结果态留 4.2s 让用户看清「装完了 / 失败了」,再淡出;期间同一插件重新开装会取消这个定时器。
1647
+ // 结果态留 4.2s 让用户看清「装完了 / 失败了」,再淡出;期间同一任务重新开跑会取消这个定时器。
1519
1648
  const timer = setTimeout(() => {
1520
- installTimers.delete(key);
1521
- const current = installTasks.get(key);
1649
+ installTimers.delete(taskKey);
1650
+ const current = installTasks.get(taskKey);
1522
1651
  if (current && current.phase !== 'running' && Date.now() - current.at >= 4000) {
1523
- installTasks.delete(key);
1652
+ installTasks.delete(taskKey);
1524
1653
  renderInstallToast();
1525
1654
  }
1526
1655
  }, 4200);
1527
- installTimers.set(key, timer);
1656
+ installTimers.set(taskKey, timer);
1657
+ }
1658
+ function installToastStart(name) {
1659
+ progressToastStart(name, name, PROGRESS_GROUP_PLUGIN, PLUGIN_TASK_LABELS);
1660
+ }
1661
+ function installToastFinish(name, phase) {
1662
+ progressToastFinish(name, phase);
1528
1663
  }
1529
1664
 
1530
1665
  // 面板卸载后组件的 busy 会归零,但模块级的 installTasks 还在跑:按钮禁用要同时看它,
@@ -1983,12 +2118,17 @@ function renderReadmeBlocks(markdown, base) {
1983
2118
  const FAB_SIZE = 50;
1984
2119
  const FAB_EDGE = 22;
1985
2120
 
1986
- const MCP_CSS = `body{--mcp-cat-all:var(--dsw-alias-label-primary);--mcp-cat-uncat:var(--dsw-alias-label-primary);--mcp-cat-dev:var(--dsw-static-deepseek-500);--mcp-cat-design:var(--dsw-static-blue-400);--mcp-cat-office:var(--dsw-static-green-500);--mcp-cat-auto:var(--dsw-static-amber-500);--mcp-cat-ops:var(--dsw-static-neutral-bluish-500);--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(--dsw-alias-state-success-primary);--mcp-err:var(--dsw-alias-state-error-primary);--mcp-warn:var(--dsw-alias-state-warn-primary);--mcp-mut:var(--dsw-alias-label-secondary);--mcp-bg-1:var(--dsw-alias-bg-layer-1);--mcp-bg-2:var(--dsw-alias-bg-layer-2);--mcp-bg-3:var(--dsw-alias-bg-layer-3);--mcp-line:var(--dsw-alias-border-l2);--mcp-mono:var(--ds-font-family-code,ui-monospace,SFMono-Regular,Menlo,monospace);--mcp-r-xs:var(--dsw-radius-xs);--mcp-r-sm:var(--dsw-radius-sm);--mcp-r-md:var(--dsw-radius-md);--mcp-r-lg:var(--dsw-radius-lg);--mcp-r-xl:var(--dsw-radius-xl);--mcp-r-panel:var(--dsw-radius-panel)}
2121
+ const MCP_CSS = `body{--mcp-cat-all:var(--dsw-alias-label-primary);--mcp-cat-uncat:var(--dsw-alias-label-primary);--mcp-cat-dev:var(--dsw-static-deepseek-500);--mcp-cat-design:var(--dsw-static-blue-400);--mcp-cat-office:var(--dsw-static-green-500);--mcp-cat-auto:var(--dsw-static-amber-500);--mcp-cat-ops:var(--dsw-static-neutral-bluish-500);--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(--dsw-alias-state-success-primary);--mcp-err:var(--dsw-alias-state-error-primary);--mcp-warn:var(--dsw-alias-state-warn-primary);--mcp-mut:var(--dsw-alias-label-secondary);--mcp-bg-1:var(--dsw-alias-bg-layer-1);--mcp-bg-2:var(--dsw-alias-bg-layer-2);--mcp-bg-3:var(--dsw-alias-bg-layer-3);--mcp-line:var(--dsw-alias-border-l2);--mcp-mono:var(--ds-font-family-code,ui-monospace,SFMono-Regular,Menlo,monospace);--mcp-r-xs:var(--dsw-radius-xs);--mcp-r-sm:var(--dsw-radius-sm);--mcp-r-md:var(--dsw-radius-md);--mcp-r-lg:var(--dsw-radius-lg);--mcp-r-xl:var(--dsw-radius-xl);--mcp-r-panel:var(--dsw-radius-panel);--mcp-tip-bg:#17263fec;--mcp-tip-fg:#f4f7fd;--mcp-tip-line:#ffffff2e;--mcp-tip-err:#ff8a8a;--mcp-tip-ok:#7ee0a1;--mcp-tip-shadow:0 10px 28px rgba(0,0,0,.30)}
1987
2122
  /* Skill 看板升级 · 分类色(决策 10:只从宿主既有色相取,只用于色点与徽标)。
1988
2123
  暗色套与宿主 ui-theme 同构(body[data-ds-dark-theme],取证见进度文档 §七);
1989
2124
  分类色永远与文字标签成对出现,不单独用颜色传达信息。 */
1990
- body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-design:var(--dsw-static-blue-300);--mcp-cat-office:var(--dsw-static-green-400);--mcp-cat-auto:var(--dsw-static-amber-400);--mcp-cat-ops:var(--dsw-static-neutral-bluish-400)}
1991
- .dsh-mcp-wrap,.dsh-mcp-panel-overlay,.dsh-mcp-overlay,.dsh-mcp-fab,.dsh-mcp-footer-action,.dsh-mcp-toast{color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family,inherit)}
2125
+ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-design:var(--dsw-static-blue-300);--mcp-cat-office:var(--dsw-static-green-400);--mcp-cat-auto:var(--dsw-static-amber-400);--mcp-cat-ops:var(--dsw-static-neutral-bluish-400);--mcp-tip-bg:#0c1a34e6}
2126
+ /* 浮层提示(底部 toast / 顶部进度 pill)不跟页面主文字色:它们的底是插件自持的深色面
2127
+ (--mcp-tip-bg)。皮肤把 --dsw-alias-toast-bg 定成深蓝、把 --dsw-alias-label-primary 定成
2128
+ 近黑 Navy(whale-song 浅色皮 #1a3a7a / #0a1e4a,实测 1.49:1),两者一组就是「深蓝字压深蓝底」,
2129
+ 用户读不清。所以这里把浮层从 label-primary 的分组选择器里摘出来,只留字体族。 */
2130
+ .dsh-mcp-wrap,.dsh-mcp-panel-overlay,.dsh-mcp-overlay,.dsh-mcp-fab,.dsh-mcp-footer-action,.dsh-mcp-toast{font-family:var(--dsw-font-family,inherit)}
2131
+ .dsh-mcp-wrap,.dsh-mcp-panel-overlay,.dsh-mcp-overlay,.dsh-mcp-fab,.dsh-mcp-footer-action{color:var(--dsw-alias-label-primary)}
1992
2132
  .dsh-mcp-wrap{box-sizing:border-box;display:flex;flex-direction:column;height:100%;min-height:0;gap:14px;padding:4px 2px;font-size:13px;-webkit-font-smoothing:antialiased}
1993
2133
  .dsh-mcp-wrap ::-webkit-scrollbar{width:8px;height:8px}
1994
2134
  .dsh-mcp-wrap ::-webkit-scrollbar-thumb{background:var(--dsw-alias-scrollbar-bg-l2);border-radius:999px;corner-shape:round;border:2px solid transparent;background-clip:padding-box}
@@ -2244,9 +2384,9 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
2244
2384
  .dsh-mcp-import-group>b{flex:0 0 auto;color:var(--mcp-mut)}
2245
2385
  .dsh-mcp-import-group>b.warn{color:var(--mcp-warn)}
2246
2386
  .dsh-mcp-import-group-note{font-size:11px;line-height:1.5;color:var(--mcp-mut);overflow-wrap:anywhere;margin-top:2px}
2247
- .dsh-mcp-toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--dsw-alias-toast-bg);backdrop-filter:blur(10px);border:0;color:var(--dsw-alias-label-primary);padding:10px 18px;border-radius:var(--mcp-r-lg);font-size:12.5px;z-index:300;box-shadow:var(--dsw-shadow-lv3);max-width:70vw;animation:mcpPop .16s ease-out}
2248
- .dsh-mcp-toast.err{border:1px solid var(--dsw-alias-state-error-primary)}
2249
- .dsh-mcp-toast.ok{border:1px solid var(--dsw-alias-state-success-primary)}
2387
+ .dsh-mcp-toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--mcp-tip-bg);backdrop-filter:blur(10px);border:1px solid var(--mcp-tip-line);color:var(--mcp-tip-fg);padding:9px 16px;border-radius:var(--mcp-r-lg);font-size:12.5px;line-height:1.5;text-align:center;z-index:300;box-shadow:var(--mcp-tip-shadow);max-width:min(560px,calc(100vw - 32px));overflow-wrap:anywhere;animation:mcpPop .16s ease-out}
2388
+ .dsh-mcp-toast.err{border-color:var(--mcp-tip-err)}
2389
+ .dsh-mcp-toast.ok{border-color:var(--mcp-tip-ok)}
2250
2390
  .dsh-mcp-toggle{position:relative;width:34px;height:19px;box-sizing:border-box;border-radius:999px;border:0;cursor:pointer;background:var(--dsw-alias-border-l3);transition:background .18s,border-color .18s;flex:none;box-shadow:none;corner-shape:round}
2251
2391
  .dsh-mcp-toggle[aria-checked='true']{background:var(--mcp-accent);border-color:transparent}
2252
2392
  .dsh-mcp-toggle:disabled{opacity:.65;cursor:not-allowed}
@@ -2905,7 +3045,7 @@ body[data-ds-dark-theme]{--mcp-cat-dev:var(--dsw-static-deepseek-400);--mcp-cat-
2905
3045
  .dsh-mcp-seg-item{flex:none;white-space:nowrap;display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border:0;border-radius:var(--mcp-r-md);background:transparent;color:var(--dsw-alias-label-secondary);font-size:12px;font-family:inherit;cursor:pointer;transition:background 120ms ease,color 120ms ease}
2906
3046
  .dsh-mcp-seg-item:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
2907
3047
  .dsh-mcp-seg-item[aria-selected="true"]{background:var(--mcp-bg-1);color:var(--dsw-alias-label-primary);font-weight:600;box-shadow:0 1px 2px rgba(16,32,48,.08)}
2908
- .dsh-mcp-seg-count{font-size:10px;padding:0 4px;border-radius:var(--mcp-r-xs);background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary);line-height:14px;font-variant-numeric:tabular-nums}
3048
+ .dsh-mcp-seg-count{font-size:10px;padding:0 4px;border-radius:var(--mcp-r-xs);background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-secondary);line-height:14px;font-variant-numeric:tabular-nums;white-space:nowrap;flex:0 0 auto}
2909
3049
  .dsh-mcp-seg-item[aria-selected="true"] .dsh-mcp-seg-count{background:var(--mcp-accent);color:var(--dsw-alias-label-on-brand,#fff)}
2910
3050
  .dsh-mcp-seg-item:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:-1px}
2911
3051
  @media (prefers-reduced-motion:reduce){.dsh-mcp-seg-item{transition:none}}
@@ -10513,6 +10653,13 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
10513
10653
  // 「缓存结果」:断网 / 上游变慢时保留上次内存结果并如实标注,而不是清空列表。
10514
10654
  const [communityStale, setCommunityStale] = useState(false);
10515
10655
  const [communityOnlyRunnable, setCommunityOnlyRunnable] = useState(true);
10656
+ // 社区目录「全库总数」(连接器页徽标常驻显示的那个数)。口径与续爬策略见模块级
10657
+ // COMMUNITY_COUNT_KEY 一节:上游不给总数,只能有界地一段一段数,进度存本地。
10658
+ const [communityCount, setCommunityCount] = useState(readCommunityCount);
10659
+ const communityCountRef = useRef(communityCount);
10660
+ // 本次面板会话已花掉的统计页数(不是累计条数):预算用尽就不再占网络,下次开面板接着来。
10661
+ const communityCountPagesRef = useRef(0);
10662
+ const communityCountBusyRef = useRef(false);
10516
10663
  // 社区条目的「加入 MCP」草稿:预填 AddEditModal,用户确认后才写盘。
10517
10664
  const [communityDraft, setCommunityDraft] = useState(null);
10518
10665
  // 社区卡片详情:点整卡打开,交互与插件 README 弹层一致(遮罩 / Esc 关闭、焦点陷阱)。
@@ -10792,9 +10939,12 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
10792
10939
  const communityCursorsRef = useRef([]);
10793
10940
  const communityReadKeysRef = useRef(new Set());
10794
10941
  const communityReadQueryRef = useRef(null);
10942
+ // 后台统计要让路给列表请求:循环里读到的是最新值,所以每次渲染同步一次。
10943
+ const communityLoadingRef = useRef(false);
10795
10944
  // 每次渲染同步最新值:loadCommunity 不把 rows/query/已装列表塞进依赖——
10796
10945
  // 否则 MCP 列表一刷新就重建回调,让防抖 / 懒加载 effect 误触发第二次请求。
10797
10946
  communityRowsRef.current = communityRows;
10947
+ communityLoadingRef.current = communityLoading;
10798
10948
  communityQueryRef.current = communityQuery;
10799
10949
  communityNamesRef.current = mcpServerRows.map((row) => String(row?.serverName || '')).filter(Boolean);
10800
10950
 
@@ -10861,13 +11011,71 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
10861
11011
  loadCommunity({ cursor: '' });
10862
11012
  }, [connectorFeed, communityQuery, loadCommunity]);
10863
11013
 
11014
+ // ── 社区目录全库总数:有界、可续爬的后台统计 ──
11015
+ // 进连接器页 + 停在社区源时才开,而且先用 scheduleIdle 让列表请求走前面;然后一块一块数
11016
+ // (每块 3 页),本次会话最多 12 页 —— 全库实测 ≥13,000 条,一次爬完要十几分钟和约 9MB,
11017
+ // 不能占住用户的网络。每数完一块立刻落盘并更新徽标,所以用户看到的是数字在慢慢长准。
11018
+ // 上游失败就保住已数到的部分直接收工(不重试、不清进度),下次进面板从记录的游标接着来。
11019
+ useEffect(() => {
11020
+ if (tab !== 'connector' || connectorFeed !== 'community') return undefined;
11021
+ let cancelled = false;
11022
+ let pauseTimer = null;
11023
+ const runChunks = async () => {
11024
+ if (cancelled || communityCountBusyRef.current) return; // 同一面板里只允许一条统计流水
11025
+ communityCountBusyRef.current = true;
11026
+ try {
11027
+ while (!cancelled && communityCountPagesRef.current < COMMUNITY_COUNT_PAGE_BUDGET) {
11028
+ const record = communityCountRef.current || emptyCommunityCount();
11029
+ if (record.done) break;
11030
+ const res = await call('wbMcpCommunityCount', {
11031
+ cursor: record.cursor || '',
11032
+ pages: Math.min(COMMUNITY_COUNT_CHUNK_PAGES, COMMUNITY_COUNT_PAGE_BUDGET - communityCountPagesRef.current),
11033
+ });
11034
+ if (cancelled) break; // 面板已切走 / 卸载:结果不要,进度也不回滚
11035
+ if (!res || res.ok !== true) break;
11036
+ const done = res.done === true;
11037
+ const next = {
11038
+ counted: record.counted + Math.max(0, Number(res.counted) || 0),
11039
+ // 数到底就不留游标,避免下次又误以为"还有活要干"。
11040
+ cursor: done ? '' : String(res.nextCursor || record.cursor || ''),
11041
+ done,
11042
+ at: Date.now(),
11043
+ };
11044
+ communityCountPagesRef.current += Math.max(1, Number(res.pages) || 1);
11045
+ communityCountRef.current = next; // 不等 re-render:下一块必须用最新游标
11046
+ setCommunityCount(next);
11047
+ writeCommunityCount(next);
11048
+ if (done) break;
11049
+ // 列表请求在飞就多让一拍;没在飞也歇一下,别把公共 API 当自家目录连着打。
11050
+ await new Promise((resolve) => {
11051
+ pauseTimer = setTimeout(resolve, communityLoadingRef.current ? 1500 : 400);
11052
+ });
11053
+ }
11054
+ } catch { /* 徽标那枚数字而已:任何异常都不该把社区列表带崩 */ }
11055
+ finally { communityCountBusyRef.current = false; }
11056
+ };
11057
+ const cancelIdle = scheduleIdle(runChunks, 3000);
11058
+ return () => { cancelled = true; if (pauseTimer) clearTimeout(pauseTimer); cancelIdle(); };
11059
+ }, [tab, connectorFeed, call]);
11060
+
10864
11061
  const communityVisibleRows = communityOnlyRunnable ? communityRows.filter((row) => row.runnable) : communityRows;
10865
11062
  const communityHiddenCount = communityRows.length - communityVisibleRows.length;
10866
11063
  // 上一页的游标来自游标栈(第 N-1 页的入口游标);第 1 页没有上一页。
10867
11064
  const communityPrevCursor = communityPage > 1 ? (communityCursorsRef.current[communityPage - 2] || '') : null;
10868
11065
  // 「已加入」只按 serverName 命中现有 MCP 列表:社区条目没有 WorkBuddy 代注入,
10869
11066
  // 不存在「已安装 · 需授权」这种工作流。
10870
- const communityJoined = (row) => mcpServerRows.some((server) => String(server?.serverName || '') === row.serverName);
11067
+ // 「已加入」比的是**原始名**,不是带冲突后缀的预填名(baseServerName,见 lib/mcp-community.js)。
11068
+ // 用 serverName 比是死锁:装完 adeu 之后 taken 里就有 adeu,下一轮列表把这条预填成 adeu-2,
11069
+ // adeu-2 永远不等于任何已装名 → 徽标与按钮禁用一起失效,用户以为没装、再点一次装出第二条。
11070
+ const communityJoined = (row) => {
11071
+ const base = String(row?.baseServerName || row?.serverName || '');
11072
+ const prefill = String(row?.serverName || '');
11073
+ if (!base) return false;
11074
+ return mcpServerRows.some((server) => {
11075
+ const installed = String(server?.serverName || '');
11076
+ return installed === base || installed === prefill;
11077
+ });
11078
+ };
10871
11079
 
10872
11080
  // 「加入 MCP」= 预填草稿,用户在弹层里确认后才由宿主写盘(与连接器一键安装不同语义)。
10873
11081
  const openCommunityDraft = (row) => {
@@ -10886,19 +11094,26 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
10886
11094
  };
10887
11095
  const submitCommunityDraft = async (spec, setFormError) => {
10888
11096
  setBusy(true);
11097
+ // 进度浮层:写盘 + 宿主自动连接这两段都在 RPC 之后才发生,弹层一关就没了现场。
11098
+ // key 带动作前缀,重连同一台服务器不会顶掉这条「加入」行(也不是插件名,不参与按钮禁用)。
11099
+ const taskKey = spec?.name ? 'mcp:add:' + String(spec.name) : '';
11100
+ progressToastStart(taskKey, spec?.name, PROGRESS_GROUP_MCP, MCP_ADD_TASK_LABELS);
10889
11101
  try {
10890
11102
  const res = await call('add', spec);
10891
11103
  if (res && res.ok) {
10892
11104
  setCommunityDraft(null);
10893
11105
  setNote(t('已加入 ') + spec.name);
11106
+ progressToastFinish(taskKey, 'ok');
10894
11107
  // 宿主写盘后列表刷新有一拍延迟:先立即刷一次,再补一次让「已加入」稳定亮起。
10895
11108
  loadMcpServerRows();
10896
11109
  communityDraftRefreshRef.current = setTimeout(() => { loadMcpServerRows(); }, 1200);
10897
11110
  } else {
10898
11111
  setFormError((res && res.error) || t('添加失败'));
11112
+ progressToastFinish(taskKey, 'fail');
10899
11113
  }
10900
11114
  } catch (e) {
10901
11115
  setFormError(String(e?.message || e));
11116
+ progressToastFinish(taskKey, 'fail');
10902
11117
  } finally {
10903
11118
  setBusy(false);
10904
11119
  }
@@ -10974,24 +11189,36 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
10974
11189
  const authRow = rows.find((row) => row.authRequired);
10975
11190
  if (authRow) { await beginConnectorAuth(item, authRow.serverName); return; }
10976
11191
  setBusy(true); setBusyLabel(t('正在重连…')); setError('');
11192
+ // 与「加入 MCP」同一枚进度浮层(顶部、跨面板卸载存活):重连是逐台串行的,
11193
+ // 一台一行,用户切走再回来还能看到到底哪台在跑、哪台没成。
11194
+ const serverNames = Array.isArray(item.serverNames) ? item.serverNames : [];
11195
+ const taskKeys = new Map();
11196
+ for (const name of serverNames) {
11197
+ const key = 'mcp:reconnect:' + String(name);
11198
+ taskKeys.set(String(name), key);
11199
+ progressToastStart(key, String(name), PROGRESS_GROUP_MCP, MCP_RECONNECT_TASK_LABELS);
11200
+ }
10977
11201
  try {
10978
11202
  let lastNote = '';
10979
11203
  const failed = [];
10980
- for (const name of (Array.isArray(item.serverNames) ? item.serverNames : [])) {
11204
+ for (const name of serverNames) {
10981
11205
  const res = await call('reconnect', String(name));
10982
11206
  // call() 把宿主失败收敛成 {ok:false, error},这里必须看 ok,否则一条都没连上也会
10983
11207
  // 显示「已请求重连」。
10984
11208
  if (res && res.ok === false) {
10985
11209
  failed.push(name + ': ' + (res.error || t('调用失败')));
11210
+ progressToastFinish(taskKeys.get(String(name)), 'fail');
10986
11211
  continue;
10987
11212
  }
10988
11213
  lastNote = res?.note || lastNote;
11214
+ progressToastFinish(taskKeys.get(String(name)), 'ok');
10989
11215
  }
10990
11216
  if (failed.length) setError(t('{0}:{1}', [item.name, failed.join('; ')]));
10991
11217
  else setNote(t('{0}:{1}', [item.name, lastNote || t('已请求重连')]));
10992
11218
  await loadMcpServerRows();
10993
11219
  } catch (e) {
10994
11220
  setError(String(e?.message || e));
11221
+ for (const key of taskKeys.values()) progressToastFinish(key, 'fail');
10995
11222
  } finally {
10996
11223
  setBusy(false); setBusyLabel('');
10997
11224
  }
@@ -11770,7 +11997,12 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
11770
11997
  onClick: () => setConnectorFeed('wb'),
11771
11998
  },
11772
11999
  t('WorkBuddy 连接器'),
11773
- connectors.length ? h('span', { className: 'dsh-mcp-seg-count' }, connectors.length) : null,
12000
+ // 徽标常驻:数字一隐藏就会被读成"这一页就是全部"。连接器是一次返回全量,
12001
+ // 所以这个数就是总数;没取到之前显示「…」而不是空着。
12002
+ h('span', {
12003
+ className: 'dsh-mcp-seg-count',
12004
+ title: connectors.length ? t('WorkBuddy 官方目录里的连接器总数') : t('正在读取连接器列表…'),
12005
+ }, connectors.length || '…'),
11774
12006
  ),
11775
12007
  h('button', {
11776
12008
  type: 'button', role: 'tab', 'aria-selected': connectorFeed === 'community', className: 'dsh-mcp-seg-item',
@@ -11778,7 +12010,10 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
11778
12010
  onClick: () => setConnectorFeed('community'),
11779
12011
  },
11780
12012
  t('社区 MCP'),
11781
- communityReadTotal ? h('span', { className: 'dsh-mcp-seg-count' }, communityReadTotal) : null,
12013
+ // 这里显示的是**全库总数**(数到底)或**下界**(还在续爬,形如 1200+),
12014
+ // 不再是「已读取 98 条」——那个数会被读成"社区一共只有 98 个"(2026-10-30 用户
12015
+ // 反馈)。查询内的已读累计仍在下面那行工具条里,两个数各有各的口径。
12016
+ h('span', { className: 'dsh-mcp-seg-count', title: communityCountBadgeTitle(communityCount) }, communityCountBadgeText(communityCount)),
11782
12017
  ),
11783
12018
  ),
11784
12019
  )
@@ -11803,7 +12038,7 @@ const WorkBuddyMarket = ({ call, pluginManager }) => {
11803
12038
  h('input', { type: 'checkbox', checked: communityOnlyRunnable, onChange: (e) => setCommunityOnlyRunnable(e.target.checked) }),
11804
12039
  t('只看可直接运行(npx / uvx)'),
11805
12040
  ),
11806
- h('span', { className: 'dsh-mcp-builtin-count' },
12041
+ h('span', { className: 'dsh-mcp-builtin-count', title: t('本次查询已逐页读取的条数;上方徽标显示的是社区目录全库总数') },
11807
12042
  t('已读取 {0} 条', [communityReadTotal]) + (communityStale ? t(' · 缓存结果') : '')),
11808
12043
  communityOnlyRunnable && communityHiddenCount > 0
11809
12044
  ? h('span', { className: 'dsh-mcp-builtin-count', title: t('这些条目只有远程地址,需要自备令牌') },
@@ -14000,6 +14235,9 @@ const MCP_CONTRIBUTION = {
14000
14235
  // 社区 MCP(官方 MCP Registry v0,只读):连接器页第二个来源。payload {search, cursor,
14001
14236
  // limit, takenNames},返回 {ok, rows, nextCursor, fetchedAt, cached}。三处同步,漏一处恒 404。
14002
14237
  { id: 'dsh-armory#mcpManager/wbMcpCommunityList', service: 'mcpManager', namespace: 'mcpManager', method: 'wbMcpCommunityList', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbMcpCommunityResult') },
14238
+ // 社区目录全库总数的有界统计(面板徽标常驻显示用)。payload {cursor, pages},
14239
+ // 返回 {ok, counted, nextCursor, done, pages}。漏这一行 = 面板调它恒 404。
14240
+ { id: 'dsh-armory#mcpManager/wbMcpCommunityCount', service: 'mcpManager', namespace: 'mcpManager', method: 'wbMcpCommunityCount', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbMcpCommunityCountResult') },
14003
14241
  // 社区元信息(仓库维护信号):payload {repos, refresh},返回 {repos:{repo:{...}}}。
14004
14242
  { id: 'dsh-armory#mcpManager/wbRepoMeta', service: 'mcpManager', namespace: 'mcpManager', method: 'wbRepoMeta', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('WbRepoMetaResult') },
14005
14243
  // npm 近 30 天下载量:payload {packages, refresh},返回 {packages:{pkg:{ok,downloads,...}}}。
package/lib/index.js CHANGED
@@ -8,6 +8,7 @@ import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
8
8
  import { basename, dirname, isAbsolute, join } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { normalizeMcpImport, readManagedMcpServers, setManagedMcpDisabled, updateManagedMcpPatch } from "./mcp-config.js";
11
+ import { describeLaunchRewrite } from "./win32-stdio.js";
11
12
  import { admitImportedServers } from "./import-admission.js";
12
13
  import { collectImportLocations, missingEnvNames, resolveImportLocation } from "./mcp-import-sources.js";
13
14
  import { deriveMcpPhase, formatMcpLog, sanitizeMcpLog } from "./mcp-observability.js";
@@ -29,7 +30,7 @@ import { readRegistry, upsertMarketplace } from "./wb-registry.js";
29
30
  // T06/Awesome:DSH 社区插件市场(awesome-dsh-plugins)的宿主侧数据层。
30
31
  import { wbAwesomeCatalog, wbAwesomeDetail, wbAwesomeReadme, wbAwesomeUpdateCheck } from "./wb-awesome-market.js";
31
32
  // 社区 MCP:官方 MCP Registry(registry.modelcontextprotocol.io)只读取数,数据层在 lib/mcp-community.js。
32
- import { wbMcpCommunityList } from "./mcp-community.js";
33
+ import { wbMcpCommunityList, wbMcpCommunityCount } from "./mcp-community.js";
33
34
  // 社区元信息(维护者 / 最近提交 / issue / star):打开详情时按需查,6 小时缓存。
34
35
  import { wbRepoMeta } from "./community-meta.js";
35
36
  // npm 近 30 天下载量:插件卡片底栏的「↓N」,目录里没有这个字段,只能去 npm 统计接口取。
@@ -1515,7 +1516,7 @@ let McpManagerGateway = (() => {
1515
1516
  const state = await managedState(this.ctx);
1516
1517
  if (hasExternalMcpName(this.ctx, state, normalized.name) || state.byName.has(normalized.name)) throw new Error("MCP already exists: " + normalized.name);
1517
1518
  await writePatch(state, updateManagedMcpPatch(state.content, [normalized], { replace: false, reservedIds: reservedEntryIds(this.ctx) }));
1518
- return { note: "已添加到 profile patch,正在热加载" };
1519
+ return { note: "已添加到 profile patch,正在热加载" + describeLaunchRewrite(spec?.command, normalized.command) };
1519
1520
  });
1520
1521
  }
1521
1522
  async update(spec) {
@@ -1527,7 +1528,7 @@ let McpManagerGateway = (() => {
1527
1528
  const normalized = normalizeOne(restoreRedactedSpec(spec, current));
1528
1529
  if (!Object.hasOwn(normalized, "disabled") && current.disabled) normalized.disabled = true;
1529
1530
  await writePatch(state, updateManagedMcpPatch(state.content, [normalized], { replace: false, reservedIds: reservedEntryIds(this.ctx, new Set([state.entryIds.get(normalized.name)].filter(Boolean))) }));
1530
- return { note: "已更新 profile patch,正在热加载" };
1531
+ return { note: "已更新 profile patch,正在热加载" + describeLaunchRewrite(spec?.command, normalized.command) };
1531
1532
  });
1532
1533
  }
1533
1534
  async removeServer(name) {
@@ -2306,6 +2307,12 @@ let McpManagerGateway = (() => {
2306
2307
  async wbMcpCommunityList(payload) {
2307
2308
  return wbMcpCommunityList(payload || {});
2308
2309
  }
2310
+ // 社区目录全库总数的有界统计:payload {cursor, pages},返回 {ok, counted, nextCursor, done, pages}。
2311
+ // 官方 Registry 不给总数,只能顺游标数,所以这里一次最多 12 页;进度由面板侧存 localStorage、
2312
+ // 跨会话续爬。同样只 GET:不写盘、不执行命令、不落凭据、不进任何缓存。
2313
+ async wbMcpCommunityCount(payload) {
2314
+ return wbMcpCommunityCount(payload || {});
2315
+ }
2309
2316
  // ── 社区元信息:仓库「人味」信号(维护者 / 最近提交 / issue / star)────────
2310
2317
  // 详情弹层按需调用,只读公开接口,6 小时缓存;不接收也不保存任何凭据。
2311
2318
  async wbRepoMeta(payload) {
@@ -8,6 +8,9 @@
8
8
  // 为什么不做全量快照:实测 60 页 × 100 = 6000 条仍未翻完、逐页约 2.4s。社区列表按
9
9
  // 「首屏 + 搜索 + 游标翻页」按需取,与 SkillHub 的远程分页同构;数据层只负责一次
10
10
  // 游标请求的映射,累计条数上限由面板侧控制。
11
+ // 2026-10-30 复测:全库至少 13,000 条(130 页 × 100 仍未到底,本机串行 2.4–8s/页、
12
+ // 约 9MB),所以「一次爬完做快照」依然不成立;总数只能由 `wbMcpCommunityCount`
13
+ // 做**有界、可续爬**的分片统计(见该函数注释)。
11
14
  //
12
15
  // 安全底线:只 GET;不探测 npm 包、不跑 npx/uvx;env 的 default 原样展示、绝不落盘。
13
16
 
@@ -177,6 +180,10 @@ export function mapRegistryServer(entry, options = {}) {
177
180
  runnable,
178
181
  transport: runnable ? 'stdio' : 'http',
179
182
  serverName: sanitizeServerName(name, takenNames),
183
+ // 冲突后缀之前的原名,专门给「已加入」判定用。serverName 带后缀(-2、-3)是「预填什么名字」,
184
+ // 而「这条是不是已经装了」比较的是原始名:装完 adeu 之后 taken 里就有 adeu,下一次列表里
185
+ // serverName 会变成 adeu-2,拿它去比已装名永远不相等——「已加入」因此永远点不亮。
186
+ baseServerName: sanitizeServerName(name, []),
180
187
  command: runnable ? command : '',
181
188
  args: runnable ? args : [],
182
189
  env,
@@ -351,3 +358,82 @@ export async function wbMcpCommunityList(payload = {}, options = {}) {
351
358
  writeCache(key, value);
352
359
  return { ok: true, rows, nextCursor, fetchedAt: value.fetchedAt, cached: false };
353
360
  }
361
+
362
+ /** 单次统计请求最多翻几页(3 页 = 300 条)。面板侧还会再套一层会话预算。 */
363
+ export const COMMUNITY_COUNT_CHUNK_PAGES = 3;
364
+ /** 一次统计调用允许的页数上限(12 页 = 1200 条):防止 payload 被塞一个巨大值把上游打穿。 */
365
+ export const COMMUNITY_COUNT_MAX_PAGES = 12;
366
+
367
+ /** 页数钳制:非法 / < 1 → 默认 3 页,> 上限 → 上限。 */
368
+ function clampCountPages(value) {
369
+ const number = Number(value);
370
+ if (!Number.isFinite(number) || number < 1) return COMMUNITY_COUNT_CHUNK_PAGES;
371
+ return Math.min(COMMUNITY_COUNT_MAX_PAGES, Math.floor(number));
372
+ }
373
+
374
+ /**
375
+ * 社区目录「全库总数」的**有界**统计:从给定游标起顺着翻页,最多翻 `pages` 页,
376
+ * 返回这一段数到的条目数与下一步该从哪儿接着数。
377
+ *
378
+ * 为什么要有这个函数:官方 Registry 不返回总数(实测 `metadata.count` 就是本页条数、
379
+ * `/v0/servers/count` 与 `/v0/registry/stats` 均 404、`limit>100` 直接 422),而面板
380
+ * 徽标只显示「已读取 98 条」会被用户读成"社区一共只有 98 个"(2026-10-30 用户反馈)。
381
+ * 唯一的真数只能靠顺游标爬,所以做成**可续爬的分片统计**:每次只花几页的预算,
382
+ * 进度由面板侧持久化,跨会话继续,爬到尽头就给精确值、没到尽头就给下界(`N+`)。
383
+ *
384
+ * 口径与列表严格一致:走同一个 `buildRegistryUrl`(含 `version=latest`)与同一个
385
+ * `mapRegistryServer`(非 active / 既跑不起来又没合法远程地址 / oci 包都会被丢弃),
386
+ * 所以这个数就是"用户翻页能翻到的条目数",不是上游数据库行数。
387
+ *
388
+ * 刻意**不写内存缓存**:一次全库要 130+ 页,塞进 50 条上限的翻页缓存会把用户真正
389
+ * 在看的那几页挤掉,反而拖慢列表。
390
+ *
391
+ * 跨块不去重(游标链本身互斥,上游若真返回重叠页,最坏是把下界算高一点点);
392
+ * 单块内按 `key` 去重,与面板侧「已读取」累计口径同源。
393
+ *
394
+ * 与 `wbMcpCommunityList` 同纪律:永不抛错,失败收敛成 `{ok:false, error}`,
395
+ * 并把已经数到的部分如实带回去(中断不该把前面的工作清零)。
396
+ *
397
+ * @param {{cursor?: string, pages?: number}} payload
398
+ * @param {{fetchImpl?: Function, timeoutMs?: number}} options
399
+ * @returns {Promise<{ok:true, counted:number, nextCursor:string|null, done:boolean, pages:number}
400
+ * | {ok:false, error:string, counted:number, nextCursor:string|null, done:false, pages:number}>}
401
+ */
402
+ export async function wbMcpCommunityCount(payload = {}, options = {}) {
403
+ const input = payload && typeof payload === 'object' ? payload : {};
404
+ const budget = clampCountPages(input.pages);
405
+ let cursor = asString(input.cursor).trim();
406
+ let counted = 0;
407
+ let walked = 0;
408
+ const seen = new Set();
409
+ for (let round = 0; round < budget; round += 1) {
410
+ let raw;
411
+ try {
412
+ raw = await fetchJson(buildRegistryUrl({ cursor, limit: COMMUNITY_PAGE_LIMIT }), options);
413
+ } catch (cause) {
414
+ return {
415
+ ok: false,
416
+ error: String(cause?.message || cause),
417
+ counted,
418
+ nextCursor: cursor || null,
419
+ done: false,
420
+ pages: walked,
421
+ };
422
+ }
423
+ const { servers, nextCursor } = normalizeRegistryResponse(raw);
424
+ walked += 1;
425
+ for (const entry of servers) {
426
+ const row = mapRegistryServer(entry, {});
427
+ if (!row || !row.key || seen.has(row.key)) continue;
428
+ seen.add(row.key);
429
+ counted += 1;
430
+ }
431
+ // 两种「到底」都要认:没游标了,或上游给了游标但这一页是空的——只认前一种的话,
432
+ // 遇到「空页 + 残留游标」的实现会在续爬时原地打转。
433
+ if (!servers.length || !nextCursor) {
434
+ return { ok: true, counted, nextCursor: null, done: true, pages: walked };
435
+ }
436
+ cursor = nextCursor;
437
+ }
438
+ return { ok: true, counted, nextCursor: cursor || null, done: false, pages: walked };
439
+ }
package/lib/mcp-config.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { parseDocument, Scalar, YAMLMap, YAMLSeq, isMap, isSeq } from 'yaml'
2
2
  import { isSafeExpression, makeEnvExpression, makeExpression } from './env-expression.js'
3
+ import { isCmdShellCommand, planStdioLaunch, win32LaunchContext } from './win32-stdio.js'
3
4
 
4
5
  export const MCP_PLUGIN_NAME = '@deepseek-ai/dsh-mcp-client'
5
6
  const JS_TAG = 'tag:yaml.org,2002:js'
@@ -183,6 +184,20 @@ function normalizeEntry(name, input, warnings) {
183
184
  if (typeof raw.cwd !== 'string') throw new Error(`${serverName}.cwd 必须是字符串`)
184
185
  if (raw.cwd) result.cwd = expressionValue(raw.cwd)
185
186
  }
187
+ // win32 上裸 npx / npm 是 .cmd 壳,宿主 `spawn(command, args, {shell:false})` 直接 ENOENT,
188
+ // 子进程起不来 → 面板只剩「SdkError: Connection closed」。写入前归一成绝对 node.exe + 入口 JS;
189
+ // 非 win32、绝对路径、${VAR} / !!js 表达式一律原样放行(判据与实测见 lib/win32-stdio.js)。
190
+ const launchContext = win32LaunchContext()
191
+ const launch = planStdioLaunch(result, launchContext)
192
+ if (launch.rewritten) {
193
+ warnings.push(`${serverName}:Windows 上裸 ${result.command} 无法 spawn,已改用本机 Node 启动器`)
194
+ result.command = launch.spec.command
195
+ result.args = launch.spec.args
196
+ if (launch.spec.env !== undefined) result.env = launch.spec.env
197
+ } else if (launchContext.probeError && isCmdShellCommand(result.command)) {
198
+ // 探测层异常时如实说明「本该归一但没归」,别让条目带着裸 npx 落盘后只剩一句连接失败。
199
+ warnings.push(`${serverName}:本机 Node 启动器探测失败(${launchContext.probeError}),命令已按原样写入,Windows 上可能无法启动`)
200
+ }
186
201
  } else {
187
202
  const normalizedUrl = hasUrl ? expressionValue(url.trim()) : ''
188
203
  if (!hasUrl) throw new Error(`${serverName} 的 HTTP 配置需要 http(s):// URL`)
package/lib/typert.js CHANGED
@@ -122,6 +122,9 @@ export const TYPERT = {
122
122
  // 社区 MCP:官方 MCP Registry v0 只读列表(数据层 lib/mcp-community.js)。
123
123
  // 与上面 wb* 系列同一宽容度:payload 形状由数据层校验,契约层 $any。
124
124
  mv('wbMcpCommunityList', [payloadParam], 'WbMcpCommunityResult'),
125
+ // 社区目录全库总数(有界、可续爬的分片统计):Registry 不返回总数,面板徽标要常驻
126
+ // 显示"到底有多少条"只能顺游标数。payload $any({cursor, pages}),结果 {counted, nextCursor, done}。
127
+ mv('wbMcpCommunityCount', [payloadParam], 'WbMcpCommunityCountResult'),
125
128
  mv('wbAwesomeDetail', [payloadParam], 'AwesomeDetailResult'),
126
129
  // README 预览:点击卡片按需取一次(npm registry / GitHub raw),落盘缓存 7 天。
127
130
  mv('wbAwesomeReadme', [payloadParam], 'AwesomeReadmeResult'),
@@ -502,6 +502,24 @@ export const WHATS_NEW_EN = {
502
502
  where: 'Market / Security',
503
503
  },
504
504
  ],
505
+ '2.4.5': [
506
+ {
507
+ text: '**The Community MCP badge now shows the size of the whole catalog, and stays on screen**: it used to show how many rows this query had read, which reads as "this is all there is". The official Registry returns no total, so the panel counts the catalog page by page in the background (up to 12 pages per session, progress kept locally so the next visit resumes): an exact number once the crawl finishes, a "N+" lower bound while it is still running, and "..." before counting starts — the badge never disappears again. The tooltip says which of the two you are looking at, and the toolbar keeps showing the rows read by the current query',
508
+ where: 'Market -> Connectors -> Community MCP',
509
+ },
510
+ {
511
+ text: '**MCP servers added from the market or the community now start on Windows**: the host spawns stdio servers without a shell, but `npx` / `npm` on Windows are `.cmd` wrappers, so a bare spawn dies with `ENOENT` in milliseconds and the panel is left with "Connection closed" until the reconnect budget runs out. The single write boundary now rewrites a bare `npx` / `npm` into the absolute `node.exe` on this machine plus the CLI entry script, and injects a writable npm cache directory (an existing `npm_config_cache` setting is never overwritten, in any casing). Non-Windows, absolute paths, `uvx`, `${VAR}` and `!!js` are passed through untouched, saving twice is idempotent, and every rewrite is reported in the install notice. Know the trade-off: the rewritten command is specific to this machine, so moving the config to another machine means adding the connector again',
512
+ where: 'Market -> Connectors -> Add MCP',
513
+ },
514
+ {
515
+ text: '**Community cards now mark what you already added**: the list name carries a collision suffix (installing a second entry with the same id shows up as `xxx-2`), so comparing it against the installed command never matched — the badge stayed dark, the button stayed enabled, and clicking again silently created a duplicate. The comparison now uses the name with the suffix removed',
516
+ where: 'Market -> Connectors -> Community MCP',
517
+ },
518
+ {
519
+ text: '**Lightweight feedback when installing or reconnecting, and the dark-blue notice is finally readable**: adding or reconnecting a community MCP opens a small loading toast, and progress and result now travel through the same path. The old notice paired dark text with a deep blue background at a measured 1.49:1 contrast (WCAG AA asks 4.5:1 for body text); it is now 11.6:1 on light and 16.1:1 on dark, with both palettes carried by the overlay itself instead of drifting with the host background',
520
+ where: 'Market -> Connectors / Skills',
521
+ },
522
+ ],
505
523
  };
506
524
 
507
525
  /**
package/lib/whats-new.js CHANGED
@@ -579,4 +579,25 @@ export const WHATS_NEW = [
579
579
  },
580
580
  ],
581
581
  },
582
+ {
583
+ version: '2.4.5',
584
+ items: [
585
+ {
586
+ text: '**社区 MCP 角标改成「全库总数」并常驻**:角标原本显示本次已读取的条数,很容易被读成「社区目录一共只有这么多」。官方 Registry 不返回总数,插件改为在后台逐页统计全库条目(每次会话最多 12 页,进度记在本机、下次打开接着数):数到底显示精确值,没到底显示「N+」下界,还没开始数显示「…」,角标不再随列表消失;悬浮说明写清当前是精确值还是下界,工具条继续显示本次查询的「已读取 N 条」',
587
+ where: '市场 → 连接器 → 社区 MCP',
588
+ },
589
+ {
590
+ text: '**Windows 上从市场 / 社区加入的 MCP 不再必然连不上**:宿主起 stdio 服务时不走 shell,而 `npx` / `npm` 在 Windows 上是 `.cmd` 壳——直接 spawn 必然 `ENOENT` 秒退,面板只剩「连接已关闭」,重连次数耗尽后永久失败。写入配置的唯一边界现在把裸 `npx` / `npm` 归成本机绝对路径的 `node.exe` + 入口 JS,并注入一个可写的 npm 缓存目录(你配过 `npm_config_cache` 就不动,任意大小写都认);非 Windows、已是绝对路径、`uvx`、`${VAR}`、`!!js` 原样放行,重复保存幂等,改写内容会如实写进安装提示。副作用要清楚:归一后的命令是本机专属路径,换机导入需重新「加入 MCP」',
591
+ where: '市场 → 连接器 → 加入 MCP',
592
+ },
593
+ {
594
+ text: '**社区条目的「已加入」会亮了**:列表名带着防撞名的后缀(同名再装会显示成 `xxx-2`),拿它比已装命令名永远不相等——徽标不亮、按钮不禁用、再点一次装出第二条。现在按去掉后缀的原始名比较',
595
+ where: '市场 → 连接器 → 社区 MCP',
596
+ },
597
+ {
598
+ text: '**安装与重连有轻提示,深蓝提示窗文字改到能读**:加入 / 重连社区 MCP 时弹出小 loading 浮层,进度与结果走同一条链;原先深蓝底配深色文字实测对比度只有 1.49:1(WCAG AA 要求正文 4.5:1),现在浅底 11.6:1、深底 16.1:1,深浅两套由浮层自带配色决定,不再随宿主背景漂移',
599
+ where: '市场 → 连接器 / 技能',
600
+ },
601
+ ],
602
+ },
582
603
  ]
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Windows 下 stdio MCP 的「可执行化」——把裸 `npx` 换成真正能 spawn 的形式。
3
+ *
4
+ * 为什么需要这一层(2026-10-30 本机实测,不是推测):
5
+ * 宿主 `@deepseek-ai/dsh-mcp-client` 用 `child_process.spawn(command, args, { shell: false })`
6
+ * 拉起 stdio 子进程。win32 上 Node 不做 PATHEXT 解析,而 npm 的 `npx` 实际是 `npx.cmd`
7
+ * (批处理壳,必须经 cmd.exe 才有解释器):
8
+ * - spawn('npx', …, {shell:false}) → ENOENT,实测 6ms 内子进程根本没起来
9
+ * - spawn('npx.cmd', …, {shell:false}) → EINVAL(Node ≥ 24 明确拒绝直接 spawn .cmd)
10
+ * 子进程起不来 → stdin/stdout 管道立刻关闭 → SDK 侧只剩一句
11
+ * `SdkError: Connection closed`,再按 `reconnect.maxAttempts: 10` 重试到耗尽,面板永久「连接失败」。
12
+ * 官方 MCP Registry 的 npm 条目给出的命令恰恰就是裸 `npx`,所以 Windows 上「社区 MCP 选哪一条都连不上」
13
+ * 与条目本身无关,是命令形态问题。同机唯一两条能连上的(mcp-windows / mcp-rea)都是绝对 `.exe`——
14
+ * CreateProcess 对无扩展名的命令只补 `.exe`,所以裸 `node` / `python` 反而没事,出事的是 `.cmd` 壳。
15
+ *
16
+ * 这一层只改**命令形态**,不改语义:`npx -y pkg` → `node.exe npx-cli.js -y pkg`,
17
+ * 参数顺序、包名、附加参数一律原样保留;非 win32、绝对路径、`${VAR}` / `!!js` 表达式全部放行不改。
18
+ * 改写是幂等的:改写后的绝对 node.exe 再进这里不会被二次包装,读回配置也不会漂移。
19
+ *
20
+ * 顺带处理第二层拦路石(同样实测):本机全局 npm 缓存目录不可写时,`npx -y <pkg>` 会在
21
+ * 下载阶段 `EPERM` 失败——spawn 已经成功,但包拿不下来。所以为改写过的条目注入一个
22
+ * 属于军械库的可写缓存目录;用户自己配过缓存键时绝不覆盖。
23
+ *
24
+ * 边界:`uvx` / `uv` 在 Windows 上是真 `.exe`(astral 直接发二进制),不属于 `.cmd` 壳问题,
25
+ * 本机又未安装 uv,无法实测 → 本层不动它,避免把没验证过的路径写成规则。
26
+ */
27
+ import { existsSync, mkdirSync, statSync } from 'node:fs';
28
+ import { win32 as winpath } from 'node:path';
29
+ import { homedir } from 'node:os';
30
+
31
+ /** 会被 npm 装成 `.cmd` 壳、因而 spawn 不了的命令名。 */
32
+ const CMD_SHELL_COMMANDS = new Set(['npx', 'npm']);
33
+ /** `.cmd` 壳 → 同目录 npm 包里的真正入口 JS。 */
34
+ const LAUNCHER_ENTRY = { npx: 'npx-cli.js', npm: 'npm-cli.js' };
35
+ /** 交给宿主求值的表达式命令:形态不是我们能静态决定的,禁止改写。 */
36
+ const EXPRESSION_RE = /^\s*(?:!!js\b|\$\{)/;
37
+ /** 绝对路径 / UNC / 带引号的路径:本来就可 spawn。 */
38
+ const ABSOLUTE_RE = /^\s*"?\s*(?:[A-Za-z]:[\\/]|\\\\|\/)/;
39
+
40
+ const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
41
+
42
+ /** 取命令尾段并小写:`C:\a\b\npx.cmd` 与 `npx` 都归到 `npx`。 */
43
+ export function commandBase(command) {
44
+ if (typeof command !== 'string') return '';
45
+ const text = command.trim().replace(/^"|"$/g, '');
46
+ const segments = text.split(/[\\/]/).filter(Boolean);
47
+ const tail = (segments.length ? segments[segments.length - 1] : text).toLowerCase();
48
+ return tail.replace(/\.exe$/, '').replace(/\.cmd$/, '');
49
+ }
50
+
51
+ /** 这条命令是不是 npm 装出来的 `.cmd` 壳(探测失败时要不要提醒,就按这个判)。 */
52
+ export function isCmdShellCommand(command) {
53
+ return CMD_SHELL_COMMANDS.has(commandBase(command));
54
+ }
55
+
56
+ /**
57
+ * 纯函数:按注入的上下文决定这条 stdio spec 该怎么写。
58
+ * 不做任何 fs / process 访问,便于零成本单测与跨平台运行。
59
+ *
60
+ * @param {{command:string,args?:string[],env?:object}} spec 已归一化的 stdio spec(就地不改,返回新对象)
61
+ * @param {{applicable?:boolean,node?:{exe:string,npxCli:string}|null,cacheDir?:string|null}} context
62
+ * @returns {{spec:object, rewritten:boolean, reason:string}}
63
+ * reason: 'not-win32' | 'not-stdio-command' | 'expression' | 'already-normalized'
64
+ * | 'already-absolute' | 'unsupported-command' | 'node-not-found' | 'rewritten'
65
+ */
66
+ export function planStdioLaunch(spec, context = {}) {
67
+ if (!isObject(spec) || typeof spec.command !== 'string' || !spec.command.trim()) {
68
+ return { spec, rewritten: false, reason: 'not-stdio-command' };
69
+ }
70
+ if (context.applicable !== true) return { spec, rewritten: false, reason: 'not-win32' };
71
+ // 表达式(`${NODE_BIN}`、`!!js …`)由宿主在挂载时求值,形态我们无法静态判定 → 放行。
72
+ if (EXPRESSION_RE.test(spec.command)) return { spec, rewritten: false, reason: 'expression' };
73
+ const inArgs = Array.isArray(spec.args) ? spec.args : [];
74
+ // 幂等护栏:已经是「node.exe + …/npx-cli.js」或「裸 node + …/npx-cli.js」形态的,不再包第二层
75
+ // (读回配置、反复保存、项目 MCP 重挂都会重新走一遍这里)。
76
+ if ((commandBase(spec.command) === 'node' || ABSOLUTE_RE.test(spec.command))
77
+ && inArgs.some((arg) => typeof arg === 'string' && /(?:npx|npm)-cli\.js$/i.test(arg))) {
78
+ return { spec, rewritten: false, reason: 'already-normalized' };
79
+ }
80
+ const base = commandBase(spec.command);
81
+ if (!CMD_SHELL_COMMANDS.has(base)) {
82
+ // 绝对路径本来就是我们要的样子;其余(node / python / 自定义 exe)走 CreateProcess 的 .exe 补齐。
83
+ if (ABSOLUTE_RE.test(spec.command)) return { spec, rewritten: false, reason: 'already-absolute' };
84
+ return { spec, rewritten: false, reason: 'unsupported-command' };
85
+ }
86
+ const node = isObject(context.node) ? context.node : null;
87
+ const entry = LAUNCHER_ENTRY[base];
88
+ if (!node || !node.exe || (entry === 'npx-cli.js' && !node.npxCli)) {
89
+ // 找不到本机 Node:宁可不改。改写成一个不存在的路径会把「连不上」换成更难的「找不到文件」。
90
+ return { spec, rewritten: false, reason: 'node-not-found' };
91
+ }
92
+ // 入口 JS 必须是探测到的真实路径;缺了就当找不到,绝不拼一个没验证过的路径出去写盘。
93
+ const cli = entry === 'npx-cli.js' ? node.npxCli : node.npmCli;
94
+ if (!cli) return { spec, rewritten: false, reason: 'node-not-found' };
95
+ const args = inArgs.filter((item) => typeof item === 'string');
96
+ const next = { ...spec, command: node.exe, args: [cli, ...args] };
97
+ if (context.cacheDir) {
98
+ const env = isObject(spec.env) ? { ...spec.env } : {};
99
+ // 用户显式配过缓存(任意大小写)时以用户为准,不覆盖。
100
+ const hasCacheKey = Object.keys(env).some((key) => key.toLowerCase() === 'npm_config_cache');
101
+ if (!hasCacheKey) env.npm_config_cache = context.cacheDir;
102
+ if (Object.keys(env).length) next.env = env;
103
+ }
104
+ return { spec: next, rewritten: true, reason: 'rewritten' };
105
+ }
106
+
107
+ /**
108
+ * 探测本机 Node 启动器。所有输入均可注入,便于在任意平台上跑同一份逻辑。
109
+ * 只在 win32 返回 applicable:true;其余平台一律 applicable:false(本层完全不介入)。
110
+ */
111
+ export function detectWin32LaunchContext({
112
+ platform = process.platform,
113
+ env = process.env,
114
+ home = homedir(),
115
+ exists = existsSync,
116
+ isFile = (value) => {
117
+ try {
118
+ return statSync(value).isFile();
119
+ } catch {
120
+ return false;
121
+ }
122
+ },
123
+ mkDir = mkdirSync,
124
+ } = {}) {
125
+ if (platform !== 'win32') return { applicable: false, platform, node: null, cacheDir: null };
126
+ const rawPath = env.PATH || env.Path || env.path || '';
127
+ const dirs = rawPath.split(';').map((dir) => dir.trim()).filter(Boolean);
128
+ let fallback = null;
129
+ for (const dir of dirs) {
130
+ const exe = winpath.join(dir, 'node.exe');
131
+ if (!isFile(exe)) continue;
132
+ const npmBin = winpath.join(dir, 'node_modules', 'npm', 'bin');
133
+ const npxCli = winpath.join(npmBin, 'npx-cli.js');
134
+ const npmCli = winpath.join(npmBin, 'npm-cli.js');
135
+ if (isFile(npxCli) && isFile(npmCli)) {
136
+ // 同目录成对命中:这是官方安装包的标准布局,优先采用。
137
+ return { applicable: true, platform, node: { exe, npxCli, npmCli }, cacheDir: armoryNpmCacheDir(home, mkDir) };
138
+ }
139
+ if (!fallback) fallback = { exe, npxCli: isFile(npxCli) ? npxCli : '', npmCli: isFile(npmCli) ? npmCli : '' };
140
+ }
141
+ return { applicable: true, platform, node: fallback, cacheDir: armoryNpmCacheDir(home, mkDir) };
142
+ }
143
+
144
+ /** 军械库专用 npm 缓存目录;建不出来就返回 null(不注入,保持原样而不是指向一个坏路径)。 */
145
+ function armoryNpmCacheDir(home, mkDir) {
146
+ if (typeof home !== 'string' || !home) return null;
147
+ const dir = winpath.join(home, '.dsh', 'armory', 'mcp-npm-cache');
148
+ try {
149
+ mkDir(dir, { recursive: true });
150
+ } catch {
151
+ return null;
152
+ }
153
+ return dir;
154
+ }
155
+
156
+ let cachedContext;
157
+
158
+ /**
159
+ * 进程内缓存的启动器上下文(首次用到才探测;探测失败不重试到写盘热路径上)。
160
+ *
161
+ * 这里的 try/catch 是**窄闸门**,不是兜底吞异常:本函数站在「加入 MCP」的唯一写入路径上,
162
+ * 探测层任何未预见的异常都不该让用户的添加动作整体失败。降级方向是回到修复前的行为
163
+ * (不改写、原样写入),并把降级原因如实带在 `probeError` 上交给调用方上报,
164
+ * 而不是静默成「这条不需要归一」。
165
+ */
166
+ export function win32LaunchContext(force = false, detect = detectWin32LaunchContext) {
167
+ if (cachedContext === undefined || force === true) {
168
+ try {
169
+ cachedContext = detect();
170
+ } catch (error) {
171
+ cachedContext = { applicable: false, platform: process.platform, node: null, cacheDir: null, probeError: String(error?.message || error) };
172
+ }
173
+ }
174
+ return cachedContext;
175
+ }
176
+
177
+ /** 测试与热修入口:注入假上下文 / 清空缓存。 */
178
+ export function setWin32LaunchContext(value) {
179
+ cachedContext = value;
180
+ }
181
+
182
+ export function resetWin32LaunchContext() {
183
+ cachedContext = undefined;
184
+ }
185
+
186
+ /**
187
+ * 给用户看的改写完说明:只比较「进」与「出」的命令,不往 spec 上挂任何多余字段
188
+ * (多余字段会被 workspace 配置序列化进 .dsh/mcp.json,污染用户文件)。
189
+ */
190
+ export function describeLaunchRewrite(originalCommand, finalCommand) {
191
+ if (typeof originalCommand !== 'string' || typeof finalCommand !== 'string') return '';
192
+ if (originalCommand.trim() === finalCommand.trim()) return '';
193
+ return `;Windows 启动器已改用本机 Node(${finalCommand}),裸 ${originalCommand.trim()} 在 win32 无法 spawn`;
194
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-armory",
3
- "version": "2.4.4",
3
+ "version": "2.4.5",
4
4
  "description": "Unified Skill + MCP + Expert + Plugin board for DeepSeek Harness (desktop app and Web GUI) — custom skill suites, skill install/uninstall with live refresh, global panel page, JSON import, WorkBuddy connector & SkillHub & Expert & DSH community plugin market installs, MCP OAuth authorization, tool-usage tailor with wire-tax trends, and profile-backed Host Remote management.",
5
5
  "repository": {
6
6
  "type": "git",