dsh-armory 1.9.1 → 1.9.3

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
@@ -25,7 +25,7 @@ dsh plugin --profile web add dsh-armory
25
25
  dsh plugin --profile web add github:2026heshao/dsh-armory
26
26
  ```
27
27
 
28
- Restart DSH, then open **Skills · MCP · Experts** from the global panel list at the top of the sidebar (or the floating button bottom-right). Desktop users on the `desktop` profile: swap `--profile web` for `--profile desktop`. You can also search `armory` and one-click install inside [dsh-market](https://github.com/dsh-market/dsh-market#readme).
28
+ Restart DSH, then open **Skills · MCP · Experts** from the global panel list at the top of the sidebar (or the floating button bottom-right). Desktop users on the `desktop` profile: swap `--profile web` for `--profile desktop`.
29
29
 
30
30
  ## What it solves
31
31
 
@@ -84,6 +84,11 @@ A **unified board** for DeepSeek Harness (Web and desktop): five top-level tabs
84
84
  - **Summoning** = appending one independent **agent preset** row to the profile patch (a `@deepseek-ai/dsh-agent-preset` entry that mirrors the built-in `standard` preset, with only the persona prefix swapped for the expert's full persona). New sessions then offer **WorkBuddy Expert · …** and that expert takes over the session, while the standard tools / planning / compaction / delegation stay intact. Multiple experts coexist; writes go through the existing patch pipeline (file lock + version check + atomic write), preserving other rows, comments and `!!js` expressions
85
85
  - **Bundled skills**: tick to install them too — a full local WorkBuddy cache (`~/.workbuddy/plugins/cache/experts/`) is copied wholesale into `~/.agents/skills/` (prefixed `wbexpert__`, discovered by the skill registry immediately); without a cache it falls back to the CDN's single `SKILL.md` and labels the source honestly
86
86
  - **Dismissing** removes that preset and its skill directory (hot-reloaded, no restart); team-type experts (expert team) are read-only for now
87
+ - **Summon menu (in-session quick access, enhanced in 1.9.3)** — the small board icon at the right of the composer toolbar opens three categories (**Skills / MCP / Experts**); hovering a category expands its submenu, and clicking an entry writes a reference into the composer (skills = `/skill-name`, MCP = names the `mcp__server__*` tools, experts = attaches to the current session):
88
+ - **Six rows per screen, scrollable**: a submenu shows six entries at a time and scrolls for the rest (thin themed scrollbar), while the **Manage skills / MCP / experts…** button stays pinned to the bottom instead of scrolling away
89
+ - **Most-recently-used first**: entries you just used float to the top with a "recent" badge, the rest keep their original order; the recency pool holds 40 entries
90
+ - **Experts attach / swap / detach at any time**: click one to take over the current session (including one already mid-conversation), click another to replace it, click the chip's × to drop it. It affects **only that session** — no tool catalog changes, nothing persisted
91
+ - **What you see is what's active**: once an expert is attached, the session header shows the expert that is **actually in effect** (the host's "preset chosen at session creation" label steps aside while an expert is attached); the header badge, the composer chip and the menu stay in sync within the same frame after attach / swap / detach
87
92
  - Import MCP configuration from other clients installed on this machine: Claude Code, Codex (`config.toml`), OpenCode (including `.jsonc`), pi, Claude Desktop, Cursor, Windsurf, VS Code, Gemini CLI, Roo Code, and the cross-tool shared config. Everything is previewed before writing, with "merge" and "replace" modes
88
93
  - Follows the DSH dark/light theme and adapts to narrow/mobile widths
89
94
  - Update notice and **one-click upgrade**: a dismissible banner when a newer release exists (at most one check per day; disable with `DSH_ARMORY_DISABLE_UPDATE_CHECK`). Nothing happens until you press Upgrade — it **never auto-updates**; the upgrade itself is executed by DSH's official pluginManager, which handles version checks and rollback. Local dev copies installed via `link:` / `file:` / `github:` get no button (it would replace your link with an npm package)
package/README.md CHANGED
@@ -25,7 +25,7 @@ dsh plugin --profile web add dsh-armory
25
25
  dsh plugin --profile web add github:2026heshao/dsh-armory
26
26
  ```
27
27
 
