npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

hiwork-capabilities

v0.1.11

Published

HiWork 能力中心插件:在中央内容区提供「专家·技能·连接器」入口,专家域已接通名册、启用状态、@ 召唤与子代理链路。

Readme

hiwork-capabilities

HiWork 的能力中心插件(菜单名「专家·技能·连接器」)。

三类能力在 DSH 里分别是专家(agent persona)、技能(skill)、连接器(MCP connector)。 当前进度:

  • 专家:Host 半边(名册 + 启用状态 + RPC + 召唤链路)与 Web 半边(页签 + @ 触发器)均已落地。

  • 技能:已落地,统一承载发现、启停、导入、回收站与市场安装(原先由 @michengai/dsh-skills-managerdsh-skill-hub 两个插件分别提供,现已并入本插件, 不再安装那两个插件;署名见 assets/NOTICE.md)。

  • 连接器:已落地,统一承载目录浏览、三通道接入(OAuth 2.0 PKCE / 手工 HTTP·stdio / mcpServers JSON 导入)、连接启停与断开、工具与 Prompt 发现、连接诊断(原先由第三方插件 dsh-mcp-connector 提供,现已并入本插件,不再安装它;署名见 assets/NOTICE.md)。治理与作用域留给二期。

  • 包名 / bundle id:hiwork-capabilities

  • client 插件名:hiwork-capabilities-client

  • 中央页 feature:id = capabilitiesorder = 40(排在「新会话」、定时任务 20、知识库 30 之后)

命名:三类能力在 DSH 里分别是 agent preset / skill / MCP connector,用 capabilities 一个词覆盖三者,且与 hiwork-core / hiwork-automation / hiwork-knowledge 的命名家族一致。菜单文案仍按产品要求保留「专家·技能·连接器」。

目录

