dsh-plugin-admin 0.3.1 → 0.4.1

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.md CHANGED
@@ -7,7 +7,7 @@ dsh web UI 插件:在浏览器设置弹窗(侧边栏底部“设置”)中
7
7
  - 列表展示当前 profile 下的全部 bundle 层(名称、版本,以及**内置 / 包安装 / 本地安装**标记;本地安装的插件额外显示其源路径,依据 profile 依赖清单中的 `link:` / `file:` / 绝对路径 spec 判定);
8
8
  - **名称模糊搜索**:列表上方搜索框支持按插件名 / 版本号 / 本地路径模糊过滤(大小写不敏感子串匹配),可叠加「全部 / 扩展插件 / 系统内置」筛选胶囊,无结果时给出专属空状态提示;
9
9
  - 支持扩展插件一键卸载(带有优雅的行内二次确认);
10
- - **远程更新检测(自动)**:打开「插件管理」Tab 即自动按 profile 实际使用的 npm registry(`npm_config_registry` → 项目/用户 `.npmrc` → 官方源)查询每个 **registry 安装**的插件(跳过内置与本地路径安装)的 `latest` 版本,与本地版本比对——有新版时卡片显示琥珀色「⬆ 有新版本 vX.Y.Z」徽标并出现一键「⬆ 更新」按钮(`npm install <name>@latest`);工具栏「⬆ 检查更新」可强制手动刷新;查询带超时与 5 分钟缓存(host 侧缓存,**缓存命中时按当前安装版本重算 `updateAvailable`**,重启 dsh 后首次打开自动重查),网络失败按条目提示而非报错;**「有新版本」提醒会保存下来(浏览器 localStorage 持久化)**——关闭再进入管理中心、甚至重启 dsh 后依然显示(含本次检查网络失败时),**只有点「更新」真正升级、或后续检查确认已是最新版本后,提醒才自动消除并同步清掉持久化记录**;
10
+ - **远程更新检测(自动)**:打开「插件管理」Tab 即自动按 profile 实际使用的 npm registry(`npm_config_registry` → 项目/用户 `.npmrc` → 官方源)查询每个 **registry 安装**的插件(跳过内置与本地路径安装)的 `latest` 版本,与本地版本比对——有新版时卡片显示琥珀色「⬆ 有新版本 vX.Y.Z」徽标并出现一键「⬆ 更新」按钮(`npm install <name>@latest`);工具栏「⬆ 检查更新」是唯一的手动重查入口,**强制绕过 5 分钟缓存直接重查 registry 并刷新缓存内容**(自动检查走缓存,手动检查才是真查);查询带超时与 5 分钟缓存(host 侧缓存,**缓存命中时按当前安装版本重算 `updateAvailable`**,重启 dsh 后首次打开自动重查),网络失败按条目提示而非报错;**「有新版本」提醒会保存下来(浏览器 localStorage 持久化)**——关闭再进入管理中心、甚至重启 dsh 后依然显示(含本次检查网络失败时),**只有点「更新」真正升级、或后续检查确认已是最新版本后,提醒才自动消除并同步清掉持久化记录;更新其中一个插件不会影响其余插件的提醒;升级完成后的自动复核同样**强制绕过缓存**重新查询,registry 在 TTL 窗口内刚发布更新的情况下也不会把刚升完级的插件误标回「有新版本」,检查与升级并发交错时,检查刚发现的提醒也不会被升级提交用旧快照覆盖(合并一律基于当前 state 的函数式更新,持久化由统一的镜像 effect 收口);
11
11
  - 安装/卸载在 host 侧编排 profile 目录下的 pnpm 并自动同步 `package.json` 的 `dsh.profile.bundles` 清单(变更在重启 dsh 后生效)。
12
12
  - **💬 会话管理**:
13
13
  - **标题与内容摘要**:自动解析会话标题(`session/title` 或首条用户提问)并渲染首条消息的文本摘要气泡预览(带折行省略与多行保护);
@@ -15,7 +15,8 @@ dsh web UI 插件:在浏览器设置弹窗(侧边栏底部“设置”)中
15
15
  - **状态呼吸灯与徽标**:绿色呼吸光晕(**会话在线**,悬停提示说明"在线 = 仍挂载于 dsh host 内存,非正在运行")、琥珀色标签(**已归档**)、灰色默认点(**已结束**);
16
16
  - **多维快捷搜索与筛选**:输入框支持同时模糊匹配标题、对话摘要、工作目录或 Session ID;支持按状态胶囊筛选(全部、在线、已归档、已结束);
17
17
  - **会话清理与恢复**:支持会话永久物理删除(行内二次防误触确认,彻底清理磁盘日志、工作区记账与归档集合);**在线会话不再需要重启 dsh**——「关停并删除」通过插件在 host 侧透明捕获的 `AgentHandle` 走 dsh 官方 dispose 链(停止 agent 运行、等待静止、注销 agent、从内存 SessionStore 移除并触发 `session/disposed`,持久化层随即 flush 缓冲事件并释放写路径),然后才删除日志,因此日志不会在下次 flush 复活;支持已归档会话一键取消归档,侧边栏即时联动刷新。
