hiwork-capabilities
v0.1.11
Published
HiWork 能力中心插件:在中央内容区提供「专家·技能·连接器」入口,专家域已接通名册、启用状态、@ 召唤与子代理链路。
Readme
hiwork-capabilities
HiWork 的能力中心插件(菜单名「专家·技能·连接器」)。
三类能力在 DSH 里分别是专家(agent persona)、技能(skill)、连接器(MCP connector)。 当前进度:
专家:Host 半边(名册 + 启用状态 + RPC + 召唤链路)与 Web 半边(页签 +
@触发器)均已落地。技能:已落地,统一承载发现、启停、导入、回收站与市场安装(原先由
@michengai/dsh-skills-manager与dsh-skill-hub两个插件分别提供,现已并入本插件, 不再安装那两个插件;署名见assets/NOTICE.md)。连接器:已落地,统一承载目录浏览、三通道接入(OAuth 2.0 PKCE / 手工 HTTP·stdio /
mcpServersJSON 导入)、连接启停与断开、工具与 Prompt 发现、连接诊断(原先由第三方插件dsh-mcp-connector提供,现已并入本插件,不再安装它;署名见assets/NOTICE.md)。治理与作用域留给二期。包名 / bundle id:
hiwork-capabilitiesclient 插件名:
hiwork-capabilities-client中央页 feature:
id = capabilities,order = 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 / 遮罩 / 按钮关闭 |
| 降级 | runtime 或 locale 缺席时页面只渲染外壳;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.ts 向 ctx.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.overlay(id = palette-panel,
order = 2,与原生 command-popup 并列而不复用它的 id——复用会替换它的 cell)。
三者共享同一个 ComposerPaletteState 实例(inject 的结果按「注册项 × 会话」缓存,
所以能共享的只能是实例本身)。
| 关注点 | 约定 |
| --- | --- |
| 四个域的来源 | 指令 = ctx.remote.commands;技能 = ctx.remote.skills;专家 = 本插件自己的名册快照(与专家 tab、@ 触发器同源);连接器 = 连接器域 RPC。前两个是宿主目录,不能自列——技能域在界面上有 10 类来源根,但启停策略已经经 src/skills/provider.ts 进了宿主注册表,自列会造出「浮窗里能选、模型看不见」的错位 |
| 选中动作 | 三类:指令与技能往草稿末尾写 /名字 (普通文本,命令语义交回宿主自己的 / 触发管线,与官方 dsh-client-ui-skill 的 onPick → {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: 0 与 overflow 才真的开始滚,搜索行和 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):入口按钮的 mousedown 被 preventDefault,所以鼠标点击时按钮根本不是 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 选多了不改输入框高度 | 宿主 .row 是 flex-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.ts 与 tests/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 根,CapabilitiesView 的 useState 每次都复位回「专家」。所以用一个待定值承载(capabilitiesNav.ts:这头 request,那头 take),页面在 useLayoutEffect 里 take 一次并订阅——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.ts 与
tests/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 技能链路真的会扫的才算(项目级两根、dsh、agents)。另外六个(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.ts 的 SKILLS_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。
| 关注点 | 约定 |
| --- | --- |
| 宿主服务 | loader 与 storageDomain 都经 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 | 子进程的 PATH 由 spawnPath.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.ts 的 CONNECTORS_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 的取值域含 _,所以 a 与 a__b 可以并存,mcp__a__b__x 的主人是 a 还是 a__b 字符串判不了。遮掉一个本该放行的工具是用户看得见的失败,放行一个本该遮掉的是静默的隔离漏洞——目标既然是硬隔离,就遮,并把 ambiguousTools 计数与按会话去重的 warn 报出去 |
| 先撤旧的再下新的,中间不得有 await | restrict 的语义是求交,两条叠加只会越收越紧;而「先撤后下」之间若让出事件循环,别的代码可以在这条缝里注销掉某个工具名,restrict 会因为「未知名字」整次抛错。落定是一个同步块 |
| 失败后 deny 置 undefined(不是 []) | 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_b → Error: 本会话只允许使用已选中的连接器工具(…不在其中);echo_a → e2e-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:761,loopCtx 就是
:1531 那个 this.runtime = { ctx },即 agent-loop 插件自己的 ctx),不带第三个参数
——createScope 只有显式给了 options.parent 才 bindScopeParent,所以 agent 作用域在
scope 链上是个根。子代理随后由 agentPresets.composeFrom(childCtx, parent.ctx) 挂到
父会话所 mount 的那个 preset 的 standing 作用域上(standingMountFor(parentCtx)),
而不是挂到父 agent 的作用域;create/resume 传的 parentAgent 在 agent-loop 里只有一处
用途(:1715 的 agents.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-core 的 hiworkFeatureCenter 服务(结构契约见
src/client/contracts.ts,运行时不 import hiwork-core,符合 DSH client
模块表「feature 插件之间禁止 runtime-import」的约束)。
独立安装降级
没有 hiwork-core 时(或它尚未加载),本插件注册设置页分区
settings.section#capabilities;hiwork-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-llm 的 HarnessError,于是整个 LLM 库被一并内联(当前约
1.2 MB)。这是本插件家族(hiwork-automation 同款)的既定做法,不要改成运行时
import 官方包——那条路在桌面端会直接加载失败。
client bundle 纯度
src/client/** 对 @deepseek-ai/* 只允许 import type(打包时被擦除),运行时只
import react / react/jsx-runtime。tests/packaging.spec.ts 会在构建后扫描
lib/client.js 的 require 说明符,越界即失败。
命令
| 命令 | 作用 |
| --- | --- |
| 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.json里connections与grants都是空的,没有需要迁移的真实连接。 - 打包进桌面端:在
hiwork-desktop/src/bundled-plugins.ts增加本地 archive 条目 (与hiwork-automation同一 vendor tarball 流程,见scripts/publish-to-desktop.mjs)。