assets/
  experts/<division>/<slug>.md        321 位专家的英文库(MIT,第三方)
  experts-zh/<division>/<slug>.md     同 321 位专家的中文库(MIT,第三方)
  avatars/LICENSE                     头像立绘的许可(Apache-2.0,第三方)
  skills-upstream/*-LICENSE           技能域两个上游的许可全文(Apache-2.0 / MIT)
  connectors-upstream/*-LICENSE       连接器域上游的许可全文(MIT)
  connectors/catalog.json             连接器目录的随包内置快照(第三方目录的逐字拷贝)
  NOTICE.md                           署名与同步约定
src/
  index.ts                            Host 入口:装配三个能力域、预热名册 / 技能目录 / 连接器记录
  experts/
    types.ts                          领域类型 + 分区顺序/中文名常量
    catalog.ts                        资产扫描、frontmatter 解析、进程内缓存、正文按需读取
    settings.ts                       启用状态(settings ns hiwork-agency-agents)
    summon.ts                         召唤链路:list_experts / summon_expert + persona 注入
    prompt.ts                         面向模型的系统提示词 section 文案
    protocol.ts                       线上契约(频道、类型、错误映射;不含 zod)
    schemas.ts                        zod 入参校验(Host 专用)
    rpc.ts                            /hiwork-capabilities 的 loopback 端点实现
  skills/                             技能域(Host)
    types.ts                          领域类型、`SKILL_ERROR_CODES`、策略端口
    errors.ts                         业务错误码 → 线上错误码的映射与词典参数
    frontmatter.ts                    `SKILL.md` frontmatter 解析(无 YAML 依赖)
    roots.ts                          10 类来源根 + rank 表 + 路径安全原语
    discovery.ts                      只读来源的递归发现(断环 + 三重预算)
    catalog.ts                        扫描、跨根去重、遮蔽决胜、权威快照
    policy.ts                         三态启停策略(条目级 + 来源级,显式名单 > 默认)
    settings.ts                       启停状态(settings ns `hiwork-skills`)
    provider.ts                       模型侧 provider(rank 1,只发原生链路不会替我们发的条目)
    store.ts                          原子写 / 回滚 / 回收站 / 临时目录
    zip.ts                            纯 JS ZIP 解压(EOCD + 中央目录 + inflateRawSync)
    importer.ts                       导入 ZIP / 目录 / SKILL.md + 新建技能
    market.ts                         SkillHub / ClawHub 双源搜索与下载安装
    protocol.ts                       线上契约(端点白名单、视图类型;不含 zod)
    schemas.ts                        zod 入参校验(Host 专用)
    rpc.ts                            技能域端点分发
  connectors/                         连接器域(Host)
    types.ts                          域内模型 + 宿主服务接缝(loader / storageDomain 的结构契约)
    errors.ts                         业务错误码 → 线上错误码的映射与词典参数
    protocol.ts                       线上契约(端点白名单、业务码全集、视图类型;不含 zod)
    schemas.ts                        zod 入参校验(Host 专用)
    descriptors.ts                    连接器描述符:归一化 → 安全审计 → 视图投影
    catalog.ts                        目录:内置 → 远程 registry(CDN→raw)→ 上次缓存
    store.ts                          storageDomain 域 `hiwork_connectors` 与三张表的封装
    provision.ts                      装载接缝:连接记录 → `ctx.loader` 的 dsh-mcp-client 条目
    validation.ts                     落库前的连通性校验(initialize 通了才存凭证)
    probe.ts                          已装连接的工具 / Prompt 发现(tools/list)
    mcp-wire.ts                       MCP 线上的最小 JSON-RPC 客户端原语
    oauth.ts                          资源发现 → DCR → PKCE S256 → 换 token / 刷新 / 撤销
    callback.ts                       OAuth 的 loopback 回调服务器(127.0.0.1 随机端口)
    browser.ts                        唤起系统默认浏览器(授权页)
    grants.ts                         授权的后台续期与退休
    diagnostics.ts                    连接健康摘要、工具发现结论与失败摘要的脱敏
    util.ts                           输入卫生(serverName / URL / 命令 / 头)
    channels/oauth.ts                 通道一:OAuth 一键授权 + 按描述符造记录
    channels/manual.ts                通道二:手工填 HTTP / stdio 坐标
    channels/json.ts                  通道三:`mcpServers` JSON 批量导入
    scope.ts                          会话级工具限定:deny 补集、重算挂点、按会话的表
    rpc.ts                            连接器域端点分发
  client/
    index.ts                          Web 入口:建运行时、注册 feature / 设置页降级 / @ 触发器
    CapabilitiesView.tsx              页面外壳:专家 / 技能 / 连接器页签
    ExpertsTab.tsx                    专家页签:搜索 + 分区标签 + 卡片网格
    ExpertDetail.tsx                  专家详情弹窗(按需拉 persona 正文 + 复制)
    ExpertAvatar.tsx                  头像组件:立绘 <img>,缺图时降级为 emoji / 首字符
    avatar.ts                         立绘分配:22 分区 → 5 个视觉分类池,分类内轮转
    avatars.ts                        36 张立绘(JPEG data URI,第三方,见 assets/NOTICE.md)
    mentions.ts                       输入框 `@` 来源:候选、原子引用与模型侧线格式
    composer.ts                        输入框工具栏的纯逻辑:插入点、失败分类、目标解析、浮层几何
    ComposerPalette.tsx                输入框行首的四域入口与两张卡片的浮窗(卡片 1 四域 + 悬停展开的卡片 2)
    paletteIcons.tsx                   浮窗内联图标:四个域的图标 + 搜索 + 居右箭头
    capabilitiesNav.ts                 footer「管理…」跳能力中心页签的待定值通道(纯逻辑,无 React)
    paletteChips.tsx                   工具栏上的连接器 chip(可删;删即取消该会话的限定)
    paletteSources.ts                  指令与技能两个宿主目录的按会话缓存(含软失效)
    paletteStore.ts                    四域浮窗的每会话状态(开合 / 域 / 搜索 / 连接器选择)
    paletteRows.ts                     分组 + 搜索 → 可点行(纯逻辑,无 React)
    expertGroups.ts                    已启用专家按分区归组(浮窗与页面共用)
    SkillsTab.tsx                     技能页签外壳:子页签 + 来源分组 + 搜索/筛选 + 启停
    SkillsMarket.tsx                  技能市场子页签:搜索 + 源筛选 + 安装
    SkillDetail.tsx                   技能详情弹窗(frontmatter / 正文 / 诊断 / 路径)
    SkillsCreate.tsx                  新建技能弹窗
    SkillsImport.tsx                  导入弹窗(ZIP / 文件夹 / SKILL.md)
    SkillsTrash.tsx                   回收站弹窗(恢复 / 永久删除)
    SkillsConfirm.tsx                 危险操作确认弹窗(三个页签共用)
    SkillsModal.tsx                   弹窗外壳(遮罩 / Esc / 焦点),三个页签共用
    skillsRuntime.ts                  技能域运行时(uSES;照 runtime.ts)
    skillText.ts                      技能域的展示名 / 错误码翻译 / 来源名
    ConnectorsTab.tsx                 连接器页签外壳:子页签「已安装 / 市场」(默认已安装)
    ConnectorsMarket.tsx              市场子页签:分类筛选 + 卡片网格 + 详情弹窗
    ConnectorDetail.tsx               连接器详情弹窗(坐标 / 凭证 / Prompt / 工具预告)
    ConnectorAuthorize.tsx            目录接入弹窗:OAuth 两段式(授权页 + 轮询等待)
    ConnectorConnect.tsx              手工接入弹窗(HTTP / stdio)
    ConnectorImport.tsx               `mcpServers` JSON 导入弹窗
    ConnectorStatus.tsx               连接诊断弹窗(打开即探测工具清单 + 失败摘要)
    connectorsRuntime.ts              连接器域运行时(uSES;变更串行,照 skillsRuntime.ts)
    connectorText.ts                  连接器域的枚举 / 错误码翻译与哨兵文案
    rpcCall.ts                        RPC 信封拆解与 `RpcCallError`(两个域共用)
    uploadPicker.ts                   文件/文件夹选择与 ZIP 打包(纯逻辑 + 浏览器 API)
    runtime.ts                        客户端状态源(Host 是唯一事实来源)
    hooks.ts                          useSyncExternalStore 绑定
    locales.ts                        命名空间 hiwork-capabilities 的 zh/en 词典
    contracts.ts                      宿主 client 能力的结构契约(type-only)
    styles.ts / styles.css            样式注入
scripts/
  host-build.mjs                      Host 产物自包含打包(esbuild)
  client-build.mjs                    Client 产物闭包工厂打包(esbuild)
tests/                                入口、名册、settings、RPC、运行时、视图、召唤链路、@ 触发器、
                                      四域入口浮窗、技能发现/启停/导入/回收站/市场、
                                      连接器目录/描述符/装载/落库/校验/通道/诊断/工具发现/工具限定、
                                      打包契约

专家域(Host)

专家 persona 资产来自第三方 MIT 项目,头像立绘来自第三方 Apache-2.0 项目,署名与 同步方式见 assets/NOTICE.md。 两套 persona 库按 slug 一一配对,22 个分区,共 321 位专家。

| 关注点 | 约定 | | --- | --- | | 名册 | loadCatalog() 扫描 assets/experts{,-zh}/,只读每个文件头 8KB 取 frontmatter,进程内缓存;正文由 readExpertBody(id, locale) 按需读取 | | 启用状态 | 本插件自己的 settings 命名空间 hiwork-agency-agents,schema { enabled: string[] }默认全停用 | | 分区顺序 | DIVISION_ORDER 按业务热度硬编码,未列出的分区按字母序追加在后 | | 与 Web 通信 | ctx.connection.rpc 的 loopback 频道 /hiwork-capabilities |

RPC 端点(src/experts/protocol.ts 是唯一契约来源):

| 端点 | 作用 | | --- | --- | | experts.snapshot | 全部专家元数据 + 分区 + 启用集合 + revision + 可写性 + 诊断 | | experts.toggle | 单个专家启用/停用,返回新快照 | | experts.set-enabled | 整表替换启用集合(设置页批量操作),返回新快照 | | experts.prompt | 按需拉取某位专家的 persona 正文(zh/en) |

写入一律带 revision 做乐观并发:落后于 Host 时返回 conflict,客户端刷新后重试。 internal 只记 Host 日志,不把堆栈发给 Web 端。

专家 tab(Web)

| 关注点 | 约定 | | --- | --- | | 状态源 | runtime.ts 是唯一写入口:快照只来自 RPC 返回值,变更端点返回的权威快照立即发布,随后后台对账;传输失败重试一次,业务错误不重试。写入可以重叠,每次发布都基于实时状态,互不覆盖 pending 与快照 | | 写冲突 | conflict 不猜测本地状态:挂一条一次性提示并立刻重新拉快照,用户重试时用新 revision | | 双语 | 名册自带 name / description 的 zh/en,按宿主 locale 择一作主、另一语言作次要行;不进词典 | | 筛选 | 搜索(中英名称、简介、slug 都参与匹配)/ 分区标签(带计数,点击即筛选并高亮)/ 仅看已启用,均为纯 UI 状态 | | 分区行 | 横向滚动、不显示滚动条,两侧箭头翻页;箭头只在「那个方向还有内容」时出现(初始化只有右箭头,滚到最右只剩左箭头,放得下则都没有) | | 列表 | 全部卡片一次性渲染(几百条量级,不做分页);卡片是「头像 + 名称 + 双语次要行 + 三行截断简介」,不重复显示分区;鼠标落在卡片任意位置都有 hover 反馈(边框加深 + 光晕),名称变色是额外一层 | | 头像 | 36 张立绘按 5 个视觉分类分池复用:22 个分区先归并成分类,分类内按 slug 排序后轮转取池(每张都用上,复用次数差 ≤ 1),分类外的新分区走 slug 哈希。分配只跟 slug 与分区有关,切语言、开开关都不换脸;缺图时降级为 emoji / 名称首字符 | | 刷新 | 没有刷新按钮:快照由 runtime 维护,任何写入返回的权威快照都会立即发布 | | 详情 | 打开弹窗时才调 experts.prompt 拉 persona 正文(列表接口不下发),可一键复制(成功/失败都有反馈,1.6s 后复位);Esc / 遮罩 / 按钮关闭 | | 降级 | runtimelocale 缺席时页面只渲染外壳;hiwork-core 未提供 hiworkFeatureCenter 时走设置页分区,两条入口共用同一个运行时 |

专家召唤(Host)

src/experts/summon.ts 把「已启用的专家」变成模型可调用的能力,向会话贡献三样东西:

| 贡献 | 说明 | | --- | --- | | list_experts(division?, locale?) | 按分区浏览已启用专家;不带分区只给分区名与计数,带分区才展开 id 与简介(省 token)。分区可用 id、中文名或英文名匹配,与界面语言无关;total 恒为全局已启用数,不随分区筛选变化,分区无匹配时另有专门文案 | | summon_expert(expert, task, locale?) | 用 ctx.subagents.start('spawn', …) 起一个子代理,等它跑完并返回文本答案 | | 系统提示词 section | hiwork-capabilities:experts(order 117):告知名册存在、工具用法,以及停用的专家不可召唤 |

关键约定:

  • persona 注入SubagentStartRequest.persona 是唯一能替换子代理系统提示词的入口, 只遮蔽子代理自己的 deployment:persona,不影响父会话;要求 provider 具备 capabilities.persona
  • {{ 消毒:persona 是严格 {{变量}} 模板,专家正文里有 19 个文件含 {{(Drupal / Liquid 模板示例)。sanitizePersona 在相邻开花括号之间插入零宽空格,渲染结果肉眼一致 但插值器再也看不到 {{
  • 递归防护:rc.1 的系统提示词装配不带 agent,section 无法区分父/子会话,所以子代理侧 用 toolFilter: { deny: ['list_experts', 'summon_expert'] } 摘掉这两个工具本身。
  • 会话限定带进子代理:子代理不继承父会话的连接器限定(见「子代理继承会话限定」), 所以启动时还会并上 ConnectorToolScope.subagentDeny(sessionId)。取名单失败一律当没有。
  • 失败即回收run.result 不因子代理失败而 reject,但无论成败都必须 dispose()stopReason !== 'completed' 时报错并附上中断前的部分输出。
  • 语言:Host 没有 locale 服务,工具用可选的 locale 参数(zh / en),默认 zh

输入框 @ 触发器(Web)

src/client/mentions.tsctx.inputTriggers 注册一个 @ 来源(name = hiwork-capabilities:experts,可选注入:宿主没有输入框触发管线时静默降级)。

| 关注点 | 约定 | | --- | --- | | 候选 | 只列已启用专家,每次按键按当前快照重算,天然跟随设置页开关;中英名称、slug、id 都参与匹配 | | 分组 | 单个来源 + 候选自带 section(分区名),不按 22 个分区注册 22 个来源 | | 上限 | 最多 60 行,超出追加一行「还有 N 位」提示;空名册 / 无匹配各给一行不可插入的提示行,菜单不会整组消失 | | 线格式 | chip 与剪贴板是 @名字;发送时序列化成 @[名字](hiwork-expert:<division>/<slug>),模型拿到的是稳定 id,切语言、改名都不影响草稿里已插入的引用 | | 词典 | lexicon 返回已启用专家的展示名,在启用集合或界面语言变化时通知(指纹 = 语言 + 排序后以 NUL 连接的已启用 id 列表;语言不经过 runtime 发布,所以另外订阅宿主 locale) |

输入框四域入口(Web)

输入框工具栏行首的按钮点开一个两张卡片的浮窗:卡片 1 是四个域(指令 / 专家 / 技能 / 连接器,每行「图标 + 名称 + 居右箭头」),鼠标移入某一行才出现卡片 2(上方搜索框、中间 列表、footer 的「管理…」)。它取代了宿主原生的「指令」按钮(加号)——原生加号被 CSS 藏掉,入口按钮自己就是加号(占的正是那个位置,图标沿用同款语义,用户不用重新学一遍)。 打字 / 唤起原生命令菜单的键盘路径照旧保留。三个注册项分属两个槽位:入口按钮与 连接器 chip 在 conversation.input.left(会话作用域,id = palette-trigger / palette-chips),二级面板在 conversation.input.overlayid = palette-panelorder = 2,与原生 command-popup 并列而不复用它的 id——复用会替换它的 cell)。 三者共享同一个 ComposerPaletteState 实例(inject 的结果按「注册项 × 会话」缓存, 所以能共享的只能是实例本身)。

| 关注点 | 约定 | | --- | --- | | 四个域的来源 | 指令 = ctx.remote.commands;技能 = ctx.remote.skills;专家 = 本插件自己的名册快照(与专家 tab、@ 触发器同源);连接器 = 连接器域 RPC。前两个是宿主目录,不能自列——技能域在界面上有 10 类来源根,但启停策略已经经 src/skills/provider.ts 进了宿主注册表,自列会造出「浮窗里能选、模型看不见」的错位 | | 选中动作 | 三类:指令与技能往草稿末尾/名字 (普通文本,命令语义交回宿主自己的 / 触发管线,与官方 dsh-client-ui-skillonPick → {text:'/name '} 同款);专家插原子引用 chip(与 @ 共用 buildExpertReference,所以外观与模型侧线格式完全一致);连接器是多选,勾完不关浮窗 | | 排布 | 槽位出口带 display: contents,所以三个注册项的根节点都是宿主 .tools 的直接 flex 子项,靠 CSS order 排成「入口(−1)→ 回形针(0)→ chip(1)→ 权限下拉框(2)」。:has(> button[aria-haspopup="listbox"]) 认出 .tools,再把它 > div:first-of-type.modes)压到后面(不用 :nth-child(2):悬停「+」时 Tooltip 会插一个 span,子序号会漂)。原生加号按 button[aria-haspopup='listbox'] 隐藏——类名是哈希的、aria-label 是本地化的,两者都不能当选择器 | | 浮层几何 | 面板挂在卡片顶边的零高锚点里,bottom: calc(100% + 4px); left: 0只向上长(「向下」等于盖住用户的草稿)。可用高度由 panelMaxHeight(rect.bottom) 现算,写成根节点上的自定义属性 --hiwork-capabilities-palette-available-height(面板下边缘被 bottom 钉死,改高度只动上边缘,不会反过来影响这次测量);样式表再取 --hiwork-capabilities-palette-max-height: min(320px, 它) 作为真正的上限——320px 是设计值(浮窗是「掠一眼就走」的东西,不该盖掉半屏对话;没有布局时也靠它兜底),实测值只在窗口很矮时才更小。这条上限必须夹在卡片 2 上,不能夹在容器上:容器是 align-items: flex-end 的非拉伸 flex 容器,卡片的高度由内容撑出来,max-height 写在容器上只夹容器自己的盒子——真机上「专家」域 16 位时卡片一路长到窗口外面、盖住输入框。卡片还要 box-sizing: border-box(它有 6px 内边距 + 1px 边框,按 content-box 算上限只管内容盒,卡片实际高出 14px)。夹住之后正文区才拿到确定高度,min-height: 0overflow 才真的开始滚,搜索行和 footer 留在原地,键盘 ↑↓ 也让高亮行走在视野里(block: 'nearest')。宽度必须是确定值:绝对定位盒不写宽度就是「收缩到适应」,而它量的是 min-content——二级列表里的描述是 nowrap 的,最长的一条决定整块面板的宽度(真机「技能」域实测 3955px,视口才 1588) | | 两张卡片 | 容器自己不画边框底色,只有 gap: 8px;边框、圆角、投影落在两张卡片上,中间那道缝才透得出下面的内容。卡片 1 定宽 flex: none(它是锚点,塌了会跟着指针跑),卡片 2 flex: 1 1 auto; min-width: 0 拿剩下的宽度、自带高度上限(列表长了在卡片里滚,短了就是自然高度——不是被撑到上限)。卡片 2 里搜索行比列表行矮一档、圆一档(28px / 圆角 10px,列表行与 footer 行 36px / 8px):高度由 input 的 line-height + 上下内边距给——行高那条不能省,宿主全局行高是 24px,只改内边距整行还是 34px(高的是行盒,不是内边距);它还必须排在 font: inherit 后面font 是简写,会把 line-height 一起重置回继承值,写在前面等于没写(真机就是这么量出 34px 的)。圆角与边框挂在 .search-row 上。展开后不因鼠标移开而收起:hover 只负责「展开」,收起只发生在 Esc / 点浮窗外 / 关浮窗——级联菜单不收,同时省掉一整套 mouseleave 振荡与「键盘焦点在卡片 2 时被 hover 收起」的守卫。重新打开浮窗恢复成只有卡片 1(expanded 是面板体的局部状态,关闭即卸载) | | 卡片 1 的图标与箭头 | 四个域名长度一样、字形也没区分度,只写文字看不出「旁边还有东西」,所以每行是「图标 + 名称 + 居右箭头」(paletteIcons.tsx,16px / strokeWidth 1.4 / currentColor,与宿主自己的图标按钮同一档描边)。箭头 margin-left: auto 才真的贴右,否则它只是「名字后面多了个符号」 | | 名字与描述 | 两级列表的一行是「名字(用户要敲的 token,nowrap)+ 描述(补充信息,nowrap + 省略号)」。两条都 nowrap 时收缩按「flex 基准 × 收缩因子」分摊,而描述的基准是 max-content(一整句)——照默认值写会把技能名压成 1–3 个字母(真机实测 a / li… / p..,整列失去可读性)。所以名字 flex: none + max-width: 60%,描述 flex: 1 1 auto; min-width: 0描述可以被挤没,名字不行 | | 键盘与焦点 | 两套上下文(按事件来源分流):卡片 1 上 ↑↓ 就是「键盘 hover」——移动即切换卡片 2,→ 把焦点送进搜索框(Enter 不处理:卡片 1 的行是 role="tab" 的按钮,回车/空格由浏览器自己变成 click,再拦一道会跑两遍);卡片 2 上 ↑↓ 移动高亮(到顶夹住、不环绕)、Enter 触发。Esc 两级退避:卡片 2 开着先收回它(焦点回到卡片 1 那一行,浮窗留着),再按才关浮窗。打开时焦点落在哪儿看是谁打开的PaletteOpenOrigin):入口按钮的 mousedownpreventDefault,所以鼠标点击时按钮根本不是 activeElement、判成 'pointer'——这条路把焦点交给一级栏那个盒子tabIndex={-1},不画焦点环):键盘上下文照样归浮窗(Esc 关得掉、↑↓ 换得了域),但四个域一个都不激活。不能改成聚焦某一行:浏览器会给它画上焦点环(编辑器是文本类元素、恒匹配 :focus-visible,脚本聚焦会继承这个判定),用户还没选任何东西,卡片 1 上却有一行看着像「已激活」;也不能干脆不碰焦点——按键会全落在编辑器上,鼠标点开的浮窗成了键盘关不掉的一块。Tab 到按钮上回车是 'keyboard',那时焦点本来就在按钮上、画环是应有的反馈,才把焦点放进当前域那一行。鼠标 hover 同样绝不抢焦点(否则鼠标划过一级,编辑器里正打的字就没了),只有点击与 → 才把焦点送进搜索框。行与一级栏的 mousedown 一律 preventDefault,否则点一下焦点就跑到 body、键盘随之失效;一行用 onMouseOver 而不是 onMouseEnter(后者是 React 用 mouseover/mouseout 合成的,jsdom 里要构造 relatedTarget 才触发,mouseover 真机与单测同一套)。关闭后把焦点还给同一个输入框卡片里的编辑器:宿主的 commandUi.bindComposerFocus 全树没有调用者(照抄它不会有任何效果),而全局查 [contenteditable] 会还焦到另一个会话的编辑器上 | | 插入链路 | 公开的 InputActions 没有 insertReference,只能走会话作用域:sessions.scope(id)actx.get('conversation')conversation.input.for(actx)insertReference(ref, span) / insertText(text, span)。读快照与落笔之间不让出事件循环(宿主用 draftRev 做 CAS),全链路 try/catch | | 插入位置 | 引用插在草稿开头连续相接的 chip 之后/名字 插在末尾;两者的 span 都走宿主的探测投影(每个 chip 折成 1 个占位符 U+FFFC,与宿主 detectOffsetOfClipboardOffset 同规则),拿剪贴板偏移会被宿主直接拒掉 | | 失败分类 | no-target(拿不到目标 / 宿主不支持)、busy(相位不是 plain/claimed,正在提交)、rejected(宿主拒绝或抛异常);失败只提示不关浮窗,用户可以换一项重试 | | 空态分四种 | 宿主没有这个面(「这台宿主没有提供任何指令」)/ 这个域还没有可选项(指路去哪儿开)/ 搜索没匹配 / 加载失败(带重试)。前两者靠「分组数组里一个都没有」区分,所以空分组在 domainGroups 里就被丢掉了——留一个空分组会把「还没有启用任何专家」说成「没有匹配的项」 | | 降级 | 宿主没有 sessions 时三个注册项都不注册;slots.inject 对未知槽位名静默不注册;remote 的三个子面(commands / skills / $on)各经一次 ctx.inject 送达,每次只把那一个子面推给数据源——「见过宿主」不等于「这台宿主有这个目录」 | | chip 选多了不改输入框高度 | 宿主 .rowflex-wrap: wrap,我们挂在里面的 chip 一变多,被顶下去的是发送按钮组——整个输入框长高一行(真机实测 98 → 138px,而且选到第 4 个连接器就开始了)。根因是 flex 的换行判定用压缩前的假设宽度、且发生在收缩之前,所以光让 chip 可收缩没用。改法是三件套:有 chip 时把 .tools 设成 flex: 1 1 0(假设宽度归零 ⇒ 永远轮不到它换行,它自己再长到「行宽 − 发送按钮组 − 间距」)、chip 列表 min-width: 0 + overflow-x: auto、chip 本体 flex: none(少了最后这条,容器变窄时它们会先被压成一片省略号,滚动永远不触发)。先试过更直观的 max-width: calc(100% - 245px),扫一遍发现那个 245px(发送按钮组 233.047px + 间距)差 0.047px 就照样换行,减到 246px 才不换——卡在小数点上的阈值经不起字体或宿主改版,所以改成不依赖任何魔数的写法。滚动条藏掉(这一行只有 26px 高),改成溢出时右边缘渐隐提示「还有更多」;窗口收窄到卡片 470px 时仍是单行、chip 不被压扁、也不会溢出卡片(tests/styles.spec.tstests/composer-button.spec.tsx 各钉一半) | | 连接器 chip | 排在回形针之后,每个都能点掉。名字优先取宿主那份权威视图(scope.view.servers[]),退到连接目录,再退到 key 本身——不静默丢:用户勾了什么就必须看见什么,哪怕那条记录已经被删掉(那时标 data-lost,配色不同) | | 连接器选择 | 本地先落、宿主后验:chip 要在点击的同一帧出现,而确认要跨一次 RPC。写入带 writerId(每窗口一个)+ seq(该窗口单调递增)防乱序,只有「这次写入之后没有更新的写入」才允许宿主返回值覆盖本地 keys。失败不回滚本地选择(用户的点击是真的),只把错误摆进浮窗 | | footer 的「管理…」 | 跟着当前域只出一条(看专家只有「管理专家」),点了打开能力中心并落到对应页签;指令域不出——宿主指令没有管理页,硬塞一条会指到一个跟当前内容无关的页面。页签映射靠两层命名各说各话,不拼字符串:expert→experts / skill→skills / connector→connectors | | 跳页签的通道 | 宿主没有「按 id 打开某页签」的 API(dsh-client-ui-settings 只提供注册,设置壳的 modal 开合与 activeSection 都是组件局部状态,它自己的类型注释写着「No store is registered」;SettingsOnboardingOwnerProps.openSection 只发给当前 onboarding 步骤,借不来)。唯一能用的是 hiworkFeatureCenter.openFeature('capabilities'),而「落在哪一页」还得另想办法:feature-center 每次关闭都会卸载整个 React 根CapabilitiesViewuseState 每次都复位回「专家」。所以用一个待定值承载(capabilitiesNav.ts:这头 request,那头 take),页面在 useLayoutEffecttake 一次并订阅——layout effect 在提交之后、绘制之前跑,不会先画一帧「专家」再跳;用 useState 初值读则会在严格模式下被吃掉两次(第二次拿到 undefined,表现成「开发态深链偶尔失效」)。先记待定值再开门openFeature 的 notify 与 React 的提交之间没有先后保证,两种时序都要对;打不开(不返回 true)就把待定值撤掉,免得留给「用户下次自己点开」 | | footer 的降级 | nav.available()(面有没有到)用 useSyncExternalStore 订阅,hiwork-core 迟到时 footer 自己出现;没有 hiwork-core(插件退成设置页分区那种部署)时整条不渲染——与全插件「缺服务就静默不注册」一致,不摆一个点了没反应的控件 |

技能域(Host)

技能页签统一承载发现、启停、导入、回收站、市场安装五件事。RPC 注册在专家域那一个 共用的 ctx.effect 里(同一个 channel /hiwork-capabilities,见 src/index.tstests/plugin-entry.spec.ts 的单频道断言);只有模型侧 provider 另开一条 effect。 功能移植自两个第三方插件,署名与许可见 assets/NOTICE.md

| 关注点 | 约定 | | --- | --- | | 来源根 | 10 个:8 个用户级(DSH 400 / Agents 500 / CC Switch 510 / Codex 520 / Claude 530 / Gemini 540 / OpenCode 550 / Cursor 560)+ 2 个项目级(活动会话 cwd 向上找最近 .git)。rank 必须单调递增,否则会改变同层重名的决胜结果;只有 dsh 可写 | | 原生与否 | 每个来源根带 native:只有 DSH 技能链路真的会扫的才算(项目级两根、dshagents)。另外六个(CC Switch / Codex / Claude / Gemini / OpenCode / Cursor)DSH 从来不看一眼——所以它们默认不加载,界面上的开关关着,打开才会由本插件发射候选把技能真的装进模型目录(native 同时是来源级三态的默认值) | | 发现 | 只读来源带预算地递归(目录链接允许,用真实祖先路径集合断环),可写来源只扫一层;单文件技能(<name>.md)与 bundle 技能(<name>/SKILL.md)都认。没有 SKILL.md 的目录根本不会被枚举(现场 ~/.agents/skills/dingtalk-* 有 13 个这样的目录,它们的 SKILL.md 被移进了 .trash/)——宁可少列一条,也不摆一个点不开的幽灵卡片;.trash/ 本身还因「以 . 开头即隐藏目录」被跳过 | | 名录 | 跨根按真实路径去重,再按 rank 决胜;被遮蔽的条目仍然返回,带 shadowedBy 说明是谁赢了。参与决胜的是占得住名字的条目:原生来源不论开关都算(关着时也会补发一条停用候选把名字占住),非原生来源只有在开着时才算(关着就一条都不发,压不住任何人)。只有胜出那份的启停覆盖会生效——provider 候选取 rank − 1、同层按 rank 决胜,所以拨一张被遮蔽卡片的开关既不改界面结论也不改模型侧;界面据此把那个开关置灰 | | 展示名 | frontmatter 里可选的 title给人看的名字(可以写中文),宿主只剥引号并把换行收成空格,然后原样下发。它不参与 loadable 判定、不产生诊断、也不参与去重与 / 调用——名字是标识符(kebab-case)而展示名不是,两件事分开了界面才可能显示中文 | | 启停状态 | 本插件自己的 settings 命名空间 hiwork-skills,存三态覆盖,条目级与来源级各一份:disabled / enabled(都不在 = 跟随 frontmatter)与 disabledSources / enabledSources(都不在 = 跟随来源的 native)。来源也要三态,是因为一张「关掉的名单」分不清「默认关」和「用户明确关掉」。写入带 revision 做乐观并发。解析结果里另带一个 overrideChangesDefault:把覆盖清掉会不会改变结果——由「跑两遍解析再比」得出(手写条件会随三态规则漂移),界面据此决定摆不摆「跟随默认」 | | 启停生效 | 注册一个全局 rank 1 的 provider,只在原生链路不会替我们说话时发射候选:非原生来源开着时全发(否则「打开来源」只改了界面)、原生来源的显式停用与来源级停用照发(并把 invocation 双双置 false)。原生来源里被停用的候选仍要返回,否则低优先级的同名副本会「漏」上来;非原生来源关着时一条都不发——没有需要压制的同名候选,发了反而会让一个用户没启用的目录去遮蔽别处同名的技能 | | 写入 | 一律「临时路径 + rename」原子替换,覆盖导入先备份、失败回滚;来源与目标重叠、来源含符号链接、路径穿越一律拒 | | 回收站 | $DSH_HOME/hiwork-capabilities/trash(刻意做 skills/ 的兄弟目录,发现层看不见它);删除走回收站,不直接 rm | | 导入 | 四个入口(本机路径 / ZIP / 浏览器文件表 / 新建)归结到同一条链路;预演与实导跑同一套前置检查,避免「预演说没问题、确认之后才失败」 | | 预算 | ZIP:解压前 32 MiB、单条 32 MiB、总量 64 MiB、1000 条、路径 512 字符;目录深度 64 层、单名 128 字符 | | 市场 | SkillHub(api.skillhub.cn 主 / api.skillhub.tencent.com 备,失败自动切换)与 ClawHub(clawhub.ai)双源并发查、按 slug 去重、按下载量降序。不跨源回退:两站 slug 没有共同命名空间,拿 A 的 slug 去 B 下载只会把「主站不通」变成「技能不存在」。单源失败记进 failures 不抛错 | | 市场安装 | 下载回来的是 ZIP,直接复用 importUploadedSkill——解压预算、路径穿越防护、原子替换与回滚全部现成,不需要第二套「下载安装」的安全语义。落成的目录名取 slug 而不是包里的 frontmatter name(后者常是中文或另一个名字,按它命名会得到「搜的是 A、装出来是 B」) |

RPC 端点(src/skills/protocol.tsSKILLS_RPC_ENDPOINTS 是唯一契约来源):

| 端点 | 作用 | | --- | --- | | skills.snapshot | 全部来源根 + 技能条目 + 汇总 + revision + 可写性 + 诊断 | | skills.detail | 按需读取某个技能的 SKILL.md 正文与 frontmatter | | skills.set-skill | 单个技能的三态覆盖(inherit / enable / disable),返回新快照 | | skills.set-source | 来源级开关,返回新快照 | | skills.create | 手写一个技能(同名拒绝,不覆盖) | | skills.import | 导入(dryRun 预演 / 实导),返回统一回执 | | skills.delete | 移入回收站 | | skills.trash-list | 回收站条目列表(纯读) | | skills.trash-restore | 从回收站恢复,返回新快照 + 重取过的回收站列表 | | skills.trash-purge | 永久删除 | | skills.market-search | 双源搜索(纯读,不入快照) | | skills.market-install | 下载并安装,返回新快照 |

两层错误码:线上只有 7 个 RpcErrorCode(「这次请求怎么了」),用户要看的是「为什么 失败」,所以 Host 把业务码(error.import.overlap 这类点分码)放进 error.details.code,Web 拿它查词典,details.params 做插值;查不到时回落到 Host 的 中文原文。Host 是唯一事实来源:每个变更端点都返回 { result, snapshot },客户端 直接发布这份权威快照,不做本地推算。

技能 tab(Web)

| 关注点 | 约定 | | --- | --- | | 子页签 | 「已安装」= 本机磁盘的权威投影(全部来自快照);「技能市场」= 远端两个源的一次查询结果。两者刻意分开:市场结果只活在 SkillsMarket 自己的 state 里,不进运行时状态——混进去会让「界面上的技能 = 磁盘上的技能」这条不变量失守 | | 展示名 | 卡片主名按 title(frontmatter 里写的展示名,可以中文)→ 声明名 name → 物理目录名三级回退,副名与 data-skill-name 始终是物理名——操作(启停 / 删除 / 详情)与 /技能名 调用走的都是标识符,展示名只拿去显示与搜索 | | 搜索 | 「已安装」即时筛选(展示名、物理名、描述都参与);「技能市场」是显式发起的(回车 / 点搜索 / 换源),不做输入即搜——每敲一个字符打一次外网请求既慢又容易被对面限流当成爬虫。挂载时自动搜一次空关键词,两个源各回最热的一批 | | 已安装判定 | 只有一份:SkillsTab 按快照算出名字集合(物理名 + 声明名,都小写)传给市场。市场自己维护「我刚装过谁」的名单,装完 Host 回传的新快照就是新的权威,徽标跟着亮 | | 来源分组 | 按来源分组展示,来源行同时是筛选器;组标题的 hover 提示写出真实目录(展示名是给人看的:~/.agents/skills 叫「通用技能」——「共享」会被读成团队共享,而这个目录只是被多个工具读);删除按钮只出现在 mutable 的来源上(别的工具的目录不归我们处置) | | 遮蔽 | 徽标写「谁生效」({source} 里那份生效)而不是「被谁遮蔽」——后者读起来像有人把这张卡藏了;胜出那份自己没加载时(来源关着、或那一条被关了)改说「{source} 里那份没加载」,绝不声称一份没进目录的技能「生效」。被遮蔽卡片上的开关置灰并用 title 说明原因(拨了不影响胜出那份),详情弹窗里直接给出胜出那份的文件路径(列表侧从同一份快照里查,不为它加端点) | | 来源默认关 | 非原生来源的分组徽标写「默认不加载」(title 补一句「HiWork 默认不扫描这个目录」),原生来源被用户关掉时才说「整个来源已停用」——两种「关着」是两件事,混成一句会让用户去找一个自己从没动过的开关。来源关着时组内单条开关也置灰:Host 侧那一整组的候选根本不发射,拨了不会有任何效果 | | 三态 | 开关直接读 Host 的 enabled/override;「跟随默认」按钮则只在 overrideChangesDefault 为真时出现——覆盖与 frontmatter 得出同一个结论时,点它什么都不会变(用户拨开关拨了个本来就成立的答案),摆出来只是噪音。真逆着 frontmatter 时(作者用 disable-model-invocation 关掉、用户又打开)它是界面上唯一的退路,否则只能手改 ~/.hiwork/settings.yaml | | 分页 | ClawHub 的搜索接口没有 page 参数,第 2 页起它回的仍是同一批条目,所以第 2 页起只查 SkillHub,并如实回传 sources、在页面上说明一句;只选 ClawHub 时整个翻页条不出现(摆一个点了没反应的「下一页」比不摆更糟) | | 卸载 | 只从「已安装」页签发起:市场条目与本机技能之间是按名字的对应关系,拿一个猜出来的名字去删文件比让用户多花一秒要糟得多 | | 弹窗 | 新建 / 导入 / 回收站 / 详情同一时刻只开一个,外壳共用 SkillsModal(遮罩、Esc、忙碌期吞掉关闭) | | 降级 | 查不到业务码时展示 Host 的原文;一次变更失败只在工具栏下方挂提示,列表照旧——不该把整页变成错误页 |

连接器域(Host)

连接器页签统一承载目录浏览、三通道接入、启停·断开·重命名、工具发现与诊断。RPC 同样 注册在专家域那一个共用的 ctx.effect 里(同一个 channel,见 tests/plugin-entry.spec.ts 的单频道断言);只有授权续期另开一条 effect。功能移植自第三方插件 dsh-mcp-connector, 署名与许可见 assets/NOTICE.md

| 关注点 | 约定 | | --- | --- | | 宿主服务 | loaderstorageDomain 都经 ctx.inject 可选注入,缺任一即降级为「此宿主不支持连接器」(页签照常可浏览目录,但连不上任何东西)。两者都不能进顶层 inject:那会让整个插件(含专家 / 技能两页签)在缺这些服务的宿主上直接不激活 | | 装载接缝 | 「连一台 MCP server」= 经 ctx.loader 动态装载一条 @deepseek-ai/dsh-mcp-client 条目(create / update / remove),条目 id = hiwork-mcp-<key>。前缀属本插件而不是上游的 mcp-<key>——两个插件同时装着时条目 id 会撞 | | 工具名 | 由 serverName 决定:mcp__<serverName>__<rawName>。必须匹配 [A-Za-z0-9_-]{1,32} 且在存活实例间唯一;落库前先归一化,撞名时改写并回一条 warning.connection.serverNameRewritten | | 开机重建 | 运行期创建的条目只在进程内存里活着,重启即消失,而记录表才是唯一真相——激活时按表把条目重装一遍(provisioned / failed / skipped 三个数落进日志)。失败只记日志:一条连不上的记录不该拖垮整个插件的激活 | | 落库 | storageDomain 域 hiwork_connectors刻意不叫 mcp_connector,避免与上游插件同域同文件互相覆盖),三张表 connections / grants / catalog;无 revision——本域唯一的冲突是 serverName 撞名 | | 凭证边界 | 只落在 storageDomain 与 loader 配置树,不进目录、不进日志、不进快照、不回传 Web;读接口一律先脱敏。写回记录的失败摘要(lastError)也要先过 redactSecrets:一个坏掉或恶意的 server 可以在错误体里回显 Authorization 头,而这条摘要会流进快照与界面 | | stdio | 命令只从目录数据或用户明确输入得到,不做任何 shell 插值;只从本机拉起进程 | | stdio 的 PATH | 子进程的 PATHspawnPath.ts 现算:继承来的原样排最前,其后补确实存在的常见安装位置。打包版桌面端从访达启动时 PATH 只有 Resources/node + 系统四条(那里有 node/pnpm,没有 npx),不补的话目录里那 39 条本地命令(34 npx / 5 uvx / 1 pipx)一条都装不上。补出来的 PATH 只写进 loader 条目、不进记录——它是这台机器的运行环境,不是这条连接的配置。装载前先在这条 PATH 里查一遍命令,查不到就抛 error.command.notFound(点名到命令),而不是让装载去撞一次 ENOENT 再被包成泛化的 error.loader.failed | | 上限 | 单条连接的 key 与显示名各 64 字符、连接总数 64 | | 目录 | 远程 registry(jsDelivr CDN 主源 → GitHub raw 回退)→ 上次缓存(带 etag)→ 随包 assets/connectors/catalog.json,四级依次兜底。icon 只放行绝对 https 地址:上游目录用的是它自己 HTTP 服务的路由前缀,照搬过来会变成向第三方发请求加载图片 | | 工具发现 | 失败不当异常抛(探测最常见的结局就是失败,抛出去只会塌成一只不透明的错误信封),结论放在结果的 errorCode 里;只有「没有这条连接」这种前提不成立才抛。stdio 连接压根不发请求、lastError 保持原样——「没探」不该被记成「探失败」 | | OAuth | 资源发现 → DCR → Authorization Code + PKCE S256,回调走 127.0.0.1 随机端口的 loopback(RFC 8252 §7.3)。在 RPC 上拆成 connect-start / connect-status 两段——这是唯一一处刻意偏离上游的行为契约(上游的 connect 一路 await 到浏览器授权完成,超时 300s,放 loopback RPC 上既撞宿主超时也让 AbortSignal 语义变得微妙) | | 授权续期 | 后台按 accessTokenExpiresAt 排期刷新,并在没有连接再用它时撤销;单开一条 effect,RPC 层只认它一个 schedule 方法 |

RPC 端点(src/connectors/protocol.tsCONNECTORS_RPC_ENDPOINTS 是唯一契约来源):

| 端点 | 作用 | | --- | --- | | connectors.snapshot | 目录(含分类、精选、已装标记)+ 已装连接 + 汇总 + 可写性 + 警告 | | connectors.detail | 单个连接器描述符详情(按需,含凭证字段 / Prompt / 工具预告) | | connectors.connect-start | 发起连接,立刻返回 { flowId, authorizationUrl? } | | connectors.connect-status | 查询某次连接流程的结论(OAuth 回调是异步的) | | connectors.configure | 手工 HTTP / stdio 配置并连接 | | connectors.import-json | mcpServers JSON 导入(dryRun 预演 / 实导) | | connectors.set-enabled | 单条连接启停(保留记录) | | connectors.disconnect | 断开并移除 loader 条目 | | connectors.rename | 重命名连接(不动 serverName,工具名因此不变) | | connectors.tools | 按需发现某条连接的工具清单(写回 lastError,失败在结果里报) | | connectors.refresh-catalog | 强制刷新远程目录(纯读) | | connectors.tool-scope | 读某会话的工具限定状态(纯读;刻意不走 requireRuntime) | | connectors.set-tool-scope | 落定某会话的连接器选择(整份替换,回同一个 scope 视图) |

两层错误码与技能域同款:线上只有 7 个 RpcErrorCode,业务码(error.oauth.denied 这类 点分码)放进 error.details.code 由 Web 查词典,details.params 做插值。Host 是唯一事实 来源:每个变更端点都返回 { result, snapshot },快照写后重算,客户端不做本地推算。

会话级工具限定(Host)

在输入框的四域入口里勾上若干连接器,就表示「本次会话只让模型用这些 MCP server 的工具」。 这是一句能力约束而不是提示词里的一句请求:经 ctx.agents.get(sessionId).ctx.tools.restrict 下发之后,被遮住的工具既不出现在模型可见的工具声明里,也调不动。核心在 src/connectors/scope.ts,对外的两个端点见上表。

| 关注点 | 约定 | | --- | --- | | 只存内存 | 表随插件激活而空,所以「本会话」这个判据不需要任何 session 标记或时间戳比较;进程重启 = 全部解除。会话销毁(agent/disposed)即恢复 | | 界面不会撒谎 | 工具栏上的 chip 直接渲染宿主那份权威视图(scope.view.servers[]paletteChips.tsx 有说明),客户端没有一份会自己复活的本地选择:表项没了(agent/disposed / 重启 / 被淘汰)下一次读回来就是空,chip 跟着消失。不存在「界面显示限定着、实际没限」,也没有反过来 | | deny 不用 allow | allow 会把原生工具(summon_expert、技能工具、子代理的结构化输出)一起遮掉——那不是连接器域的对象。deny 逐个点名要遮的工具,其余一律不管 | | 只遮我们自己装载的 | 归属不到本机任何一条连接的 mcp__* 工具一律放行:专家(preset)可以自己挂 MCP server,第三方 MCP 桥也可能存在。代价见下面那条边界 | | 同名歧义 fail-closed | serverName 的取值域含 _,所以 aa__b 可以并存,mcp__a__b__x 的主人是 a 还是 a__b 字符串判不了。遮掉一个本该放行的工具是用户看得见的失败,放行一个本该遮掉的是静默的隔离漏洞——目标既然是硬隔离,就遮,并把 ambiguousTools 计数与按会话去重的 warn 报出去 | | 先撤旧的再下新的,中间不得有 await | restrict 的语义是求交,两条叠加只会越收越紧;而「先撤后下」之间若让出事件循环,别的代码可以在这条缝里注销掉某个工具名,restrict 会因为「未知名字」整次抛错。落定是一个同步块 | | 失败后 denyundefined(不是 [] | deny 同时是「当前已下发的集合」和跳过条件。失败后留个空数组,下一次重算会得出同样的集合而直接跳过——一次偶发失败就变成永久失效 | | 全集必须不带 scope | 枚举用 tools.schemas()(全局视图),不是 tools.schemas(agentScope)。带 scope 的视图已被别人的 restriction 过滤过(第三方放开的那一刻就会漏),更要命的是 ptc 模式下带 scope 的视图里 MCP 工具一个都看不见deny 恒空 → 限定静默失效 | | 重算挂点 | 只挂 tools/change(工具集才是真判据:「界面上是启用的」在「条目还在但装载失败」时会撒谎)。它是未按 scope 过滤的全局通知,所以重放按当前选择幂等。重算用 queueMicrotask 合并,不用时间防抖——那会制造「工具已出现但还没被遮掉」的真实窗口 | | 生效粒度 | 「下一个 step」而不是「下一个 turn」:systemPrompt.assemble 每 step 模型调用前跑,wireSchemas 每次重新解析 restriction。chip 变化下一步就生效,代价是正在跑的那一步不受影响 | | 会话未就绪 | 不是错误、是常态(用户完全可能先勾连接器再发第一条消息):记下选择 + pending:true,注册 agent/created 自动补生效。客户端不需要重试或轮询 | | 执行期兜底 | tools.guard(...) 是可选层(executionGuard: false 就关掉),挡的是「新工具注册 → 微任务重算跑完」那条缝。名字在 ToolExecution顶层 name 上,没有 exec.tool 这一层——按 exec.tool.name 读会永远拿到 undefined,而它是 fail-open,于是这条兜底会静默变成「永远放行」(不坏事,但等于没开;真机这条就是照类型面猜错的,tests/connectors-scope.spec.ts 里对应的假形状也跟着错,一路绿着) | | 乱序保护 | 写端点带 writerId(每窗口一个随机串)+ seq(该窗口单调递增)。按 writer 分而不是全局 seq:全局单调计数会让另一个窗口(计数器落后)的写入被误判过期而卡死 | | 为什么这两个端点不套 {result, snapshot} 也不走 requireRuntime | 写端点的权威状态就是 scope 视图(改的只是内存里一张会话表,ConnectorsSnapshotView 在这次操作里可证明不变),而重算它要拉一次目录——TTL 过期又断网时是 4 秒,用户每点一下多选框等 4 秒不可接受。读端点则必须能在缺运行时的情况下回答(available:false),否则客户端分不清「不可用」与「出错」 |

两条已知边界(都是刻意的,不是待修项):

  • 前缀扫荡只覆盖本插件装载的连接器。若将来有别的 MCP 桥挂进同一个会话,它的工具不会被 这段限定遮住。在 HiWork 里 MCP 只由本插件 provision.ts 装载,所以实际集合等同。
  • 专家(preset)自挂的 MCP server 的工具不进 deny。子代理不继承父会话的限定—— 这一点已经在真机上证实(机理与证据见下),补偿办法是把本会话的名单叠到子代理自己的 toolFilter 上。

真机验收:怎么证明「模型真的看不见、也调不动」

唯一硬证据是请求头,不是模型的自我报告。 会话日志($DSH_HOME/sessions/<项目>/<会话>/session.v3.jsonl.zstd) 里的 request/header 记录逐字保存了那一次调用的工具声明,zstd -dc 出来按 data.header.tools[].name 看。一台真机、一个会话、两轮,把两件事都钉住(连接器 A/B 各 挂一个独占工具):

| | 勾着 A 时 | 点掉 A 之后(同一会话的下一个 step) | | --- | --- | --- | | 请求头里的工具总数 | 38(mcp__* 只有 echo_a,遮住 46 个) | 84(47 个 mcp__* 全回来) | | 逼模型「必须真发起调用」 | echo_bError: 本会话只允许使用已选中的连接器工具(…不在其中)echo_ae2e-a: A3 | 两个都成功:e2e-b: B4 / e2e-a: A4 | | B 那台 MCP server 的日志 | 整轮没有 tools/call(压根没打到服务端) | 记到了 tools/call |

要点:

  • 被遮住的工具调用不是 UNKNOWN_TOOL,而是执行期那条 guard 的原文——名字在宿主里仍然是 已知的(restrict 只把它从可见面摘掉,不改「已知」),所以能一路走到 guard 才被拦下。反过 来说,看到这句人话就说明 guard 是活的(它一度因为读了不存在的 exec.tool.name 而静默 放行,见上表「执行期兜底」)。
  • 上面那次是先勾连接器、再发第一条消息:会话的 agent 那时还不存在,靠 pending + agent/created 补生效——「勾选 → 发送 → 立刻生效」这条路是真机验过的。
  • 取消勾选不必等回合边界:下一个 step 的工具声明就回来了。
  • 会话级而不是全局:另一个不勾任何连接器的会话同期的请求头里 47 个 mcp__* 一个不少。

踩过的坑:先按「让模型调 B、看它报不报错」来验,模型答「该工具不存在」,据此判成「取消选择 不释放限定」。翻请求头才发现那一轮 B 的工具就在声明里——模型只是照抄了上一轮的结论 (它的 reasoning 原话是「Same situation」),一个工具都没试着调。「模型没调」与「工具不可 见」在模型的自述里长得一模一样,判定只能落到请求头上;要检「调不动」就得在提示词里明确要求 「每一件都必须发起真实调用,不许根据工具列表推断」。

子代理继承会话限定(真机证实)

机理(dsh-agent-loop / dsh-agent-presets,0.1.2-rc.1):宿主给每个 agent 建 scope 走的 都是 createScope(loopCtx, this)dsh-agent-loop/lib/index.js:761loopCtx 就是 :1531 那个 this.runtime = { ctx },即 agent-loop 插件自己的 ctx),不带第三个参数 ——createScope 只有显式给了 options.parentbindScopeParent,所以 agent 作用域在 scope 链上是个。子代理随后由 agentPresets.composeFrom(childCtx, parent.ctx) 挂到 父会话所 mount 的那个 preset 的 standing 作用域上(standingMountFor(parentCtx)), 而不是挂到父 agent 的作用域;create/resume 传的 parentAgent 在 agent-loop 里只有一处 用途(:1715agents.enter(agent, parentAgent)运行期归属,与 scope 链无关)。 bindScopeParent 全仓也只被 dsh-agent-presets 调用(dsh-agent / dsh-subagent 一次都没有)。 父会话那条 restriction 所在的层根本不在子代理的作用域链上

真机证据(一个会话、两次召唤 —— 补偿前后各一次,外加一次没勾连接器的反向对照):

| 请求头 | 父会话 | 被召唤的专家子会话 | | --- | --- | --- | | 勾着 A(补偿777e83c7) | 38 个工具 / 1 个 mcp__*(只有 echo_a) | 74 个 / 47 个 mcp__*mcp__e2e-b__echo_b 在内) | | 勾着 A(补偿2f838a3a) | 38 / 1 | 28 个 / 1 个(只剩 echo_a;恰好 74 − 46) | | 点掉 A 之后(反向对照) | 84 / 47 | 74 / 47(没限定就没有名单可叠,回到原样) |

三行都要有:只报「勾着 A 时子代理也被限定了」,说不出这份限定来自会话而不是别的什么东西; 「点掉之后子代理回到 47 个」才是「名单是按会话取的」那个反证。父会话那两列同时说明补偿没有 回头影响父会话自己的工具面。第二次召唤的 summon_expert 正常返回 ok——名单里的名字都是 此刻注册着的,restrict 一次都没被拒。

补偿落在 summon_expert 启动子代理这一步:把 ConnectorToolScope.subagentDeny(sessionId) 并进子代理自己的 toolFilter.deny(那份 toolFilter 原本只用于摘掉递归召唤的两个工具, 现在多带一份会话名单)。名单在快要 start 的位置取(越晚越不容易过期),并且过滤成 此刻真正注册着的名字——restrict 碰到未知名字是整次调用抛错,而子代理的 setup 一抛 就把整次召唤回滚掉,一个过期名字足以让 summon_expert 直接失败。读名单本身也整个包在 try/catch 里:这一层只是补偿,权威仍是父会话那条 restriction,不值得拿召唤去赌。

残留边界:名单是召唤那一刻的快照,子代理跑起来之后父会话再改选不会影响它(子代理是一次性 任务,改选也不会追进它的运行中);另外通用 subagent 工具(dsh-tool-subagent,不是本插件 注册的)不在这条补偿的覆盖范围内。

pnpm check 覆盖不到这条链路,插件侧的护栏都在 tests/connectors-scope.spec.ts (假 tools 端口模拟 intersect)+ tests/styles.spec.ts(三条样式约定)+ tests/experts-summon.spec.ts (子代理那份 toolFilter 的合成)。

怎么读 request/header 这段证据

一条 request/header 不是每步都写:同一个 agent 实例的第一条请求记 reason: initial (从日志恢复的会话是 resume),此后只有头部与上一条不同时才追加一条 reason: change; 头部相同但新开了一个请求序列(startsSeries)时才补一条 reason: series。所以

  • 「这一轮没有新记录」= 工具声明与上一轮逐字相同headerEquals 比较的是完整头部,含 tools)。要让这句话成立得先确认那一轮真的跑过——看它后面有没有 step/start
  • 反过来,「多一个工具」这种最小变化也一定会留下一条 change限定一旦丢,藏不住。

重放成本与生命周期(真机实测)

重放成本。 schedule() 的微任务只遍历 table.keys():表里没有被限定的会话,就一次 schemas() 都不读。开机那段工具风暴因此根本走不到重算(几十条连接各自 register 一串工具、 每条都 emit 一次 tools/change,而此刻表必然是空的——选择只存内存、重建即清)。这条有形态化 断言:tests/connectors-scope.spec.ts 的「表里没有会话被限定时,整场工具风暴一次都不读工具清单」。

真会出现的那次重算(限定生效期间某条连接的工具增删)主项是逐个工具一次 snapshotJsonValue(parameters) 深拷贝ToolRuntime.schemas() 就是这么实现的)。拿真机抓下来的 真实 84 个工具定义、真机那份 dsh-util-values 量:

| 工具集 | 参数总字节 | 中位数 | p95 | | --- | --- | --- | --- | | 真机现状 84 个 | 27 KB | 0.38 ms | 0.42 ms | | 放大到 1000 个 | 328 KB | 4.6 ms | 4.7 ms | | 放大到 4000 个 | 1.3 MB | 18.1 ms | 18.7 ms |

量的是主项(不含 view(scope) 那层 Map 遍历),跑在宿主进程外,但函数与数据都是真的。方案里定的 优化门槛是 20 ms —— 那要四千个工具才够得着,所以不做提前返回那套

运行期的工具风暴。 在一条已限定的会话(只勾了 A)还活着的时候,去设置页把 E2E B 停用再启用 (它的工具真的注销又重新注册)、然后回到这条会话跑一轮:请求头没有任何新记录,仍是 38 / 1; 同一时刻另一个没勾连接器的会话是 84 / 47(mcp__e2e-b__echo_b 在内)。两个方向都钉住了。

生命周期。 同一台真机、同一个进程里连续跑完:

| 场景 | 会话 | 结果 | | --- | --- | --- | | 新建会话 → 先勾连接器 → 发第一条 | 54583cc6 | 首个请求头就是 38 / 1(「勾选 → 发送 → 立刻生效」) | | 同期一个没勾的会话 | 3bfecc59 | 84 / 47,且 chip 为空(没有幽灵选择) | | 切走再切回 | 切到别的会话再点回来 | chip 回来了(渲染的是宿主视图);请求头无新记录(仍 38 / 1),且 3 步共用一个 header —— 说明切会话没有重建 agent(新实例必然会补一条 initial/resume) | | /compact 真压缩 | 54583cc6 | Compacted 7 history items (~859 tokens);压缩后新序列记的是 reason: series + 38 / 1,再跑一轮仍 38 / 1 | | 恢复历史会话 | 点回上个进程里被限定过的 779e76c3 | chip 为空、新记录 reason: resume + 84 / 47 —— 内存语义(跨进程即清)+ 界面不撒谎 |

「重启」这一行不用另跑:779e76c3 的那条限定正是上一个进程留下的,重启后被清掉就是同一件事。

(表里的会话 id 只是 2026-09-11 那次验收留下的标签,用来对齐当时的日志;验收会话随时会被清掉, 要复查就按上面这套流程重跑一遍,数字得自己再量一次。)

连接器 tab(Web)

| 关注点 | 约定 | | --- | --- | | 子页签 | 「已安装」= 本机连接记录的权威投影(默认停在它上面,顺序也在前);「市场」= 远端目录的一次投影。两组工具栏控件同一时刻只摆一组——同时摆着,用户会分不清「搜索」搜的是目录还是本机 | | 市场卡片 | 分类分章 + 卡片网格。卡片不渲染 icon:目录里的图标是社区提交的远端 URL,当 <img> 加载等于让每张卡片都去敲一次第三方 | | 已安装卡片 | 名称(按钮,点开诊断)+ 传输 / 健康徽标 + 工具前缀 + 失败摘要 + 启停开关 + 重命名 / 断开。断开在卡片页脚单独占一行(不可逆,和日常动作挤在一起容易点错) | | 无乐观更新 | 开关与徽标直接读 snapshot,而 snapshot 只在 Host 回包时换引用。一次写失败不该把整页变成错误页:消息挂在工具栏下方,列表照旧 | | 变更串行 | 同一个 runtime 的写操作排一队。Host 的 configure 是「校验 → 落库 → 装载」三步序列,并发的两次同名配置可能都通过撞名检查,然后一条盖掉另一条 | | 诊断弹窗 | 打开即探测(没有「开始」按钮),stdio 连接短路、一次请求都不发。探测走写入队列——Host 要写回 lastError 并重算快照,代价是最坏情况下占住队列 30 秒,这是刻意的取舍 | | 授权等待 | 轮询 connect-status 的这几秒里不换快照:Host 的快照此时必然没变(流程还没落库),照单全收等于让整个页签每秒重渲染一次,直到用户点完同意 | | 弹窗 | 详情 / 接入(目录·手工·导入)/ 诊断 / 重命名 / 断开同一时刻只开一个,外壳共用 SkillsModal(遮罩、Esc、忙碌期吞掉关闭) | | 导入结论 | 预览与实导的结论摆在一层居中的浮层里(比宿主弹窗窄一圈),不接在底栏上方长出来——那个位置读起来像表单的一部分,视线又正好落在按钮上,点完「预览」很容易以为没反应。失败(整次导入没成)同样进浮层:它比成功更不该只留一行小字。关掉浮层表单原样留着,Esc 也只关浮层这一层 | | 降级 | 查不到业务码时展示 Host 的原文;宿主没有 loader / storageDomain 时在页签顶部挂一条说明——不说的话,用户会以为是网络或凭证的问题,白白折腾半天 |

入口

| 项 | 值 | | --- | --- | | feature id | capabilities | | order | 40 | | 菜单文案 | 专家·技能·连接器(跟随语言切换,词典键 view.title) | | 图标 | 内联 SVG(枢纽 + 三个卫星节点),侧栏折叠成图标栏时只显示它 | | render | 复用 CapabilitiesView,不依赖 FeatureRenderContext |

注册走 hiwork-corehiworkFeatureCenter 服务(结构契约见 src/client/contracts.ts,运行时 import hiwork-core,符合 DSH client 模块表「feature 插件之间禁止 runtime-import」的约束)。

独立安装降级

没有 hiwork-core 时(或它尚未加载),本插件注册设置页分区 settings.section#capabilitieshiwork-core 后到时立刻撤下该分区并切成中央 feature,保证同一功能不会同时出现两个主入口。

host bundle 自包含

桌面端 profile 会清空 node_modules/@deepseek-ai/*,所以 Host 产物必须自包含:esbuild 把官方包整体内联,lib/index.js 只允许剩 node:* 说明符(tests/packaging.spec.ts 按语法扫描,不按子串,避免把 esbuild 的 // node_modules/… 注释误判成依赖)。

代价是体积:defineTool 来自 @deepseek-ai/dsh-tools 的 barrel,而该 barrel 运行时 import @deepseek-ai/dsh-llmHarnessError,于是整个 LLM 库被一并内联(当前约 1.2 MB)。这是本插件家族(hiwork-automation 同款)的既定做法,不要改成运行时 import 官方包——那条路在桌面端会直接加载失败。

client bundle 纯度

src/client/**@deepseek-ai/* 只允许 import type(打包时被擦除),运行时只 import react / react/jsx-runtimetests/packaging.spec.ts 会在构建后扫描 lib/client.jsrequire 说明符,越界即失败。

命令

| 命令 | 作用 | | --- | --- | | pnpm typecheck | 严格类型检查 | | pnpm build | 构建 lib/index.js(Host)与 lib/client.js(Web) | | pnpm test | Vitest(DOM 用例首行 // @vitest-environment jsdom) | | pnpm verify | typecheck → build → test(提交前跑这个) |

本地试用(不打包桌面端)

pnpm build && pnpm pack        # 产出 hiwork-capabilities-0.1.4.tgz

在 DSH profile(如 ~/.dsh/profiles/web)的 package.json 里加依赖 "hiwork-capabilities": "file:<绝对路径>/hiwork-capabilities-0.1.4.tgz",并在 dsh.profile.bundles 追加 "hiwork-capabilities",然后 pnpm install --force 并重启 dsh web(Host 半边变化必须重启,只改 client 时刷新页面即可)。

后续

  • 连接器二期:治理(Connection / Server / Tool 三层 allow·deny)、项目·全局作用域、 配置备份与快照、插件版本检测更新、旧 OAuth 插件迁移、连接器描述 URL 安装、带变量的 Prompt 模板发送。本期不做的还有上游那 18 个对话工具——管理面只在页面里,模型侧的 工具面是连接成功后由 dsh-mcp-client 注册的 mcp__<serverName>__*
  • 部署层:把 dsh-mcp-connector 从桌面 profile 的 dsh.profile.bundles 移除 (与当初移除两个技能插件同一手法)。数据面无碍:现场 $DSH_HOME/storages/mcp_connector.jsonconnectionsgrants 都是空的,没有需要迁移的真实连接。
  • 打包进桌面端:在 hiwork-desktop/src/bundled-plugins.ts 增加本地 archive 条目 (与 hiwork-automation 同一 vendor tarball 流程,见 scripts/publish-to-desktop.mjs)。