28
- 装完**重启 DSH**,侧栏顶部「全局面板」里点 **技能 · MCP · 专家**(或右下角悬浮按钮)。桌面端装在 `desktop` profile 时把 `--profile web` 换成 `--profile desktop`。也可以在装了 [dsh-market](https://github.com/dsh-market/dsh-market#readme) 的 DSH 里搜 `armory` 一键安装。
28
+ 装完**重启 DSH**,侧栏顶部「全局面板」里点 **技能 · MCP · 专家**(或右下角悬浮按钮)。桌面端装在 `desktop` profile 时把 `--profile web` 换成 `--profile desktop`。
29
29
 
30
30
  ## 它解决什么
31
31
 
@@ -74,6 +74,11 @@ dsh plugin --profile web add github:2026heshao/dsh-armory
74
74
  - **召唤** = 往 profile 补丁写入一条**独立 Agent 预设**(`@deepseek-ai/dsh-agent-preset` 行,plugins 复刻内置 standard 预设、仅 persona 的 prefix 换成专家人设全文)——新建会话时选择「WorkBuddy 专家 · …」即专家接管该会话,标准工具/规划/压缩/委派原样保留;多个专家可共存,写入走既有 patch 写链(文件锁 + 版本校验 + 原子写),其余条目、注释与 `!!js` 表达式原样保留
75
75
  - **捆绑技能**:召唤时可勾选一并安装——本机 WorkBuddy 缓存(`~/.workbuddy/plugins/cache/experts/`)有完整技能树就整目录复制到 `~/.agents/skills/`(`wbexpert__` 前缀,即刻被技能注册表发现);无缓存时按 CDN 的 SKILL.md 单文件兜底并如实标注来源
76
76
  - **送还** = 摘除该召唤预设与对应技能目录(热加载,无需重启);团队型(expert team)暂只读展示
77
+ - **召唤菜单(会话内快速调用,1.9.3 增强)**:会话输入框工具行右侧的看板小图标 → 「技能 / MCP / 专家」三分类,悬停展开子菜单,点一条即把引用写进输入框(技能 = `/技能名`、MCP = 点名 `mcp__服务__*`、专家 = 挂到当前会话)——
78
+ - **每屏 6 行 + 滚动**:子菜单一次显示 6 条,超出的往下滚(细滚动条跟随主题);「管理技能 / MCP / 专家…」固定在底部,不被列表滚走
79
+ - **最近使用置顶**:刚用过的条目自动排到最前并带「最近」标记,其余保持原有顺序;最近使用记录池 40 条
80
+ - **专家随时挂 / 换 / 摘**:点一个专家即接管当前会话(包括已经聊了一半的),再点别的直接替换,点 chip 的 × 立刻取消;**只影响这一个会话**,不动工具目录、不写任何持久化配置
81
+ - **显示与实效一致**:挂专家后,会话标题栏显示的始终是**当前真正生效**的专家(宿主那条「会话创建时的预设」标签在挂载期间自动让位);三处显示(标题栏徽章、输入框 chip、菜单)挂/换/摘后同帧同步
77
82
 
78
83
  ## 安装
79
84
 
package/lib/client.js CHANGED
@@ -352,32 +352,52 @@ const MCP_CSS = `body{--mcp-accent:var(--dsw-alias-brand-primary);--mcp-ok:var(-
352
352
  .dsh-mcp-quick-count{margin-left:auto;color:var(--mcp-accent);font-size:11px;font-variant-numeric:tabular-nums}
353
353
  .dsh-mcp-quick-arrow{margin-left:5px;color:var(--mcp-mut);font-size:11px}
354
354
  /* 子菜单:与锚点行垂直居中(top:50% + translateY(-50%)),比顶对齐更贴近 WorkBuddy;
355
- 出场动画只动 opacity/transform(动效克制),替代上一版「整块突现」的残影来源。 */
356
- .dsh-mcp-quick-sub{position:absolute;top:50%;left:calc(100% + 6px);z-index:951;width:216px;max-height:min(264px,40vh);overflow:auto;padding:5px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-lg);background:var(--mcp-bg-1);box-shadow:var(--dsw-elevation-overlay,0 8px 28px rgba(0,0,0,.14));transform:translateY(-50%);transform-origin:left center;animation:dshMcpQuickIn .13s ease-out}
355
+ 出场动画只动 opacity/transform(动效克制),替代上一版「整块突现」的残影来源。
356
+ 面板本体 max-height 由「6 行列表 + 底部固定管理按钮」精确算出(6×28 + 5×2 padding
357
+ + 30 按钮 + 4 间距 + 边框),列表自身滚动,管理按钮永远贴底可见。 */
358
+ .dsh-mcp-quick-sub{position:absolute;top:50%;left:calc(100% + 6px);z-index:951;display:flex;flex-direction:column;width:232px;max-height:min(216px,40vh);padding:5px;border:1px solid var(--mcp-line);border-radius:var(--mcp-r-lg);background:var(--mcp-bg-1);box-shadow:var(--dsw-elevation-overlay,0 8px 28px rgba(0,0,0,.14));transform:translateY(-50%);transform-origin:left center;animation:dshMcpQuickIn .13s ease-out}
359
+ /* 可滚动列表:最多 6 行(6 × 28px),溢出即滚动;隐藏水平溢出防止拖宽面板 */
360
+ .dsh-mcp-quick-list{min-height:0;max-height:168px;overflow-y:auto;overflow-x:hidden;border-radius:var(--mcp-r-sm)}
361
+ /* 细滚动条(跟随主题 token,悬停加深;hover 之外 6px 宽半透明,几乎不占视觉) */
362
+ .dsh-mcp-quick-list{scrollbar-width:thin;scrollbar-color:color-mix(in srgb,var(--mcp-line) 90%,transparent) transparent}
363
+ .dsh-mcp-quick-list::-webkit-scrollbar{width:6px}
364
+ .dsh-mcp-quick-list::-webkit-scrollbar-track{background:transparent}
365
+ .dsh-mcp-quick-list::-webkit-scrollbar-thumb{background:color-mix(in srgb,var(--mcp-line) 90%,transparent);border-radius:3px}
366
+ .dsh-mcp-quick-list:hover::-webkit-scrollbar-thumb{background:color-mix(in srgb,var(--dsw-alias-label-tertiary) 45%,transparent)}
367
+ .dsh-mcp-quick-list:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:-1px}
357
368
  /* 视口下缘不够时 JS 加 flip:子菜单改为向锚点行上方展开 */
358
369
  .dsh-mcp-quick-row.flip .dsh-mcp-quick-sub{top:auto;bottom:50%;transform:translateY(50%)}
359
370
  .dsh-mcp-quick-row.flip .dsh-mcp-quick-sub{animation-name:dshMcpQuickInFlip}
360
371
  @keyframes dshMcpQuickIn{from{opacity:0;transform:translateY(-50%) translateX(-4px)}to{opacity:1;transform:translateY(-50%)}}
361
372
  @keyframes dshMcpQuickInFlip{from{opacity:0;transform:translateY(50%) translateX(-4px)}to{opacity:1;transform:translateY(50%)}}
362
373
  @media (prefers-reduced-motion: reduce){.dsh-mcp-quick-sub{animation:none}}
363
- .dsh-mcp-quick-sub.empty{padding:9px 11px;color:var(--mcp-mut);font-size:12px}
364
- .dsh-mcp-quick-sub-note{padding:6px 9px;color:var(--mcp-mut);font-size:11px}
365
- .dsh-mcp-quick-manage{display:flex;align-items:center;width:100%;margin-top:4px;padding:6px 9px;border:none;border-top:1px solid var(--mcp-line);border-radius:0 0 var(--mcp-r-sm) var(--mcp-r-sm);background:transparent;color:var(--mcp-accent);font-family:inherit;font-size:11px;text-align:left;cursor:pointer;transition:background .12s}
374
+ .dsh-mcp-quick-sub.empty{display:block;padding:9px 11px;color:var(--mcp-mut);font-size:12px}
375
+ .dsh-mcp-quick-sub-note{padding:6px 9px;color:var(--mcp-mut);font-size:11px;white-space:normal}
376
+ .dsh-mcp-quick-manage{display:flex;align-items:center;flex:none;width:100%;margin-top:4px;padding:6px 9px;border:none;border-top:1px solid var(--mcp-line);border-radius:0 0 var(--mcp-r-sm) var(--mcp-r-sm);background:transparent;color:var(--mcp-accent);font-family:inherit;font-size:11px;text-align:left;cursor:pointer;transition:background .12s}
366
377
  .dsh-mcp-quick-manage:hover{background:var(--dsw-alias-interactive-bg-hover)}
367
378
  .dsh-mcp-quick-manage:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:-1px}
368
- .dsh-mcp-quick-item{display:flex;align-items:baseline;gap:7px;width:100%;padding:6px 9px;border:none;border-radius:var(--mcp-r-sm);background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:12px;line-height:16px;text-align:left;cursor:pointer;transition:background .12s}
379
+ .dsh-mcp-quick-item{display:flex;align-items:baseline;gap:7px;flex:none;width:100%;height:28px;padding:6px 9px;border:none;border-radius:var(--mcp-r-sm);background:transparent;color:var(--dsw-alias-label-primary);font-family:inherit;font-size:12px;line-height:16px;text-align:left;cursor:pointer;transition:background .12s}
369
380
  .dsh-mcp-quick-item:hover{background:var(--dsw-alias-interactive-bg-hover)}
370
381
  .dsh-mcp-quick-item:focus-visible{outline:2px solid var(--mcp-accent);outline-offset:-1px}
371
382
  .dsh-mcp-quick-item-name{flex:none;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
372
383
  .dsh-mcp-quick-item-badge{flex:none;padding:1px 5px;border-radius:var(--mcp-r-xs);background:color-mix(in srgb,var(--mcp-accent) 14%,transparent);color:var(--mcp-accent);font-size:10px;line-height:12px}
373
384
  .dsh-mcp-quick-item-desc{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--mcp-mut);font-size:11px}
374
385
  .dsh-mcp-quick-loading,.dsh-mcp-quick-err{padding:9px 11px;color:var(--mcp-mut);font-size:12px}
386
+ /* ── 会话头「当前生效专家」徽章(SessionExpertBadge)─────────────────────
387
+ 尺寸/配色对齐宿主 AgentPresetLabel(.b06baG_label:22px 高、12px 字号、
388
+ tertiary 色、180px 上限),并排显示不突兀;窄窗(≤540px 容器)跟随宿主隐藏。
389
+ 宿主用 container query,这里没有对应容器名,用媒体查询近似同一断点。 */
390
+ .dsh-mcp-head-expert{display:inline-flex;align-items:center;gap:4px;max-width:180px;height:22px;padding:0 2px 0 0;border-radius:var(--dsw-radius-xs,var(--mcp-r-sm));background:var(--dsw-alias-fill-tsp-secondary,transparent);color:var(--dsw-alias-label-tertiary,var(--mcp-mut));font-size:12px;line-height:22px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
391
+ .dsh-mcp-head-expert-icon{flex:none;opacity:.7}
392
+ .dsh-mcp-head-expert-name{min-width:0;overflow:hidden;text-overflow:ellipsis}
393
+ /* 陈旧预设标签:挂上专家后由 SessionExpertBadge 动态加此 class(只命中带
394
+ WorkBuddy 专家 前缀的那条宿主标签)。不删节点、不改 DOM 结构,只视觉隐身——
395
+ 摘掉专家时 class 被移除,标签原样回来(React 始终是这些节点的所有者)。 */
396
+ .dsh-mcp-hide-stale-preset{display:none!important}
397
+ @media (max-width:560px){.dsh-mcp-head-expert{display:none}}
375
398
  .dsh-mcp-quick-note{padding:7px 11px;margin-bottom:4px;border-radius:var(--mcp-r-sm);background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);font-size:11px;line-height:15px;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden;white-space:normal;word-break:break-word}
376
399
  .dsh-mcp-quick-note.err{color:var(--mcp-err)}
377
400
  .dsh-mcp-quick-note.ok{color:var(--mcp-ok)}
378
- .dsh-mcp-quick-new-session{width:calc(100% - 8px);margin:0 4px 6px;border:1px solid color-mix(in srgb,var(--mcp-accent) 35%,transparent);border-radius:var(--mcp-r-sm);background:color-mix(in srgb,var(--mcp-accent) 10%,transparent);color:var(--mcp-accent);font:inherit;font-size:12px;line-height:26px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:6px;white-space:nowrap;overflow:hidden}
379
- .dsh-mcp-quick-new-session:hover{background:color-mix(in srgb,var(--mcp-accent) 18%,transparent)}
380
- .dsh-mcp-quick-new-session span{overflow:hidden;text-overflow:ellipsis}
381
401
  .dsh-mcp-quick-err{color:var(--mcp-err)}
382
402
  /* ── 主面板页(main slot,和「插件」同级的入口页)────────────────────────
383
403
  入口型页面要用 --dsh-frame-top-clearance 让出窗口顶带(官方 ui-layout 的约定:
@@ -2859,9 +2879,25 @@ const ExpertMarket = ({ call }) => {
2859
2879
  const QUICK_MENU_KINDS = [
2860
2880
  { key: 'skill', label: '技能', icon: 'skill', hint: '技能按需触发:选择后以 /技能名 插入输入框' },
2861
2881
  { key: 'mcp', label: 'MCP', icon: 'plug', hint: 'MCP 工具按 server 生效:选择后点名 mcp__服务__* 工具' },
2862
- { key: 'expert', label: '专家', icon: 'expert', hint: '专家挂到当前会话:整个会话由该专家人设接管' },
2882
+ { key: 'expert', label: '专家', icon: 'expert', hint: '专家随时挂/换/摘:点一个即接管本会话,再点别的直接换,点 chip 的 × 取消' },
2863
2883
  ];
2864
- const QUICK_RECENT_LIMIT = 6;
2884
+ // 子菜单列表每屏固定 6 行(6 × 28px),更多条目在 .dsh-mcp-quick-list 里纵向滚动;
2885
+ // 「最近」徽章只标最近池里的前 6 名(首屏可见的那批),避免整列都是徽章。
2886
+ // 专家预设显示名前缀:召唤时写死的 `WorkBuddy 专家 · <显示名>`(见 expert-market.js
2887
+ // buildExpertPresetRow),宿主的会话头预设标签原样显示它——用来识别「那条会过期的标签」。
2888
+ const EXPERT_PRESET_LABEL_PREFIX = 'WorkBuddy 专家 · ';
2889
+ // 专家挂载状态的最小事件总线:快速菜单挂/换/摘专家后立刻通知会话头徽章与菜单 chip,
2890
+ // 三处显示同一帧内对齐(不再各自等重挂载)。低频轮询只作兜底,覆盖「别的入口挂的专家」。
2891
+ const expertChangeListeners = new Set();
2892
+ const emitExpertChange = () => {
2893
+ for (const listener of [...expertChangeListeners]) {
2894
+ try { listener(); } catch { /* 单个订阅者失败不影响其它订阅者 */ }
2895
+ }
2896
+ };
2897
+ const onExpertChange = (listener) => {
2898
+ expertChangeListeners.add(listener);
2899
+ return () => { expertChangeListeners.delete(listener); };
2900
+ };
2865
2901
  // 引用文本格式(对齐宿主真实语义,避免「同名空文档」):
2866
2902
  // skill → /name:slash token 命中技能源 lexicon(dsh-client-ui-skill 的 skill source,
2867
2903
  // trigger "/"),发送后走官方 skill gesture,模型真正调起技能——@name 会落进
@@ -2893,7 +2929,8 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
2893
2929
  const cancelClose = () => { if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; } };
2894
2930
  // scheduleClose 在 JSX 前定义(rootHoverProps 用它);此处不重复声明。
2895
2931
 
2896
- // 菜单打开时拉一次各类清单(每类 ≤6)。三类并行取,任何一类失败不影响其它类。
2932
+ // 菜单打开时拉一次各类清单。清单取全量(不做 6 条截断),交给子菜单自己
2933
+ // 「最近使用置顶 + 每屏 6 行滚动」展示;三类并行取,任何一类失败不影响其它类。
2897
2934
  // MCP 行字段是 serverName(gateway list() 的行形状,与面板列表同一来源);禁用的不进菜单。
2898
2935
  const loadRecent = useCallback(async () => {
2899
2936
  setListError('');
@@ -2904,9 +2941,9 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
2904
2941
  call('wbExpertInstalled'),
2905
2942
  ]);
2906
2943
  const kinds = recentRes?.ok && recentRes.kinds ? recentRes.kinds : {};
2907
- const skillRows = skillsRes?.ok && Array.isArray(skillsRes.skills) ? skillsRes.skills.slice(0, QUICK_RECENT_LIMIT) : [];
2908
- const mcpRows = mcpRes?.ok && Array.isArray(mcpRes.servers) ? mcpRes.servers.filter((server) => !server.disabled && server.serverName).slice(0, QUICK_RECENT_LIMIT) : [];
2909
- const expertRows = expertRes?.ok && Array.isArray(expertRes.presets) ? expertRes.presets.slice(0, QUICK_RECENT_LIMIT) : [];
2944
+ const skillRows = skillsRes?.ok && Array.isArray(skillsRes.skills) ? skillsRes.skills : [];
2945
+ const mcpRows = mcpRes?.ok && Array.isArray(mcpRes.servers) ? mcpRes.servers.filter((server) => !server.disabled && server.serverName) : [];
2946
+ const expertRows = expertRes?.ok && Array.isArray(expertRes.presets) ? expertRes.presets : [];
2910
2947
  setRecent({
2911
2948
  skill: skillRows.map((row) => ({ name: row.name, description: row.description || row.whenToUse || '' })),
2912
2949
  mcp: mcpRows.map((row) => ({ name: row.serverName, description: row.url || row.command || row.transport || '' })),
@@ -3015,8 +3052,8 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3015
3052
  } catch { /* 剪贴板不可用:静默 */ }
3016
3053
  };
3017
3054
 
3018
- // 子菜单排序:最近使用的条目按 usedAt 新→旧置顶(带「最近」标记),未命中最近记录的
3019
- // 排在后面保持注册表原序。主键 per kind:skill/mcp=条目名,expert=plugin。
3055
+ // 子菜单排序:最近使用的条目按 usedAt 新→旧置顶(带「最近」标记,只标首屏 6 个),
3056
+ // 未命中最近记录的排在后面保持注册表原序。主键 per kind:skill/mcp=条目名,expert=plugin。
3020
3057
  const recentListFor = (kind) => (kind === 'skill' ? recent.recentSkill : kind === 'mcp' ? recent.recentMcp : recent.recentExpert);
3021
3058
  const itemKeyOf = (kind, item) => String(kind === 'expert' ? (item.plugin || item.name || '') : (item.name || ''));
3022
3059
  const orderSubItems = (kind, items) => {
@@ -3030,7 +3067,7 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3030
3067
  return { item, index, rank: rank === undefined ? Number.MAX_SAFE_INTEGER : rank, usedAt: rank === undefined ? 0 : recentRows[rank].usedAt || 0 };
3031
3068
  })
3032
3069
  .sort((a, b) => a.rank - b.rank || (b.usedAt - a.usedAt) || a.index - b.index)
3033
- .map((entry) => ({ ...entry.item, __recentRank: entry.rank }));
3070
+ .map((entry) => ({ ...entry.item, __recentRank: entry.rank, __badge: entry.rank < 6 }));
3034
3071
  };
3035
3072
 
3036
3073
  const subItems = (kind) => {
@@ -3042,109 +3079,82 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3042
3079
  const itemLabel = (kind, item) => (kind === 'expert' ? item.name || item.plugin : item.name);
3043
3080
  const itemDesc = (kind, item) => (kind === 'mcp' ? (item.transport ? item.transport : '') : (item.description || '').slice(0, 48));
3044
3081
 
3045
- // ── 挂专家(WorkBuddy 式):条目点击 = agentPresets.select(sessionId, presetId) ──
3046
- // 官方会话头部的预设选择器同款通道:blank 会话立即生效(整个会话由专家人设接管);
3047
- // 已开始的会话宿主拒绝(agent-preset/locked),此时提示改为新会话生效。挂载状态
3048
- // 由本组件自持(chip 显示 + 移除按钮);移除 = select 回默认预设。
3082
+ // ── 挂专家(WorkBuddy 式:随时挂 / 换 / 摘)──────────────────────────────
3083
+ // 主通道是宿主新的会话级人设 RPC expertAttach/expertDetach:它把专家人设作为
3084
+ // systemPrompt 的「本会话作用域 section」注入(与 dsh-persona / dsh-subagent 同款
3085
+ // deployment:persona-prefix 遮蔽机制),因此:
3086
+ // - 不受 agent-preset/locked 限制 —— 会话已开过口也能挂、能换、能摘;
3087
+ // - 只影响这一个会话,不动工具目录、不写任何持久化配置;
3088
+ // - 反复点不同专家 = 直接替换(Host 先摘旧的再装新的),不再是一次性。
3089
+ // 挂载只走人设通道(预设通道 agentPresets.select 会在会话投影里留下永不更新的
3090
+ // 专家名,造成「顶栏显示 A、实际生效 B」的分叉——见 SessionExpertBadge 注释)。
3049
3091
  // sessionId 来源:conversation.input.right 是 session 座位,slot 渲染时把 sessionId
3050
3092
  // 作为 prop 显式传入(apply() 注册处 inject: (sessionId) => ({ call, sessionId }));
3051
3093
  // 会话 summary 行本身不带 id 字段(id 只走 props),所以不能从 useSession 投影里取。
3052
- const MODULE = MODULE_CTX.ctx;
3053
- const [attached, setAttached] = useState(null); // { plugin, name, presetId }
3094
+ const [attached, setAttached] = useState(null); // { plugin, name, mode: 'persona' }
3054
3095
  const [attachBusy, setAttachBusy] = useState(false);
3055
3096
  const [attachNote, setAttachNote] = useState('');
3056
- // locked 后的「带专家新建会话」待办:宿主硬规则是已开始的会话不能换预设
3057
- // (agent-preset/locked),但官方 hero 预设选择器对 blank 会话可用——所以
3058
- // 提供「新建会话并挂上该专家」的一键路径:sessions.create() → agentPresets.select() →
3059
- // uiWorkspace.openSession(),与官方「新会话选预设」语义完全一致,只是自动化了。
3060
- const [pendingExpert, setPendingExpert] = useState(null);
3061
3097
  const sessionId = typeof sessionIdProp === 'string' && sessionIdProp !== '' ? sessionIdProp : null;
3062
- const expertPresetIdOf = (item) => 'wbexpert-' + String(item.plugin || '').toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
3063
- const createWithExpert = async (item) => {
3064
- const ctx = MODULE;
3065
- if (!ctx || typeof ctx.get !== 'function') { setAttachNote('宿主上下文不可用,无法新建会话'); return; }
3066
- const sessions = ctx.get('sessions');
3067
- const uiWorkspace = ctx.get('uiWorkspace');
3068
- const remote = ctx.get('remote.agentPresets');
3069
- if (!sessions || typeof sessions.create !== 'function') { setAttachNote('宿主未提供会话创建接口(sessions 不可用)'); return; }
3070
- if (!uiWorkspace || typeof uiWorkspace.openSession !== 'function') { setAttachNote('宿主未提供会话导航接口(uiWorkspace 不可用)'); return; }
3071
- if (!remote || typeof remote.select !== 'function') { setAttachNote('宿主未提供 agentPresets 接口(remote.agentPresets 不可用)'); return; }
3072
- setAttachBusy(true); setAttachNote('');
3073
- try {
3074
- // 继承当前会话的工作目录(拿不到就让宿主用默认 workspace)。
3075
- const cwd = sessionId && sessions.list && typeof sessions.list.getSnapshot === 'function'
3076
- ? sessions.list.getSnapshot().byId[sessionId]?.cwd : undefined;
3077
- // ClientSessions.create 成功直接返回 sessionId 字符串,失败抛 SessionCreateError
3078
- // (workspace-client 的 reuseOrCreateBlank 同款走法)。
3079
- const newId = await sessions.create(cwd ? { cwd } : {});
3080
- if (typeof newId !== 'string' || !newId) throw new Error('创建会话未返回会话 id');
3081
- const presetId = expertPresetIdOf(item);
3082
- const selected = await remote.select(newId, presetId);
3083
- if (!selected || !selected.ok) {
3084
- throw new Error('专家预设挂载失败:' + ((selected && selected.error && (selected.error.message || selected.error.code)) || '未知错误'));
3085
- }
3086
- uiWorkspace.openSession(newId);
3087
- setAttached({ plugin: item.plugin, name: item.name || item.plugin, presetId: (selected.value && selected.value) || presetId });
3088
- setPendingExpert(null);
3089
- setAttachNote('✓ 已新建专家会话:' + (item.name || item.plugin) + '(人设全程接管)');
3090
- call('wbExpertRecordUse', { kind: 'expert', row: { plugin: item.plugin, name: item.name || item.plugin } }).catch(() => {});
3091
- } catch (e) {
3092
- setAttachNote('新建专家会话失败:' + String((e && e.message) || e));
3093
- } finally {
3094
- setAttachBusy(false);
3095
- }
3096
- };
3097
- const attachExpert = async (item) => {
3098
+ const attachExpert = async (item, { quiet = false } = {}) => {
3098
3099
  setHoverKind(null);
3099
- setPendingExpert(null);
3100
- // 结果显示回菜单内(菜单不关):成功 ✓ + 挂载名;失败显示真实报错。
3101
- // 上一版把 attachNote 从界面拿掉导致失败无声,用户无从判断「为什么没显示」。
3102
- if (!sessionId) { setPendingExpert(item); setAttachNote('没有活动会话:可直接带该专家新建会话'); return; }
3103
- const presetId = expertPresetIdOf(item);
3104
- const remote = MODULE && typeof MODULE.get === 'function' ? MODULE.get('remote.agentPresets') : null;
3105
- if (!remote || typeof remote.select !== 'function') { setAttachNote('宿主未提供 agentPresets 接口(remote.agentPresets 不可用)'); return; }
3100
+ if (!sessionId) { setAttachNote('没有活动会话:请先进入一个会话再挂专家'); return; }
3106
3101
  setAttachBusy(true); setAttachNote('');
3107
3102
  try {
3108
- const result = await remote.select(sessionId, presetId);
3109
- if (result && result.ok) {
3110
- setAttached({ plugin: item.plugin, name: item.name || item.plugin, presetId: (result.value && result.value) || presetId });
3111
- setAttachNote('✓ 已挂到当前会话:' + (item.name || item.plugin));
3103
+ // 只走会话级人设通道(expertAttach):随时挂 / 换 / 摘,会话开过口也不锁。
3104
+ // 不再先试 agentPresets.select 预设通道——它成功时会把专家预设写进会话投影,
3105
+ // 会话顶栏从此固定显示那个专家,且会话一旦开过口 select 永久锁死,换专家
3106
+ // 只换人设不动投影 → 「顶栏显示 A、实际生效 B」的假象。人设通道两边一致。
3107
+ const res = await call('expertAttach', { sessionId, plugin: item.plugin });
3108
+ if (res && res.ok) {
3109
+ setAttached({ plugin: item.plugin, name: res.name || item.name || item.plugin, mode: 'persona' });
3110
+ setAttachNote('✓ 已挂到当前会话:' + (res.name || item.name || item.plugin) + (res.replaced ? '(已替换原专家)' : ''));
3112
3111
  call('wbExpertRecordUse', { kind: 'expert', row: { plugin: item.plugin, name: item.name || item.plugin } }).catch(() => {});
3112
+ // 立即广播:会话头徽章 + 陈旧预设标签的隐藏同步生效,三处显示同帧对齐。
3113
+ emitExpertChange();
3113
3114
  } else {
3114
- const locked = result && result.error && /locked/i.test(String(result.error.code || '') + String(result.error.message || ''));
3115
- if (locked) {
3116
- // 宿主硬规则:会话已开始(有 turn)就不允许换预设。给出一键替代路径
3117
- // 而不是让用户自己去预设选择器里翻——见 createWithExpert。
3118
- setPendingExpert(item);
3119
- setAttachNote('该会话已开始对话,无法中途挂专家(宿主规则:预设只在空白会话可换);可一键带该专家新建会话');
3120
- } else {
3121
- setAttachNote('挂载失败:' + ((result && result.error && (result.error.message || result.error.code)) || JSON.stringify(result && result.error) || '未知错误'));
3122
- }
3115
+ setAttachNote('挂载失败:' + ((res && res.error) || '未知错误'));
3123
3116
  }
3124
3117
  } catch (e) {
3125
- setAttachNote('挂载异常:' + String((e && e.message) || e));
3118
+ if (!quiet) setAttachNote('挂载异常:' + String((e && e.message) || e));
3126
3119
  } finally {
3127
3120
  setAttachBusy(false);
3128
3121
  }
3129
3122
  };
3130
3123
  const detachExpert = async () => {
3131
- if (!attached || !sessionId) return;
3132
- const remote = MODULE && typeof MODULE.get === 'function' ? MODULE.get('remote.agentPresets') : null;
3133
- if (!remote || typeof remote.list !== 'function' || typeof remote.select !== 'function') { setAttached(null); return; }
3134
- setAttachBusy(true);
3124
+ if (!sessionId) return;
3125
+ setAttachBusy(true); setAttachNote('');
3135
3126
  try {
3136
- const list = await remote.list();
3137
- const options = list && list.ok && Array.isArray(list.presets) ? list.presets : [];
3138
- const fallback = (options.find((row) => row.isDefault) || options[0] || {}).id;
3139
- if (fallback) await remote.select(sessionId, fallback);
3127
+ // 只清会话级人设。挂载只走人设通道(见 attachExpert),不存在「当初走了预设」
3128
+ // 的分支,也就无需回退默认预设——预设选择器保持用户自己的选择,不被插件触碰。
3129
+ const res = await call('expertDetach', { sessionId }).catch(() => null);
3140
3130
  setAttached(null);
3141
- setAttachNote('');
3131
+ setAttachNote(res && res.ok && res.removed ? '✓ 已取消本会话的专家' : '本会话没有挂着专家');
3132
+ // 摘掉也要广播:会话头徽章退场、被隐身的预设标签复原。
3133
+ emitExpertChange();
3142
3134
  } catch (e) {
3143
- setAttachNote(String((e && e.message) || e));
3135
+ setAttachNote('取消失败:' + String((e && e.message) || e));
3144
3136
  } finally {
3145
3137
  setAttachBusy(false);
3146
3138
  }
3147
3139
  };
3140
+ // 会话切换 / 组件重挂时对齐真实状态:宿主是唯一真源(专家可能由别的入口挂上,
3141
+ // 或会话被重启),不能只信组件自己的 state。挂/换/摘后由总线立即触发;
3142
+ // 5s 低频轮询兜底外部入口的变更(代价极小:expertActive 只读内存表)。
3143
+ useEffect(() => {
3144
+ if (!sessionId) return undefined;
3145
+ let cancelled = false;
3146
+ const sync = () => {
3147
+ call('expertActive').then((res) => {
3148
+ if (cancelled || !res || !res.ok || !Array.isArray(res.sessions)) return;
3149
+ const mine = res.sessions.find((row) => row.sessionId === sessionId);
3150
+ setAttached(mine ? { plugin: mine.plugin, name: mine.name, mode: 'persona' } : null);
3151
+ }).catch(() => { /* 对齐失败保持现状:不影响使用 */ });
3152
+ };
3153
+ sync();
3154
+ const off = onExpertChange(sync);
3155
+ const poll = setInterval(sync, 5000);
3156
+ return () => { cancelled = true; off(); clearInterval(poll); };
3157
+ }, [sessionId, call]);
3148
3158
 
3149
3159
  // ── 悬停开合(整树统一管理)───────────────────────────────────────────
3150
3160
  // enter/leave 挂组件根,而不是每行:菜单顶部还有提示条/错误行/新建会话按钮,
@@ -3161,7 +3171,7 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3161
3171
  // 挂载的专家:图标左侧的内联小 chip(与 trigger 同一行,不撑宽不换行)。
3162
3172
  // attachNote 不再渲染独立气泡——错误/提示走 trigger 的 title(悬停可见)。
3163
3173
  attached
3164
- ? h('span', { className: 'dsh-mcp-quick-chip', title: '本会话由该专家人设接管;点 × 摘掉切回默认预设' },
3174
+ ? h('span', { className: 'dsh-mcp-quick-chip', title: '本会话由该专家人设接管;点 × 摘掉(不动预设选择器)' },
3165
3175
  h(Icon, { d: ICONS.expert, size: 11 }),
3166
3176
  h('span', { className: 'dsh-mcp-quick-chip-name' }, attached.name),
3167
3177
  h('button', {
@@ -3187,19 +3197,6 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3187
3197
  copied ? h('div', { className: 'dsh-mcp-quick-note' }, '已复制,粘贴到输入框:' + copied) : null,
3188
3198
  attachBusy ? h('div', { className: 'dsh-mcp-quick-note' }, '正在挂载专家…') : null,
3189
3199
  attachNote ? h('div', { className: 'dsh-mcp-quick-note' + (/^✓/.test(attachNote) ? ' ok' : ' err'), role: 'alert' }, attachNote) : null,
3190
- // locked / 无会话时的一键替代路径:带该专家新建会话(createWithExpert)。
3191
- // 按钮挂在 attachNote 下方,只在该动作可用时出现;busy 期间禁用防连点。
3192
- pendingExpert && !attachBusy
3193
- ? h('button', {
3194
- type: 'button',
3195
- className: 'dsh-mcp-quick-new-session',
3196
- title: '新建一个空白会话并立即挂上「' + (pendingExpert.name || pendingExpert.plugin) + '」预设,人设全程接管',
3197
- onClick: (e) => { e.stopPropagation(); createWithExpert(pendingExpert); },
3198
- },
3199
- h(Icon, { d: ICONS.expert, size: 12 }),
3200
- h('span', null, '带「' + (pendingExpert.name || pendingExpert.plugin) + '」新建会话'),
3201
- )
3202
- : null,
3203
3200
  listError ? h('div', { className: 'dsh-mcp-quick-err' }, listError) : null,
3204
3201
  !recent && !listError ? h('div', { className: 'dsh-mcp-quick-loading' }, '正在读取…') : null,
3205
3202
  QUICK_MENU_KINDS.map((kind) => {
@@ -3232,22 +3229,24 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3232
3229
  hoverKind === kind.key
3233
3230
  ? h('div', { className: 'dsh-mcp-quick-sub' + (Array.isArray(items) && items.length ? '' : ' empty'), role: 'menu', 'aria-label': kind.label + '可选项' },
3234
3231
  Array.isArray(items) && items.length
3235
- ? items.map((item, index) => h('button', {
3236
- key: item.name || item.plugin || index,
3237
- type: 'button',
3238
- role: 'menuitem',
3239
- className: 'dsh-mcp-quick-item',
3240
- title: kind.key === 'expert' ? '挂到当前会话:整个会话由该专家接管' : (itemDesc(kind.key, item) || itemLabel(kind.key, item)),
3241
- onClick: (e) => { e.stopPropagation(); if (kind.key === 'expert') attachExpert(item); else pick(kind.key, item, quickMentionText(kind.key, item)); },
3242
- },
3243
- kind.key === 'expert' && attached && attached.plugin === item.plugin
3244
- ? h(Badge, { cls: 's-on' }, '已挂')
3245
- : typeof item.__recentRank === 'number' && item.__recentRank !== Number.MAX_SAFE_INTEGER
3246
- ? h('span', { className: 'dsh-mcp-quick-item-badge', 'aria-label': '最近使用' }, '最近')
3247
- : null,
3248
- h('span', { className: 'dsh-mcp-quick-item-name' }, itemLabel(kind.key, item)),
3249
- itemDesc(kind.key, item) ? h('span', { className: 'dsh-mcp-quick-item-desc' }, itemDesc(kind.key, item)) : null,
3250
- ))
3232
+ ? h('div', { className: 'dsh-mcp-quick-list', role: 'group', 'aria-label': kind.label + '列表(最多显示 6 行,滚动查看更多)' },
3233
+ items.map((item, index) => h('button', {
3234
+ key: item.name || item.plugin || index,
3235
+ type: 'button',
3236
+ role: 'menuitem',
3237
+ className: 'dsh-mcp-quick-item',
3238
+ title: kind.key === 'expert' ? '挂到当前会话(可随时换或取消);再点别的专家 = 直接替换' : (itemDesc(kind.key, item) || itemLabel(kind.key, item)),
3239
+ onClick: (e) => { e.stopPropagation(); if (kind.key === 'expert') attachExpert(item); else pick(kind.key, item, quickMentionText(kind.key, item)); },
3240
+ },
3241
+ kind.key === 'expert' && attached && attached.plugin === item.plugin
3242
+ ? h(Badge, { cls: 's-on' }, '已挂')
3243
+ : item.__badge
3244
+ ? h('span', { className: 'dsh-mcp-quick-item-badge', 'aria-label': '最近使用' }, '最近')
3245
+ : null,
3246
+ h('span', { className: 'dsh-mcp-quick-item-name' }, itemLabel(kind.key, item)),
3247
+ itemDesc(kind.key, item) ? h('span', { className: 'dsh-mcp-quick-item-desc' }, itemDesc(kind.key, item)) : null,
3248
+ )),
3249
+ )
3251
3250
  : h('div', { className: 'dsh-mcp-quick-sub-note' }, '暂无可选项'),
3252
3251
  h('button', {
3253
3252
  type: 'button',
@@ -3266,6 +3265,100 @@ const SummonQuickMenu = ({ call, inputActions, sessionId: sessionIdProp }) => {
3266
3265
  : null,
3267
3266
  );
3268
3267
  };
3268
+ // ── 会话头「当前生效专家」徽章(conversation.session.header.actions)──────
3269
+ // 宿主的 AgentPresetLabel 只读会话的 agentPreset 投影(会话开过口后不再变化),
3270
+ // 与本插件「随时换专家」的人设通道天然脱节:换专家后顶栏仍显示第一次的预设名。
3271
+ // 这个 slot 条目显示的是真正生效中的专家(expertActive 对账),与输入框 chip 同源
3272
+ // 同步——哪个专家在管这个会话,顶栏一眼可见;没挂专家时不渲染任何东西。
3273
+ // 样式对齐宿主 AgentPresetLabel(同高 22px、同 token),与预设标签并排不突兀;
3274
+ // 宿主标签在 540px 以下隐藏,本徽章跟随同一断点(container query 无法跨包复用,
3275
+ // 用媒体查询近似窄窗行为)。
3276
+ const SessionExpertBadge = ({ call, sessionId }) => {
3277
+ const [attached, setAttached] = useState(null);
3278
+ const [ready, setReady] = useState(false);
3279
+ const badgeRef = useRef(null);
3280
+ // 同步真源:宿主 expertActive 是唯一真源(别的入口挂的专家也要认)。挂/换/摘之后
3281
+ // 由总线立即触发一次,5s 低频轮询只兜底外部变化;组件重挂载也走同一条路径。
3282
+ useEffect(() => {
3283
+ if (!sessionId) { setAttached(null); setReady(true); return undefined; }
3284
+ let cancelled = false;
3285
+ const sync = () => {
3286
+ call('expertActive').then((res) => {
3287
+ if (cancelled) return;
3288
+ const mine = res && res.ok && Array.isArray(res.sessions)
3289
+ ? res.sessions.find((row) => row.sessionId === sessionId)
3290
+ : null;
3291
+ setAttached(mine ? { plugin: mine.plugin, name: mine.name } : null);
3292
+ setReady(true);
3293
+ }).catch(() => { if (!cancelled) setReady(true); });
3294
+ };
3295
+ setReady(false);
3296
+ sync();
3297
+ const off = onExpertChange(sync);
3298
+ const poll = setInterval(sync, 5000);
3299
+ return () => { cancelled = true; off(); clearInterval(poll); };
3300
+ }, [sessionId, call]);
3301
+ // 挂上专家时,把同容器里那条**陈旧的预设标签**藏掉:它读的是会话创建时的
3302
+ // agentPreset 投影(宿主只读、换专家不动它),所以换到第二个专家后它仍显示第一个,
3303
+ // 与真正生效的人设相反——三处显示里唯一会「说谎」的一处。只藏带本插件专家预设
3304
+ // 前缀(WorkBuddy 专家)的那条,默认预设标签与其他插件条目一律不碰;摘掉专家后
3305
+ // 立即恢复(原生 <span> 一直被 React 管着,我们只临时加 class,不移动/不删除节点)。
3306
+ // 对账幂等:每轮把「现在不该藏」的还原回显;观察 class 变化是为了宿主重渲染覆盖
3307
+ // className 后能补回(React 只在 props 变化时才写,稳态不会抖)。
3308
+ useEffect(() => {
3309
+ if (!attached || typeof document === 'undefined') return undefined;
3310
+ let stop = null;
3311
+ const isStalePreset = (node) => String(node.textContent || '').trim().startsWith(EXPERT_PRESET_LABEL_PREFIX);
3312
+ // 作用域 = 能同时容纳「本徽章」与「宿主播条目」的最近祖先(正常就是 headerActions
3313
+ // 容器);若父层只是个包装且里面没有目标条目,再上探一层(最多 3 层,防止飘到整页
3314
+ // 把看板/菜单里同名前缀的文本一起误藏)。
3315
+ const resolveScope = () => {
3316
+ const own = badgeRef.current;
3317
+ if (!own) return null;
3318
+ let node = own.parentElement;
3319
+ for (let i = 0; i < 3 && node; i++) {
3320
+ const others = [...node.querySelectorAll('span')].filter((s) => s !== own && !s.contains(own));
3321
+ if (others.some(isStalePreset)) return node;
3322
+ node = node.parentElement;
3323
+ }
3324
+ return own.parentElement;
3325
+ };
3326
+ const reconcile = () => {
3327
+ const scope = resolveScope();
3328
+ const own = badgeRef.current;
3329
+ if (!scope || !own) return;
3330
+ for (const node of scope.querySelectorAll('span')) {
3331
+ const keep = node === own || node.contains(own) || !isStalePreset(node);
3332
+ if (keep) node.classList.remove('dsh-mcp-hide-stale-preset');
3333
+ else node.classList.add('dsh-mcp-hide-stale-preset');
3334
+ }
3335
+ };
3336
+ const raf = requestAnimationFrame(() => {
3337
+ reconcile();
3338
+ const scope = resolveScope();
3339
+ if (!scope) return;
3340
+ stop = new MutationObserver(reconcile);
3341
+ stop.observe(scope, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class'] });
3342
+ });
3343
+ return () => {
3344
+ cancelAnimationFrame(raf);
3345
+ stop?.disconnect();
3346
+ // 摘掉专家 / 组件卸载:全部复原(只摘我们加的那个 class)。
3347
+ for (const node of document.querySelectorAll('.dsh-mcp-hide-stale-preset')) {
3348
+ node.classList.remove('dsh-mcp-hide-stale-preset');
3349
+ }
3350
+ };
3351
+ }, [attached]);
3352
+ if (!ready || !attached) return null;
3353
+ return h('span', {
3354
+ className: 'dsh-mcp-head-expert',
3355
+ ref: badgeRef,
3356
+ title: '当前生效专家(人设接管本会话;可在输入框工具行的专家菜单随时换/摘)',
3357
+ },
3358
+ h(Icon, { d: ICONS.expert, size: 13, className: 'dsh-mcp-head-expert-icon' }),
3359
+ h('span', { className: 'dsh-mcp-head-expert-name' }, attached.name),
3360
+ );
3361
+ };
3269
3362
  const wbFormatCount = (value) => {
3270
3363
  const n = Number(value) || 0;
3271
3364
  if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + 'M';
@@ -3858,6 +3951,8 @@ const McpBoardPage = (props) => {
3858
3951
  }, []);
3859
3952
  // 顶层分类计数:Skill 条数单独取,避免两条加载互相等待。
3860
3953
  const [skillCount, setSkillCount] = useState(null);
3954
+ // MCP 计数:与其他分类标签保持一致(此前只有 skill/tailor/expert 有数量,MCP 没有是遗漏)。
3955
+ const [mcpCount, setMcpCount] = useState(null);
3861
3956
  // 工具裁缝计数:只在用户切到该分类时拉一次,避免打开页面就扫日志。
3862
3957
  const [tailorCount, setTailorCount] = useState(null);
3863
3958
  useEffect(() => {
@@ -3866,6 +3961,10 @@ const McpBoardPage = (props) => {
3866
3961
  call('listSkills', skillScopeArg('global')).then((res) => {
3867
3962
  if (alive && res && res.ok) setSkillCount(Array.isArray(res.skills) ? res.skills.length : null);
3868
3963
  }).catch(() => {});
3964
+ // MCP 计数与 Skill 同批取(都在打开页面时取一次;global 层与 McpTab 的默认视图一致)。
3965
+ call('list').then((res) => {
3966
+ if (alive && res && res.ok && Array.isArray(res.servers)) setMcpCount(res.servers.length);
3967
+ }).catch(() => {});
3869
3968
  return () => { alive = false; };
3870
3969
  }, [call, category]);
3871
3970
  useEffect(() => {
@@ -3903,6 +4002,7 @@ const McpBoardPage = (props) => {
3903
4002
  },
3904
4003
  h(Icon, { d: ICONS[cat.icon], size: 14 }),
3905
4004
  cat.label,
4005
+ cat.key === 'mcp' && mcpCount !== null ? h('span', { className: 'dsh-mcp-cat-count' }, mcpCount) : null,
3906
4006
  cat.key === 'skill' && skillCount !== null ? h('span', { className: 'dsh-mcp-cat-count' }, skillCount) : null,
3907
4007
  cat.key === 'tailor' && tailorCount !== null ? h('span', { className: 'dsh-mcp-cat-count' }, tailorCount) : null,
3908
4008
  cat.key === 'expert' && expertCount !== null ? h('span', { className: 'dsh-mcp-cat-count' }, expertCount) : null,
@@ -4336,7 +4436,12 @@ const MCP_CONTRIBUTION = {
4336
4436
  { id: 'dsh-armory#mcpManager/wbExpertDismiss', service: 'mcpManager', namespace: 'mcpManager', method: 'wbExpertDismiss', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
4337
4437
  { id: 'dsh-armory#mcpManager/wbExpertInstalled', service: 'mcpManager', namespace: 'mcpManager', method: 'wbExpertInstalled', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WbExpertInstalledResult') },
4338
4438
  { id: 'dsh-armory#mcpManager/wbExpertRecent', service: 'mcpManager', namespace: 'mcpManager', method: 'wbExpertRecent', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('WbExpertRecentResult') },
4339
- { id: 'dsh-armory#mcpManager/wbExpertRecordUse', service: 'mcpManager', namespace: 'mcpManager', method: 'wbExpertRecordUse', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') }
4439
+ { id: 'dsh-armory#mcpManager/wbExpertRecordUse', service: 'mcpManager', namespace: 'mcpManager', method: 'wbExpertRecordUse', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
4440
+ // 会话级专家人设(随时挂 / 换 / 摘):不受 agent-preset/locked 限制。
4441
+ // 与宿主 method、typert.js 三处必须同步,漏一处这条通道就恒 404。
4442
+ { id: 'dsh-armory#mcpManager/expertAttach', service: 'mcpManager', namespace: 'mcpManager', method: 'expertAttach', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
4443
+ { id: 'dsh-armory#mcpManager/expertDetach', service: 'mcpManager', namespace: 'mcpManager', method: 'expertDetach', invocation: { kind: 'direct' }, parameters: [mcpParam('payload', 'ImportPayload')], result: mcpCodec('NoteResult') },
4444
+ { id: 'dsh-armory#mcpManager/expertActive', service: 'mcpManager', namespace: 'mcpManager', method: 'expertActive', invocation: { kind: 'direct' }, parameters: [], result: mcpCodec('ExpertActiveResult') }
4340
4445
  ].map((descriptor) => ({ ...descriptor, sourceLocation: { file: 'dsh-armory/lib/typert.js', line: 7, column: 1 } }))
4341
4446
  };
4342
4447
  async function apply(ctx) {
@@ -4387,10 +4492,16 @@ async function apply(ctx) {
4387
4492
  // 点开 = 技能/MCP/专家三分类 + 悬停最近使用子菜单。声明感知注入(会话 slot 由 conversation
4388
4493
  // 包声明,加载顺序不保证);菜单组件自管开关与外部点击关闭。
4389
4494
  // sessionId 由 slot 渲染作为 prop 显式传入(session 座位约定:id 只走 props,不走 summary 投影),
4390
- // 「挂专家」的 agentPresets.select 用它寻址当前会话。
4495
+ // 「挂专家」的人设通道 RPC 用它寻址当前会话。
4391
4496
  ctx.slots.inject('conversation.input.right', () =>
4392
4497
  ctx.slots.register({ name: 'conversation.input.right', id: PANEL_ID + '-quick', order: 40, label: PANEL_LABEL, inject: (sessionId) => ({ call, sessionId }) }, SummonQuickMenu),
4393
4498
  );
4499
+ // 会话头「当前生效专家」徽章(conversation.session.header.actions,list 型 slot):
4500
+ // 与宿主 AgentPresetLabel 同列——但显示的是人设通道真正生效的专家(expertActive),
4501
+ // 修复「换专家后顶栏仍显示第一次预设名」的显示/实效分叉。
4502
+ ctx.slots.inject('conversation.session.header.actions', () =>
4503
+ ctx.slots.register({ name: 'conversation.session.header.actions', id: PANEL_ID + '-head-expert', order: -5, label: PANEL_LABEL, inject: (sessionId) => ({ call, sessionId }) }, SessionExpertBadge),
4504
+ );
4394
4505
  // 左上角全局面板行(sidebar.panellist):「插件」旁边,主入口。行点击由宿主承担——
4395
4506
  // layout.selectPanel(PANEL_ID) 要求 main slot 里存在同 key 条目,上面的注册保证了这点。
4396
4507
  // 面板行不能用「渲染 null」隐藏(label 来自注册 options,不来自组件),所以按偏好
@@ -578,8 +578,9 @@ export function removeExpertPreset(content, plugin) {
578
578
  // 持久化在 ~/.dsh/wb-market/experts/recent.json:三类各存一份轻量记录
579
579
  // (skill: 技能名+来源目录名;mcp: serverName+作用域;expert: plugin+召唤预设名)。
580
580
  // 只在「可观察的使用动作」发生时写入:召唤/送还专家、召唤菜单里选中一项(由客户端
581
- // 显式上报)。上限各 8 条,新的顶到最前,去重按主键。文件损坏静默重置(纯增值数据)。
582
- const RECENT_LIMIT = 8;
581
+ // 显式上报)。上限各 40 条(召唤菜单每屏 6 行 + 滚动,排序需要较深的最近记录池),
582
+ // 新的顶到最前,去重按主键。文件损坏静默重置(纯增值数据)。
583
+ const RECENT_LIMIT = 40;
583
584
  function recentPath() {
584
585
  return join(cacheDir(), "recent.json");
585
586
  }
package/lib/index.js CHANGED
@@ -15,6 +15,7 @@ import { allMcpEntries, entriesForName, projectToolSchema, revisionOf, toolInven
15
15
  import { WHATS_NEW } from "./whats-new.js";
16
16
  import { wbConnectorCatalog, wbConnectorPreview, wbSkillCatalog, wbSkillDetail, wbSkillFiles, wbSkillInstall, wbSkillInstalled, wbSkillUninstall } from "./wb-market.js";
17
17
  import { installExpertSkills, listExpertRecent, listExpertSkills, readExpertPresets, recordExpertRecent, removeExpertPreset, uninstallExpertSkills, wbExpertCatalog, wbExpertDetail, writeExpertPreset } from "./expert-market.js";
18
+ import { attachPersona, detachPersona, listPersonas, reconcilePersonas } from "./session-persona.js";
18
19
  import { scanSessionLedger } from "./tool-ledger.js";
19
20
  import { evaluateEnvExpression, jsExpressionToTemplate } from "./workspace-config.js";
20
21
  import { canonicalWorkspacePath, installAgentRuntime, listWorkspaceRecords, liveWorkspaceAgentCount, projectConnectionsView, readWorkspaceConfigCached, reconcileWorkspaceConnections, reconcileWorkspaceRestricts, reconnectWorkspaceServer, withWorkspaceWrite, workspaceConnectionStatus, workspaceMountErrorsView, workspaceRestrictErrorView, workspaceScopeErrorsView, workspaceToolSchemas, writeWorkspaceConfigFile } from "./workspace-runtime.js";
@@ -1460,6 +1461,32 @@ let McpManagerGateway = (() => {
1460
1461
  // 数据与安装逻辑都在 lib/expert-market.js;这里只做 RPC 转发与 patch 写盘收尾。
1461
1462
  // 召唤 = 往 profile patch 插入一条独立 @deepseek-ai/dsh-agent-preset 行(persona.prefix
1462
1463
  // = 专家人设),DSH 热加载后新会话即可在预设选择器里选它——多专家共存,卸载即摘除。
1464
+ // ── 会话级专家人设(随时挂 / 换 / 摘,不受 agent-preset/locked 限制)──────
1465
+ // 机制与边界见 lib/session-persona.js 顶部注释:走 systemPrompt 的会话作用域
1466
+ // section,遮蔽该会话的部署人设前缀;不动工具目录、不写任何持久化配置。
1467
+ async expertAttach(payload) {
1468
+ const sessionId = String(payload?.sessionId || "").trim();
1469
+ const plugin = String(payload?.plugin || "").trim();
1470
+ if (!sessionId) throw new Error("缺少 sessionId");
1471
+ if (!plugin) throw new Error("缺少 plugin");
1472
+ const detail = await wbExpertDetail({ plugin });
1473
+ const result = attachPersona(this.ctx, sessionId, {
1474
+ plugin,
1475
+ name: detail.name,
1476
+ personaText: detail.persona,
1477
+ });
1478
+ await recordExpertRecent("expert", { plugin, name: detail.name, profession: detail.profession, presetId: `session-${plugin}` }).catch(() => {});
1479
+ return { ...result, profession: detail.profession };
1480
+ }
1481
+ async expertDetach(payload) {
1482
+ const sessionId = String(payload?.sessionId || "").trim();
1483
+ if (!sessionId) throw new Error("缺少 sessionId");
1484
+ return detachPersona(this.ctx, sessionId);
1485
+ }
1486
+ async expertActive() {
1487
+ reconcilePersonas(this.ctx);
1488
+ return { sessions: listPersonas() };
1489
+ }
1463
1490
  async wbExpertCatalog() {
1464
1491
  return wbExpertCatalog();
1465
1492
  }
@@ -0,0 +1,120 @@
1
+ // 会话级专家人设注入 —— 让「专家」像 WorkBuddy 一样可随时挂/换/摘,不受 agent-preset/locked 限制。
2
+ //
3
+ // 为什么需要这条独立通道:宿主的 agentPresets.select() 有硬锁(会话一旦开过口就
4
+ // `agent-preset/locked`),因为**预设**决定工具目录与提示词结构,中途换会破坏前后一致。
5
+ // 但用户要的是 WorkBuddy 式体验:任何会话、任何时刻都能换专家或摘掉。可行的原因是
6
+ // 人设(persona)与预设是两件事——preset 的 persona 行只是一段提示词 section:
7
+ //
8
+ // dsh-persona 的注释写明该行是 **scope-only**:挂进某个 agent 预设时,它只遮蔽
9
+ // 「那一个会话」的部署人设。dsh-subagent 也用同一手法给子会话装人设:
10
+ // childCtx.systemPrompt.section({ name: "deployment:persona-prefix",
11
+ // order: childCtx.systemPrompt.getSectionOrder("DEPLOYMENT_PERSONA_PREFIX"),
12
+ // text: persona })
13
+ //
14
+ // 而 systemPrompt.section() 的归属 scope 取自定义层自身 ctx 的 kScope(见 dsh-scope
15
+ // 的 scopeOf / ScopedLayers.effect),cordis 的 tracker 代理会把服务方法的 this.ctx
16
+ // 重绑为**调用方 ctx** —— 所以用 `agent.ctx.systemPrompt.section(...)` 注册,段就落在
17
+ // 该会话自己的 scope 层:只影响这个会话,且 ctx.effect 返回的 disposer 可随时撤销。
18
+ //
19
+ // 与「召唤预设」的分工:召唤写 profile patch,管**新会话的预设选择器**(长期身份);
20
+ // 本模块管**当前会话的即时人设**(会话内挂/换/摘)。两者互不干扰:预设仍在,随时可用;
21
+ // 会话内挂的专家只是这段提示词 section 的替换,不动工具目录与任何持久化配置。
22
+
23
+ /** 与 dsh-persona / dsh-subagent 共用的 section 名(同名即遮蔽,最近 scope 胜出)。 */
24
+ const PERSONA_PREFIX_SECTION = "deployment:persona-prefix";
25
+ /** 单个会话最多保留的撤销记录(防泄漏;正常使用远小于此)。 */
26
+ const MAX_SESSIONS = 64;
27
+ /** sessionId -> { plugin, name, disposer } */
28
+ const activePersonas = new Map();
29
+
30
+ function agentCtxOf(ctx, sessionId) {
31
+ const agents = typeof ctx?.get === "function" ? ctx.get("agents") : undefined;
32
+ if (!agents || typeof agents.get !== "function") return { error: "宿主未提供 agents 服务(agentRegistry 不可用),无法定位会话" };
33
+ const agent = agents.get(sessionId);
34
+ if (agent === undefined) return { error: "会话不在运行中(可能已归档或尚未激活):" + sessionId };
35
+ const agentCtx = agent.ctx;
36
+ if (agentCtx === undefined) return { error: "该会话没有可用的作用域上下文" };
37
+ return { agentCtx };
38
+ }
39
+
40
+ /**
41
+ * 摘掉某会话当前注入的人设(若有)。
42
+ * @returns {boolean} 是否真的摘掉了一段。
43
+ */
44
+ function clearPersona(sessionId) {
45
+ const current = activePersonas.get(sessionId);
46
+ if (!current) return false;
47
+ activePersonas.delete(sessionId);
48
+ try {
49
+ current.disposer?.();
50
+ } catch { /* 撤销失败不回滚状态:section 已随 scope 失效,重复撤销无害 */ }
51
+ return true;
52
+ }
53
+
54
+ /** 当前挂在某会话上的专家(供客户端 chip 显示真实状态)。 */
55
+ export function personaOf(sessionId) {
56
+ const row = activePersonas.get(sessionId);
57
+ return row ? { plugin: row.plugin, name: row.name } : null;
58
+ }
59
+
60
+ /** 所有会话的挂载快照(会话被销毁后由 reconcile 清理)。 */
61
+ export function listPersonas() {
62
+ return [...activePersonas.entries()].map(([sessionId, row]) => ({ sessionId, plugin: row.plugin, name: row.name }));
63
+ }
64
+
65
+ /** 会话已不在运行 → 清掉它的撤销记录(disposer 早已随 scope 失效)。 */
66
+ export function reconcilePersonas(ctx) {
67
+ const agents = typeof ctx?.get === "function" ? ctx.get("agents") : undefined;
68
+ if (!agents || typeof agents.get !== "function") return;
69
+ const live = new Set(agents.list().map((agent) => agent.id));
70
+ for (const sessionId of [...activePersonas.keys()]) {
71
+ if (!live.has(sessionId)) activePersonas.delete(sessionId);
72
+ }
73
+ }
74
+
75
+ /**
76
+ * 给一个运行中的会话注入(或替换)专家人设。同一会话可反复调用:先摘旧的再装新的。
77
+ * @param {object} ctx - 插件宿主上下文。
78
+ * @param {string} sessionId - 目标会话。
79
+ * @param {object} spec - { plugin, name, personaText }。
80
+ * @returns {{ok: true, plugin: string, name: string, replaced: boolean}}
81
+ */
82
+ export function attachPersona(ctx, sessionId, spec) {
83
+ const id = String(sessionId || "").trim();
84
+ if (!id) throw new Error("缺少会话 id");
85
+ const text = String(spec?.personaText || "").trim();
86
+ if (!text) throw new Error("人设内容为空,无法挂载");
87
+ const { agentCtx, error } = agentCtxOf(ctx, id);
88
+ if (error) throw new Error(error);
89
+ const prompt = agentCtx.systemPrompt;
90
+ if (!prompt || typeof prompt.section !== "function") {
91
+ throw new Error("该会话没有 systemPrompt 服务,无法注入人设");
92
+ }
93
+ const replaced = clearPersona(id);
94
+ // 与 dsh-persona 同款:同名 section + 同一处 order = 遮蔽该会话的部署人设前缀。
95
+ const disposer = prompt.section({
96
+ name: PERSONA_PREFIX_SECTION,
97
+ order: typeof prompt.getSectionOrder === "function"
98
+ ? prompt.getSectionOrder("DEPLOYMENT_PERSONA_PREFIX")
99
+ : 0,
100
+ text,
101
+ });
102
+ activePersonas.set(id, {
103
+ plugin: String(spec?.plugin || ""),
104
+ name: String(spec?.name || spec?.plugin || "专家"),
105
+ disposer,
106
+ });
107
+ // 防泄漏:超出上限时丢掉最早的记录(其 disposer 一并撤销)。
108
+ while (activePersonas.size > MAX_SESSIONS) {
109
+ const oldest = activePersonas.keys().next().value;
110
+ clearPersona(oldest);
111
+ }
112
+ return { ok: true, plugin: String(spec?.plugin || ""), name: String(spec?.name || spec?.plugin || "专家"), replaced };
113
+ }
114
+
115
+ /** 摘掉某会话的专家人设。@returns {{ok: true, removed: boolean}} */
116
+ export function detachPersona(ctx, sessionId) {
117
+ const id = String(sessionId || "").trim();
118
+ if (!id) throw new Error("缺少会话 id");
119
+ return { ok: true, removed: clearPersona(id) };
120
+ }
package/lib/typert.js CHANGED
@@ -101,6 +101,11 @@ export const TYPERT = {
101
101
  mv('wbExpertInstalled', [], 'WbExpertInstalledResult'),
102
102
  mv('wbExpertRecent', [], 'WbExpertRecentResult'),
103
103
  mv('wbExpertRecordUse', [payloadParam], 'NoteResult'),
104
+ // 会话级专家人设(随时挂/换/摘):走 systemPrompt 的会话作用域 section,
105
+ // 不受 agent-preset/locked 限制。payload 交给 lib/session-persona.js 校验。
106
+ mv('expertAttach', [payloadParam], 'NoteResult'),
107
+ mv('expertDetach', [payloadParam], 'NoteResult'),
108
+ mv('expertActive', [], 'ExpertActiveResult'),
104
109
  ],
105
110
  model: { services: [], events: [], objects: [] },
106
111
  }
package/lib/whats-new.js CHANGED
@@ -229,4 +229,42 @@ export const WHATS_NEW = [
229
229
  },
230
230
  ],
231
231
  },
232
+ {
233
+ version: '1.9.2',
234
+ items: [
235
+ {
236
+ text: '专家升级为 WorkBuddy 式「随时挂 / 换 / 摘」:任何会话(包括已经聊了一半的)都能挂专家、直接点另一个专家就换掉、点 chip 的 × 立刻取消——不再有「会话已开始,无法中途挂专家」的死路,也不再是一个对话只能引用一次',
237
+ where: '会话输入框工具行右侧的看板小图标 → 专家',
238
+ },
239
+ {
240
+ text: '换专家会明确告诉你「已替换原专家」,取消后点别的专家又能重新挂上;组件重挂或切换会话时自动向宿主对账,chip 显示的始终是真实状态(专家也能由预设选择器等其他入口挂上)',
241
+ where: '专家 chip 与召唤菜单内的提示条',
242
+ },
243
+ ],
244
+ },
245
+ {
246
+ version: '1.9.3',
247
+ items: [
248
+ {
249
+ text: '召唤菜单的「技能 / MCP / 专家」子菜单升级为**全量列表 + 滚动浏览**:每屏固定显示 6 行,超出的往下滚(细滚动条跟随主题);「管理技能/MCP/专家…」按钮固定在底部,不再被列表滚走。以前每类只列前 6 条,第 7 个技能在菜单里根本找不到',
250
+ where: '会话输入框工具行右侧的看板小图标',
251
+ },
252
+ {
253
+ text: '子菜单按**最近使用**排序:你刚用过的条目自动置顶并带「最近」标记(首屏 6 个),其余按原有顺序排在后面。最近使用记录池从 8 条加深到 40 条,排序更贴合真实使用习惯',
254
+ where: '召唤菜单 → 技能 / MCP / 专家',
255
+ },
256
+ {
257
+ text: '修复「换了专家,会话最上方还显示第一个专家」:会话头那条**预设标签**读的是会话创建时的事实、换专家不会变(宿主只读)。现在挂上专家后它会自动让位,改由一枚显示**当前真正生效**专家的徽章接管,并随换随新',
258
+ where: '会话标题栏(挂专家后)',
259
+ },
260
+ {
261
+ text: '修复专家显示的「三名不一致」:以前预设标签、输入框 chip、会话头三处各查一次状态,换完专家要等重挂才刷新。现在挂/换/摘后立即同帧同步,另有 5 秒兜底对账(覆盖从预设选择器等别的入口挂上的专家)',
262
+ where: '会话标题栏 + 输入框专家 chip',
263
+ },
264
+ {
265
+ text: '专家挂载不再走「预设通道」:那条路会在会话投影里写下一个**永不更新**的专家名,是上面那个「顶栏显示 A、实际生效 B」的根因。现在统一走会话级人设通道——随时挂/换/摘,显示与实效从此一致',
266
+ where: '召唤菜单 → 专家',
267
+ },
268
+ ],
269
+ },
232
270
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-armory",
3
- "version": "1.9.1",
3
+ "version": "1.9.3",
4
4
  "description": "Unified Skill + MCP + Expert board for DeepSeek Harness (desktop app and Web GUI) — global panel page, JSON import, WorkBuddy connector & SkillHub & Expert market installs, tool-usage tailor, and profile-backed Host Remote management.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -75,13 +75,19 @@
75
75
  },
76
76
  "keywords": [
77
77
  "deepseek-harness",
78
+ "deepseek",
78
79
  "dsh",
79
80
  "dsh-plugin",
80
81
  "cordis",
81
82
  "mcp",
83
+ "mcp-client",
82
84
  "armory",
83
85
  "skill",
84
- "expert"
86
+ "skill-manager",
87
+ "expert",
88
+ "agent-preset",
89
+ "workbuddy",
90
+ "plugin"
85
91
  ],
86
92
  "license": "MIT"
87
93
  }