18
- - **🔌 MCP 配置**:管理中心中的独立 Tab——列出 profile 的 `cordis.patch.yml` 中所有 `@deepseek-ai/dsh-mcp-client` 实例,支持添加(stdio 子进程 / streamable-http)、编辑与移除,配置写回后重启 dsh 生效;新增**连通性检测**(🔌 测试)——host 侧按条目配置发起一次真实的 MCP 握手(`initialize` → `notifications/initialized` → `tools/list`),stdio 走新行分隔 JSON-RPC 子进程(Windows 下与 real 插件同样经 `cmd.exe` 解析 `.cmd` shim,超时强杀进程树、捕获 stderr 尾部便于诊断),streamable-http 走 `initialize` POST(兼容 SSE / 纯 JSON 响应);成功后行内显示服务器标识、**工具数量与工具名列表**,失败给出可诊断错误(命令不存在 / 连接被拒 / 超时等);若 `command` 写成整行调用(如 `npx -y fetcher-mcp`),探测会自动拆分执行并给出**警告**提示需拆分为 `command` + `args`(否则 dsh 启动该 MCP 服务器会失败);结果经 typert 边界 JSON 安全清洗,绝无 `undefined` 字段。
18
+ - **🔌 MCP 配置**:管理中心中的独立 Tab——列出 profile 的 `cordis.patch.yml` 中所有 `@deepseek-ai/dsh-mcp-client` 实例,支持添加(stdio 子进程 / streamable-http)、编辑与移除,配置写回后重启 dsh 生效;新增**连通性检测**(🔌 测试)——host 侧按条目配置发起一次真实的 MCP 握手(`initialize` → `notifications/initialized` → `tools/list`),stdio 走新行分隔 JSON-RPC 子进程(Windows 下与 real 插件同样经 `cmd.exe` 解析 `.cmd` shim,超时强杀进程树、捕获 stderr 尾部便于诊断),streamable-http 走 `initialize` POST(兼容 SSE / 纯 JSON 响应);成功后行内显示服务器标识、**工具数量与工具名列表**,失败给出可诊断错误(命令不存在 / 连接被拒 / 超时等);若 `command` 写成整行调用(如 `npx -y fetcher-mcp`),探测会自动拆分执行并给出**警告**提示需拆分为 `command` + `args`(否则 dsh 启动该 MCP 服务器会失败);结果经 typert 边界 JSON 安全清洗,绝无 `undefined` 字段;
19
+ - **测试结果的缓存边界**:只有**成功**的探测结果持久化到 localStorage(重开面板/重启后恢复上次状态并标注「缓存于」时刻);**失败(❌ 不通)只在当前会话内显示、不落盘**——瞬时故障不会变成跨会话的过期 ❌,旧版本遗留的失败记录会在载入时自动清除;新增表单中手输与既有条目相同的 id 会被**直接拒绝并提示换名**——host 的 upsert 按 id 原位覆盖,不拦截就是静默毁掉该条目的既有配置。
19
20
  - **侧边栏右键菜单(会话 & 工作区)**:
20
21
  - **会话行右键**:在原生菜单末尾追加**复制会话 ID**与**删除会话**(危险操作);删除按标题**精确匹配**解析目标,存在同名会话时拒绝执行并引导到管理中心按 ID 删除,且菜单项需**4 秒内两次点击**确认;删除复用 `sessionAdmin` 的安全语义——在线会话走 `closeSession`(先 dispose 捕获的 agent handle 再删日志,不再要求重启),非在线会话走 `deleteSession`,均定向 detach。
21
22
  - **工作区行右键**:新增**在资源管理器打开**——调用 `fsAdmin.reveal` 在系统文件管理器中定位该工作区目录(Windows `explorer /select`、macOS `open -R`、Linux `xdg-open`)。
@@ -27,7 +28,7 @@ dsh web UI 插件:在浏览器设置弹窗(侧边栏底部“设置”)中
27
28
  - **Host 端(`lib/index.js`,零 dsh 依赖)**:
28
29
  - 注入 `['typert', 'workspaceRegistry', 'sessionPersistence']`;
29
30
  - 提供并注册四个 RPC 命名空间:
30
- - `pluginAdmin`(`list` / `install` / `remove` / `checkUpdates`):异步 `spawn` pnpm(Windows 走 shell 解析 .cmd shim,5 分钟超时且**进程树强杀**(`taskkill /T /F`,避免超时后残留 pnpm/node 子进程继续写盘),Promise 尾链串行化防并发),镜像 CLI `reconcileBundles` 同步清单,清单写回为**原子写**(临时文件 + rename,崩溃不截断 profile 的 package.json);安装/卸载参数经**字符白名单**校验(`assertPnpmOperand`),从根上排除 `&` `|` `>` `<` `%` 引号等 cmd 元字符注入向量;`install` / `remove` 返回真实 pnpm 输出尾部(`output` 字段),前端作为提示气泡的悬浮诊断信息展示;`checkUpdates` 对 registry 安装的 bundle 并发查询 npm registry 的 `latest`(registry 解析:`npm_config_registry` env → 项目/用户 `.npmrc` → 官方源;有界并发 4 路、8s 超时、5 分钟缓存),返回 `updateAvailable` / `latest` / `error`,本地路径与内置插件跳过;
31
+ - `pluginAdmin`(`list` / `install` / `remove` / `checkUpdates`):异步 `spawn` pnpm(Windows 走 shell 解析 .cmd shim,5 分钟超时且**进程树强杀**(`taskkill /T /F`,避免超时后残留 pnpm/node 子进程继续写盘),Promise 尾链串行化防并发),镜像 CLI `reconcileBundles` 同步清单,清单写回为**原子写**(临时文件 + rename,崩溃不截断 profile 的 package.json);安装/卸载参数经**字符白名单**校验(`assertPnpmOperand`),从根上排除 `&` `|` `>` `<` `%` 引号等 cmd 元字符注入向量;`install` / `remove` 返回真实 pnpm 输出尾部(`output` 字段),前端作为提示气泡的悬浮诊断信息展示;`checkUpdates` 对 registry 安装的 bundle 并发查询 npm registry 的 `latest`(registry 解析:`npm_config_registry` env → 项目/用户 `.npmrc` → 官方源;有界并发 4 路、8s 超时、5 分钟缓存;`force` 参数为 true 时**绕过缓存强制重查并刷新缓存内容**),返回 `updateAvailable` / `latest` / `error`,本地路径与内置插件跳过;
31
32
  - `sessionAdmin` (`list` / `archive` / `unarchive` / `deleteSession` / `closeSession`):直接对接 `workspaceRegistry` 与 `sessionPersistence`,提供安全幂等的持久化日志清理与归档状态流转;`list` 采用**修订号驱动的摘要缓存**(`listSnapshots` 的 revision token,未变化会话不重读事件日志)与**有界并发**(最多 4 路并行 inspect),并设单会话事件扫描上限兜底;`archive` 校验会话真实存在,拒绝向归档集写入垃圾 id;对 registry 软私有写路径(`requireState` / `setState` / `enqueueOperation`)在 `apply()` 挂载时即做**兼容性探测**,dsh 版本变更会明确报出缺失成员,而不是首次归档时才静默失败。`closeSession` 使**在线会话免重启删除**成为可能:`installAgentHandleCapture` 在挂载时透明包装公开的 `ctx.agents.create` / `resume`(原样调用并返回,仅把返回的 `AgentHandle` 按 session id 存入插件私有 Map),删除在线会话时先 `handle.dispose()` 走 dsh 官方 teardown 链(停止 loop → 等待静止 → 注销 agent → 从 SessionStore 移除 → 触发 `session/disposed` → 持久化层 `retire()` flush 缓冲事件并释放写路径),再删日志——日志不会被下次 flush 复活;未被捕获 handle 的在线会话(如插件挂载前已创建)会明确报错并引导重启,绝不误删。
32
33
  - `fsAdmin` (`reveal`):跨平台在系统文件管理器中定位一个绝对路径(Windows `explorer /select`、macOS `open -R`、Linux `xdg-open`),供工作区右键菜单「在资源管理器打开」使用;
33
34
  - `mcpAdmin` (`list` / `upsert` / `remove` / `test`):管理 profile 的 `cordis.patch.yml` 中的 MCP 客户端实例(`@deepseek-ai/dsh-mcp-client`)。基于行级 YAML 块编辑(零依赖):识别顶层 `- id:` 块并仅改动 `name` 为 MCP 客户端插件的条目,`upsert` 按 id 原位替换或追加,`remove` 整块删除,写回走**原子写**;校验 id / serverName / transport / command / url,拒绝畸形输入;文件变更与插件安装共用同一**串行操作队列**(读-改-写不交错)。`test` 按条目 id 发起**连通性探测**:stdio 子进程(newline JSON-RPC,`initialize` → `initialized` → `tools/list`,超时 `taskkill /T /F` 强杀进程树,捕获 stderr 尾部)或 streamable-http(`fetch` POST `initialize`,兼容 SSE 与纯 JSON,超时 AbortController),返回服务器标识 / 工具数量 / 耗时,或失败原因(命令不存在、连接拒绝、超时等),全程不抛异常。
@@ -87,8 +88,8 @@ node scripts/host-check.mjs
87
88
  7. 会话删除二次确认交互;
88
89
  8. **侧边栏菜单注入**:验证会话菜单追加「复制会话 ID / 删除会话」,删除项为**两次点击确认**(首击改写标签、二击才触发 RPC)且对**同名会话拒绝删除**;工作区菜单追加「在资源管理器打开」;
89
90
  9. **MCP 配置面板渲染与保存流**:独立 MCP 设置页展示服务器列表、添加/编辑/移除入口与空状态;打开添加表单填写 id / serverName / command 后保存,断言 `mcpAdmin/upsert` 收到正确载荷、表单关闭且新服务器入列;**连通性测试按钮**(🔌 测试)触发 `mcpAdmin/test` 并在行内渲染 ✅ 连通(含服务器名与工具数量)。
90
- 10. `verify-mcp-cache.mjs`:MCP 连通性测试缓存的 localStorage 往返(预置缓存渲染 / 新探测持久化 + 重挂载恢复 / 配置保存失效缓存);
91
- 11. `verify-update-reminders.mjs`:插件**更新提醒持久化**(保存下来、更新完删除提醒)——① 已保存的提醒在重开面板且本次检查网络失败时依然渲染且不被抹除;② 检查确认已是最新版本 → 徽标与 localStorage 记录同时清除;③ 点击卡片「⬆ 更新」→ 安装后提醒立即消失(含回调确认后仍不复活);④ 某个插件查询出错时不丢失已有提醒。
91
+ 10. `verify-mcp-cache.mjs`:MCP 连通性测试缓存的 localStorage 往返(预置缓存渲染 / 新探测持久化 + 重挂载恢复 / 配置保存失效缓存 / **失败探测 `{ok:false}` 只存于会话不落盘、重挂载不复活 ❌**);
92
+ 11. `verify-update-reminders.mjs`:插件**更新提醒持久化**(保存下来、更新完删除提醒)——① 已保存的提醒在重开面板且本次检查网络失败时依然渲染且不被抹除;② 检查确认已是最新版本 → 徽标与 localStorage 记录同时清除;③ 点击卡片「⬆ 更新」→ 安装后提醒立即消失(含回调确认后仍不复活);④ 某个插件查询出错时不丢失已有提醒;⑤ 同时有两个过时插件时**只更新其中一个**,另一个的提醒在重挂载(刷新)后依然保留、localStorage 也只留未更新那条;⑥ **首开即全量查询失败**时逐条渲染「⚠ 更新检查失败」标签并给出失败计数,绝不显示「全部为最新版本」——瞬时错误不写入 localStorage,下一次成功检查自动消退(混合轮次只对确实验证过的条目说「其余均为最新版本」);⑦ 自动检查在飞期间点击「⬆ 更新」,检查刚发现的其他插件提醒**不会被升级提交用旧快照覆盖**(deferred 桩脚本化交错:先放行检查、再放行安装,中间断言提醒与 localStorage 均在;旧实现在此处会丢失提醒且跳过升级后的强制复核)。
92
93
 
93
94
  Host 侧自检(`scripts/host-check.mjs`)覆盖下列契约:
94
95
  1. `sessionAdmin.deleteSession` 的**定向 detach 契约**:删除会话只允许触碰实际记账该会话的那一个工作区,绝不允许批量遍历(dsh 的 `detachSession` 写入带剪枝语义——记录中所有不在 registry 内存头索引里的会话会被永久剥离;批量调用在索引不完整时会把无关工作区的记账整体清空,表现为所有会话落入"未分组");
@@ -100,7 +101,8 @@ Host 侧自检(`scripts/host-check.mjs`)覆盖下列契约:
100
101
  6. **mcpAdmin 配置往返**:对临时 profile 的 `cordis.patch.yml` 做 list / upsert(新增、原位更新)/ remove,验证条目 id、serverName 与最终文件内容正确且仍是合法 YAML;同时校验畸形输入(非法 id / transport / 缺 command)被拒绝;`fsAdmin.reveal` 校验路径参数;
101
102
  7. **mcpAdmin 写操作串行化**:并发 `upsert` 经与插件安装共享的操作队列后全部落盘,读-改-写不交错;
102
103
  8. **mcpAdmin.test 连通性探测**:对真实 stdio MCP 服务器(newline JSON-RPC 握手)与 streamable-http 服务器(`initialize` POST)分别断言 `ok:true` 且携带 serverInfo / toolCount;对不存在的命令(`not found`)、静默子进程(超时)、死 HTTP 端点(连接失败)断言 `ok:false` 且错误可诊断;未知 id 被拒绝;
103
- 9. **checkUpdates 缓存命中回归**:5 分钟 TTL 内第二次查询命中缓存时,`updateAvailable` 按**当前安装版本**重算——仍落后版本 → 提醒保留(修复了缓存只存 latest 导致重开面板提醒丢失);把安装版本抬到 latest 模拟升级完成 → 提醒自动消除(更新完删除提醒)。
104
+ 9. **checkUpdates 缓存命中回归**:5 分钟 TTL 内第二次查询命中缓存时,`updateAvailable` 按**当前安装版本**重算——仍落后版本 → 提醒保留(修复了缓存只存 latest 导致重开面板提醒丢失);把安装版本抬到 latest 模拟升级完成 → 提醒自动消除(更新完删除提醒);
105
+ 10. **checkUpdates 强制刷新(force)**:「⬆ 检查更新」传入 `force` 时**绕过 TTL 真正重查 registry**(stub 请求计数递增),返回值立即反映 registry 新版本,且**下一个 TTL 内的普通查询直接吃到 force 刷新后的缓存内容**(检查更新强制更新缓存内容);升级到刷新后的 latest → 提醒消除。
104
106
 
105
107
  ---
106
108
 
@@ -1,6 +1,12 @@
1
1
  # dsh-plugin-admin: bring Explorer window to foreground
2
2
  # Usage: powershell -File bring-explorer.ps1 -Path <absolute path>
3
- param([Parameter(Mandatory=$true)][string]$Path)
3
+ # Fail-soft contract: never waits on user input -- an empty/missing -Path
4
+ # must NOT hit a Mandatory prompt (this console is HIDDEN; the process
5
+ # would linger forever), so the parameter is optional and guarded below.
6
+ param([string]$Path = '')
7
+
8
+ $ErrorActionPreference = 'SilentlyContinue'
9
+ if ([string]::IsNullOrWhiteSpace($Path)) { exit 0 }
4
10
 
5
11
  Add-Type @"
6
12
  using System;
@@ -18,8 +24,24 @@ public class Fr {
18
24
  }
19
25
  "@
20
26
 
21
- $base = ($Path -replace '\\', '/').Split('/')[-1]
22
- $script:pattern = [regex]::Escape($base) + '\s*-'
27
+ # The title Explorer displays is the DISPLAY NAME of the revealed FOLDER:
28
+ # the folder itself -- or its PARENT when reveal ran `/select,<file>` on a
29
+ # file. Fall back to the last path segment when the item is gone/unreadable.
30
+ $base = $null
31
+ $item = Get-Item -LiteralPath $Path
32
+ if ($null -ne $item) {
33
+ if ($item.PSIsContainer) { $base = $item.Name }
34
+ else { $base = Split-Path -Leaf $item.DirectoryName }
35
+ } else {
36
+ $segments = ($Path -replace '\\', '/').Split('/') | Where-Object { $_ }
37
+ if ($segments.Count -gt 0) { $base = $segments[-1] }
38
+ }
39
+ if ([string]::IsNullOrWhiteSpace($base)) { exit 0 }
40
+
41
+ # Stock Windows 10/11 titles are the bare folder name; some shell addons
42
+ # append " - <suffix>". Anchor at start, make the dash clause optional so
43
+ # both shapes match, and prefix collisions ("Test" vs "Test 2") stay safe.
44
+ $script:pattern = '^' + [regex]::Escape($base) + '(?:\s*-|$)'
23
45
  $script:found = [IntPtr]::Zero
24
46
 
25
47
  function Find-Target {
@@ -45,12 +67,16 @@ for ($i = 0; $i -lt 30; $i++) {
45
67
  Start-Sleep -Milliseconds 200
46
68
  }
47
69
 
48
- if ($target -ne [IntPtr]::Zero) {
49
- # Simulated ALT keypress grants foreground permission
50
- [Fr]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero)
51
- [Fr]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero)
52
- Start-Sleep -Milliseconds 80
53
- [Fr]::ShowWindow($target, 9) | Out-Null
54
- [Fr]::SetForegroundWindow($target) | Out-Null
55
- [Fr]::BringWindowToTop($target) | Out-Null
56
- }
70
+ if ($target -eq [IntPtr]::Zero) { exit 1 }
71
+
72
+ # Simulated ALT keypress grants foreground permission
73
+ [Fr]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero)
74
+ [Fr]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero)
75
+ Start-Sleep -Milliseconds 80
76
+ [Fr]::ShowWindow($target, 9) | Out-Null
77
+ [Fr]::SetForegroundWindow($target) | Out-Null
78
+ [Fr]::BringWindowToTop($target) | Out-Null
79
+
80
+ # One debuggable line on stdout; the host pipe just drains it.
81
+ Write-Output ("raised:{0}" -f $target)
82
+ exit 0
package/lib/client.js CHANGED
@@ -497,28 +497,38 @@ function setupMenuInjection(call, refreshSessions) {
497
497
  * Plugins management settings section (standalone, no internal tabs).
498
498
  */
499
499
  function PluginsSection(props) {
500
- var pairPlugins = useState({
501
- profileDir: '',
502
- plugins: [],
503
- busy: false,
504
- error: '',
505
- spec: '',
506
- confirming: null,
507
- note: '',
508
- output: '',
509
- filter: 'all',
510
- needle: '',
511
- checkingUpdates: false,
512
- updateChecked: false, // true once a check finished (even if no updates)
513
- // Seed from persisted reminders so the ⬆ 有新版本 badge is visible the
514
- // moment the panel mounts (before the fresh host check resolves) — the
515
- // reminder is only dropped once a check confirms the upgrade completed.
516
- updates: loadUpdateReminders(), // name -> { latest, updateAvailable, error?, at? }
500
+ // Lazy initializer: the seed object (and its localStorage read) must be
501
+ // built once at mount, not re-parsed on every render.
502
+ var pairPlugins = useState(function () {
503
+ return {
504
+ profileDir: '',
505
+ plugins: [],
506
+ busy: false,
507
+ error: '',
508
+ spec: '',
509
+ confirming: null,
510
+ note: '',
511
+ output: '',
512
+ filter: 'all',
513
+ needle: '',
514
+ checkingUpdates: false,
515
+ updateChecked: false, // true once a check finished (even if no updates)
516
+ // Seed from persisted reminders so the 有新版本 badge is visible the
517
+ // moment the panel mounts (before the fresh host check resolves) — the
518
+ // reminder is only dropped once a check confirms the upgrade completed.
519
+ updates: loadUpdateReminders(), // name -> { latest, updateAvailable, error?, at? }
520
+ }
517
521
  })
518
522
  var pView = pairPlugins[0]
519
523
  var setPView = pairPlugins[1]
520
524
 
521
525
  var alive = useRef(false)
526
+ // In-flight check guard as a ref, not state: the state field drives the
527
+ // disabled button visuals, but gating ASYNC handlers on it races — a stale
528
+ // closure reads TRUE after an earlier check already settled and silently
529
+ // skips a required follow-up (the post-upgrade refresh), or FALSE mid-flight
530
+ // and lets a second one through. Refs are immune to closure staleness.
531
+ var checkingRef = useRef(false)
522
532
 
523
533
  function patchPlugin(partial) {
524
534
  setPView(function (cur) {
@@ -559,68 +569,48 @@ function PluginsSection(props) {
559
569
  * - newer version confirmed -> (re)arm & persist the reminder
560
570
  * - confirmed up to date -> drop the reminder (更新完删除提醒)
561
571
  * - per-plugin check error -> keep the known reminder (a transient
562
- * registry failure must not erase it)
572
+ * registry failure must not erase it);
573
+ * with no known reminder the failure
574
+ * itself is recorded so cards/strip/
575
+ * note can surface it
563
576
  * - no longer a registry install -> drop stale reminders
577
+ * The AUTO check on panel open is cache-friendly; a manual check passes
578
+ * force=true so the host bypasses its 5-minute TTL, re-queries the registry
579
+ * and refreshes the cached content (检查更新 = 强制重新查,而不是回放缓存).
564
580
  * Runs concurrently with the list so the panel stays responsive; failures
565
581
  * are per-plugin, never fatal.
582
+ * @param force - true to force the host to bypass its update cache.
566
583
  */
567
- function checkUpdates() {
568
- if (pView.checkingUpdates) return
584
+ function checkUpdates(force) {
585
+ if (checkingRef.current) return
586
+ checkingRef.current = true
569
587
  patchPlugin({ checkingUpdates: true, note: '' })
570
- callRemote('pluginAdmin/checkUpdates', {}).then(function (result) {
588
+ callRemote('pluginAdmin/checkUpdates', { force: force === true }).then(function (result) {
589
+ checkingRef.current = false
571
590
  if (!alive.current) return
572
- patchPlugin({ checkingUpdates: false })
573
591
  if (!result.ok) {
574
- patchPlugin({ error: '检查更新失败:' + messageOf(result.error) })
592
+ patchPlugin({ checkingUpdates: false, error: '检查更新失败:' + messageOf(result.error) })
575
593
  // A failed call must not wipe previously known reminders — they stay
576
594
  // seeded from localStorage and keep rendering on the cards.
577
595
  return
578
596
  }
579
597
  var list = (result.value && result.value.updates) || []
580
- // Only the plugins the host actually checked are "live" registry
581
- // installs; builtins / local-path installs never carry a reminder and a
582
- // reminder for a removed plugin must be dropped too.
583
- var liveNames = {}
584
- for (var a = 0; a < list.length; a++) liveNames[list[a].name] = true
585
-
586
- var prev = pView.updates || {}
587
- var map = {}
588
- for (var k in prev) {
589
- if (prev[k] && liveNames[k]) map[k] = prev[k]
590
- }
591
- for (var i = 0; i < list.length; i++) {
592
- var u = list[i]
593
- if (u.updateAvailable === true) {
594
- map[u.name] = { latest: u.latest, updateAvailable: true, error: '', at: Date.now() }
595
- } else if (u.latest !== null && u.latest !== undefined && !u.error) {
596
- // Authoritatively current — the newer version is now installed.
597
- delete map[u.name]
598
- } else if (map[u.name]) {
599
- // Query failed for this entry: keep the prior reminder, surface the
600
- // transient error alongside it.
601
- map[u.name] = mergeReminder(map[u.name], { error: u.error || '' })
602
- }
603
- }
604
- // Persist whatever the check decided so the reminder survives dialog
605
- // close/reopen and dsh restarts (best-effort; never fatal).
606
- saveUpdateReminders(map)
607
-
608
- var updateCount = 0
609
- var checkErrorCount = 0
610
- for (var n in map) {
611
- if (map[n].updateAvailable === true) updateCount++
612
- if (map[n].error) checkErrorCount++
613
- }
614
- patchPlugin({
615
- updates: map,
616
- updateChecked: true,
617
- note: updateCount > 0
618
- ? '发现 ' + updateCount + ' 个插件有新版本'
619
- : (checkErrorCount > 0
620
- ? '已检查 ' + list.length + ' 个插件(' + checkErrorCount + ' 个查询失败),其余均为最新版本'
621
- : (list.length > 0 ? '已检查 ' + list.length + ' 个插件,均为最新版本' : '')),
598
+ // Merge INSIDE the functional updater against the LATEST committed
599
+ // state, never the render closure's snapshot: a reminder armed by an
600
+ // in-flight check resolving alongside an upgrade commit must survive
601
+ // (a stale-snapshot full replace erased it). The updater stays pure —
602
+ // localStorage persistence is owned by the mirror effect below.
603
+ setPView(function (cur) {
604
+ var next = {}
605
+ for (var k in cur) next[k] = cur[k]
606
+ next.checkingUpdates = false
607
+ next.updates = mergeUpdateReminders(cur.updates || {}, list)
608
+ next.updateChecked = true
609
+ next.note = updateCheckNote(next.updates, list.length)
610
+ return next
622
611
  })
623
612
  }, function (failure) {
613
+ checkingRef.current = false
624
614
  if (!alive.current) return
625
615
  patchPlugin({ checkingUpdates: false, error: '检查更新调用失败:' + messageOf(failure) })
626
616
  })
@@ -634,24 +624,33 @@ function PluginsSection(props) {
634
624
  callRemote('pluginAdmin/install', { spec: spec }).then(function (result) {
635
625
  if (!alive.current) return
636
626
  if (result.ok) {
637
- patchPlugin({
638
- busy: false,
639
- note: '已更新 ' + name + '。更改在重启 dsh 后生效',
640
- output: (result.value && result.value.output) || '',
641
- profileDir: (result.value && result.value.profileDir) || '',
642
- plugins: (result.value && result.value.plugins) || [],
627
+ // The upgrade consumed this reminder: strip it from the CURRENT state
628
+ // inside the updater (更新完删除提醒) — never from the click-time
629
+ // closure snapshot, which may miss reminders an in-flight check armed
630
+ // meanwhile (erasing those was exactly scenario 7's stale-snapshot
631
+ // bug). localStorage follows via the shared mirror effect; the forced
632
+ // refresh below confirms the new version is current.
633
+ setPView(function (cur) {
634
+ var next = {}
635
+ for (var k in cur) next[k] = cur[k]
636
+ next.busy = false
637
+ next.note = '已更新 ' + name + '。更改在重启 dsh 后生效'
638
+ next.output = (result.value && result.value.output) || ''
639
+ next.profileDir = (result.value && result.value.profileDir) || ''
640
+ next.plugins = (result.value && result.value.plugins) || []
641
+ var remaining = {}
642
+ for (var rk in cur.updates) {
643
+ if (rk !== name) remaining[rk] = cur.updates[rk]
644
+ }
645
+ next.updates = remaining
646
+ return next
643
647
  })
644
- // The upgrade consumed this reminder: clear it right away and persist
645
- // so it does not linger after the update (更新完删除提醒); the refresh
646
- // check below confirms the new version is current.
647
- var nextReminders = {}
648
- for (var rk in pView.updates) {
649
- if (rk !== name) nextReminders[rk] = pView.updates[rk]
650
- }
651
- saveUpdateReminders(nextReminders)
652
- patchPlugin({ updates: nextReminders })
653
648
  // Refresh the update map so the upgraded entry stops flagging.
654
- checkUpdates()
649
+ // Force: the @latest spec installed the registry's CURRENT latest,
650
+ // but the host TTL cache may still hold an older latest published
651
+ // minutes ago — comparing against that would re-flag the freshly
652
+ // upgraded plugin. Bypass the cache and resync its content instead.
653
+ checkUpdates(true)
655
654
  return
656
655
  }
657
656
  patchPlugin({ busy: false, error: '更新失败:' + messageOf(result.error) })
@@ -713,14 +712,25 @@ function PluginsSection(props) {
713
712
  alive.current = true
714
713
  reloadPlugins()
715
714
  // Auto-check for remote updates on mount (the panel loads lazily when
716
- // the 插件管理 tab is opened, so this runs once per open). The host
717
- // caches registry answers for 5 minutes, so re-opening the tab shortly
718
- // after does not re-hit the registry. The manual ⬆ 检查更新 button stays
719
- // for forcing a fresh check.
715
+ // the 插件管理 tab is opened, so this runs once per open). The auto path
716
+ // is cache-friendly: the host serves its 5-minute cache, so re-opening
717
+ // the tab shortly after does not re-hit the registry. The manual
718
+ // 检查更新 button is the force path — it bypasses the cache and
719
+ // refreshes its content.
720
720
  checkUpdates()
721
721
  return function () { alive.current = false }
722
722
  }, [])
723
723
 
724
+ // Single persistence point: localStorage always mirrors the LATEST
725
+ // committed reminders. Moving saves out of the async handlers (and out of
726
+ // the functional updaters, which must stay pure) removes drift by
727
+ // construction — whatever merge won the last commit is what survives
728
+ // dialog reopen and dsh restarts (best-effort; unavailable storage just
729
+ // degrades to session-only badges).
730
+ useEffect(function () {
731
+ saveUpdateReminders(pView.updates || {})
732
+ }, [pView.updates])
733
+
724
734
  return createElement('div', { 'data-dsh-admin-section': '' },
725
735
  renderPluginsView(pView, patchPlugin, installPlugin, removePlugin, checkUpdates, upgradePlugin))
726
736
  }
@@ -844,7 +854,16 @@ function loadMcpTestCache() {
844
854
  if (store === null) return {}
845
855
  try {
846
856
  var parsed = JSON.parse(store.getItem(MCP_TEST_CACHE_KEY))
847
- return parsed !== null && typeof parsed === 'object' ? parsed : {}
857
+ if (parsed === null || typeof parsed !== 'object') return {}
858
+ // Purge failure records persisted by older builds: only a durable OK is
859
+ // worth restoring on mount; a stale ❌ from a past outage must not
860
+ // outlive the session it happened in.
861
+ var out = {}
862
+ for (var id in parsed) {
863
+ var entry = parsed[id]
864
+ if (entry && entry.result && entry.result.ok === true) out[id] = entry
865
+ }
866
+ return out
848
867
  } catch (e) {
849
868
  return {}
850
869
  }
@@ -858,8 +877,11 @@ function saveMcpTestCache(state) {
858
877
  var out = {}
859
878
  for (var id in state) {
860
879
  var s = state[id]
861
- // Busy rows keep the previous cached result; failures stay ephemeral.
862
- if (!s || s.busy || s.result === null || s.result === undefined) continue
880
+ // Busy rows keep the previous cached result; failures stay ephemeral
881
+ // INCLUDING structured {ok:false} probes (timeout, connection refused),
882
+ // which arrive as a truthy result object and need their own ok check.
883
+ if (!s || s.busy || s.result === null || s.result === undefined
884
+ || s.result.ok !== true) continue
863
885
  out[id] = { result: s.result, at: typeof s.at === 'number' ? s.at : Date.now() }
864
886
  }
865
887
  store.setItem(MCP_TEST_CACHE_KEY, JSON.stringify(out))
@@ -974,6 +996,64 @@ function saveUpdateReminders(map) {
974
996
  }
975
997
  }
976
998
 
999
+ /**
1000
+ * Merge one check response into the CURRENT reminder map. Pure on purpose:
1001
+ * it runs inside the setPView updater, so it reads only its arguments and
1002
+ * must never touch localStorage or other component state. Per-entry semantics
1003
+ * (list = fresh host results, one row per live registry install):
1004
+ * - updateAvailable -> (re)arm the reminder
1005
+ * - confirmed current -> drop any entry (更新完删除提醒)
1006
+ * - query error -> keep a prior reminder untouched (error attached);
1007
+ * without one, record an ephemeral failure
1008
+ * ({updateAvailable:false}) so cards/strip/note can
1009
+ * surface it — saveUpdateReminders skips those
1010
+ * - entries absent from `list` are no longer live registry installs ->
1011
+ * stale reminders dropped
1012
+ * @param curMap - the CURRENT updates state ({ name -> entry }).
1013
+ * @param list - per-plugin check results.
1014
+ * @returns the complete next map.
1015
+ */
1016
+ function mergeUpdateReminders(curMap, list) {
1017
+ var liveNames = {}
1018
+ for (var a = 0; a < list.length; a++) liveNames[list[a].name] = true
1019
+ var map = {}
1020
+ for (var k in curMap) {
1021
+ if (curMap[k] && liveNames[k]) map[k] = curMap[k]
1022
+ }
1023
+ for (var i = 0; i < list.length; i++) {
1024
+ var u = list[i]
1025
+ if (u.updateAvailable === true) {
1026
+ map[u.name] = { latest: u.latest, updateAvailable: true, error: '', at: Date.now() }
1027
+ } else if (u.error) {
1028
+ // A prior reminder survives untouched with the transient error
1029
+ // alongside; WITHOUT one the failure itself lands in the map so an
1030
+ // all-failed first check cannot claim 全部为最新版本.
1031
+ map[u.name] = map[u.name]
1032
+ ? mergeReminder(map[u.name], { error: u.error || '' })
1033
+ : { latest: '', updateAvailable: false, error: u.error || '', at: Date.now() }
1034
+ } else if (u.latest !== null && u.latest !== undefined) {
1035
+ delete map[u.name]
1036
+ }
1037
+ }
1038
+ return map
1039
+ }
1040
+
1041
+ /** Aggregate summary line for a finished check (pure). */
1042
+ function updateCheckNote(map, checkedCount) {
1043
+ var updateCount = 0
1044
+ var checkErrorCount = 0
1045
+ for (var n in map) {
1046
+ if (map[n].updateAvailable === true) updateCount++
1047
+ if (map[n].error) checkErrorCount++
1048
+ }
1049
+ if (updateCount > 0) return '发现 ' + updateCount + ' 个插件有新版本'
1050
+ if (checkErrorCount > 0) {
1051
+ return '已检查 ' + checkedCount + ' 个插件(' + checkErrorCount + ' 个查询失败)'
1052
+ + (checkedCount > checkErrorCount ? ',其余均为最新版本' : '')
1053
+ }
1054
+ return checkedCount > 0 ? '已检查 ' + checkedCount + ' 个插件,均为最新版本' : ''
1055
+ }
1056
+
977
1057
  function McpSection(props) {
978
1058
  var pairMcp = useState({
979
1059
  mcpEntries: [],
@@ -1103,6 +1183,18 @@ function McpSection(props) {
1103
1183
  function saveMcpDraft() {
1104
1184
  var draft = mView.mcpDraft
1105
1185
  if (draft === null) return
1186
+ // A NEW entry must not collide with an existing id: the host's upsert
1187
+ // locates its target block by id and would replace that entry in place,
1188
+ // silently destroying its config. The edit form keeps its own id (the
1189
+ // field is disabled), so this guard only ever fires on the add form.
1190
+ if (draft.isNew) {
1191
+ for (var ci = 0; ci < mView.mcpEntries.length; ci++) {
1192
+ if (mView.mcpEntries[ci].id === draft.id) {
1193
+ patchMcp({ mcpError: 'ID \'' + draft.id + '\' 已被其他 MCP 条目占用,请换一个再保存。' })
1194
+ return
1195
+ }
1196
+ }
1197
+ }
1106
1198
  var config
1107
1199
  if (draft.transport === 'streamable-http') {
1108
1200
  config = { transport: 'streamable-http', serverName: draft.serverName, url: draft.url }
@@ -1305,7 +1397,8 @@ function renderPluginsView(view, patch, install, remove, checkUpdates, upgrade)
1305
1397
  key: 'btn-check-updates',
1306
1398
  className: 'btn',
1307
1399
  disabled: view.busy || view.checkingUpdates,
1308
- onClick: checkUpdates,
1400
+ title: '强制绕过 5 分钟缓存,重新查询 registry 并刷新缓存',
1401
+ onClick: function () { checkUpdates(true) },
1309
1402
  },
1310
1403
  view.checkingUpdates ? createElement('span', { className: 'spinner', key: 'spin' }) : null,
1311
1404
  view.checkingUpdates ? '检查中...' : '⬆️ 检查更新'
@@ -1356,7 +1449,9 @@ function renderPluginsView(view, patch, install, remove, checkUpdates, upgrade)
1356
1449
  // Update-check status strip: gives the auto-check a visible outcome instead
1357
1450
  // of hiding it in the footer. Checking → spinner; done with updates →
1358
1451
  // orange; done without updates → green; per-plugin query failures surface
1359
- // on the strip rather than vanishing.
1452
+ // on the strip rather than vanishing. Purely informational — the single
1453
+ // re-check entry point is the toolbar「⬆️ 检查更新」button (which forces a
1454
+ // fresh registry query), so no redundant button lives in the strip.
1360
1455
  if (view.checkingUpdates) {
1361
1456
  elements.push(createElement('div', { className: 'update-strip checking', key: 'update-checking' },
1362
1457
  createElement('span', { className: 'spinner', key: 'spin' }),
@@ -1377,14 +1472,7 @@ function renderPluginsView(view, patch, install, remove, checkUpdates, upgrade)
1377
1472
  ? '⚠️ 有 ' + errCount + ' 个插件查询版本失败(网络或 registry 不可达)'
1378
1473
  : '✅ 已自动检查版本更新,全部为最新版本')
1379
1474
  elements.push(createElement('div', { className: stripClass, key: 'update-result' },
1380
- createElement('span', { key: 't' }, stripText),
1381
- createElement('button', {
1382
- type: 'button',
1383
- key: 'btn-recheck',
1384
- className: 'btn sm',
1385
- disabled: view.busy || view.checkingUpdates,
1386
- onClick: checkUpdates,
1387
- }, '🔄 重新检查')
1475
+ createElement('span', { key: 't' }, stripText)
1388
1476
  ))
1389
1477
  }
1390
1478
 
package/lib/index.js CHANGED
@@ -1116,6 +1116,7 @@ export function apply(ctx) {
1116
1116
  // 'updateAvailable' flag is recomputed on every read against the CURRENT
1117
1117
  // installed version (see checkUpdates) — a still-outdated plugin keeps
1118
1118
  // flagging on each re-open, while one upgraded in between stops flagging.
1119
+ // A manual check (force) bypasses this cache and refreshes its content.
1119
1120
  const updateCache = new Map()
1120
1121
 
1121
1122
  const pluginService = {
@@ -1131,9 +1132,18 @@ export function apply(ctx) {
1131
1132
  * hit still recomputes `updateAvailable` against the current installed
1132
1133
  * version so the reminder survives repeated panel opens and clears only
1133
1134
  * once the plugin is actually upgraded.
1135
+ * When `force` is true the TTL cache is bypassed and every plugin is
1136
+ * re-queried against the registry, refreshing the cached `latest` values —
1137
+ * the toolbar 「⬆ 检查更新」button always forces, so a manual check really
1138
+ * checks instead of replaying a cached answer.
1139
+ * @param force - true to bypass the TTL cache and refresh cached content.
1134
1140
  * @returns { updates, checkedAt } where updates is per-plugin status.
1135
1141
  */
1136
- async checkUpdates() {
1142
+ async checkUpdates(force) {
1143
+ // Tolerate the RPC gateway passing the bound boolean or the raw args
1144
+ // object ({ force: true }) whichever way it arrives.
1145
+ const forceRefresh = force === true
1146
+ || (force !== null && typeof force === 'object' && force.force === true)
1137
1147
  const now = Date.now()
1138
1148
  const { plugins } = listLayers()
1139
1149
  const registry = resolveNpmRegistry(profileDir)
@@ -1142,7 +1152,9 @@ export function apply(ctx) {
1142
1152
  for (const plugin of plugins) {
1143
1153
  if (!plugin.dependency || plugin.localPath !== null) continue
1144
1154
  const hit = updateCache.get(plugin.name)
1145
- if (hit !== undefined && hit.latest !== null && now - hit.at < UPDATE_CHECK_CACHE_TTL_MS) {
1155
+ const cacheWarm = !forceRefresh && hit !== undefined && hit.latest !== null
1156
+ && now - hit.at < UPDATE_CHECK_CACHE_TTL_MS
1157
+ if (cacheWarm) {
1146
1158
  // Serve the cached 'latest' but recompute the flag against the
1147
1159
  // CURRENT installed version. The cached entry must carry
1148
1160
  // updateAvailable — a bare { name, latest } is read by the client
@@ -2001,6 +2013,7 @@ export function apply(ctx) {
2001
2013
  /* ---------------------- Typert Descriptors Register ---------------------- */
2002
2014
  const specParam = [{ name: 'spec', wire: 'spec', source: 'json', codec: { mode: 'src-json' } }]
2003
2015
  const nameParam = [{ name: 'name', wire: 'name', source: 'json', codec: { mode: 'src-json' } }]
2016
+ const forceParam = [{ name: 'force', wire: 'force', source: 'json', codec: { mode: 'src-json' } }]
2004
2017
  const sessionParam = [{ name: 'sessionId', wire: 'sessionId', source: 'json', codec: { mode: 'src-json' } }]
2005
2018
 
2006
2019
  ctx.effect(() => ctx.typert.register({
@@ -2043,7 +2056,7 @@ export function apply(ctx) {
2043
2056
  namespace: PLUGIN_NAMESPACE,
2044
2057
  method: 'checkUpdates',
2045
2058
  invocation: { kind: 'direct' },
2046
- parameters: [],
2059
+ parameters: forceParam,
2047
2060
  result: { mode: 'src-json' },
2048
2061
  },
2049
2062
  // sessionAdmin
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-admin",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "description": "dsh web UI plugin: unified administration panel — plugin and session management with modern tabbed Web UI (host remote + browser panel)",
6
6
  "main": "lib/index.js",