@restregular/dsh-skill-center 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,7 +35,7 @@ Sidebar Main area
35
35
  | Browse | Lists every skill visible in the **current session scope**, including skills embedded in plugins, each with provider, source root and invocation policy |
36
36
  | Search / filter | Search by name or description; filter by provider source |
37
37
  | Read body | Renders the raw `SKILL.md` Markdown inside the panel (no right-hand resource pane needed) |
38
- | Invocation switches | Toggles `disable-model-invocation` / `user-invocable` by rewriting the frontmatter; the host pushes the refreshed catalog to the model immediately |
38
+ | Invocation switches | Flips `disable-model-invocation` / `user-invocable` locally — the switch moves at once, with no round trip — and writes them into the frontmatter when you press **Save** beside *Back to session* (the button reads 未修改 → 保存 → 已保存); the host pushes the refreshed catalog to the model immediately |
39
39
  | Create | Creates `<name>/SKILL.md` under the user Agents or user DSH skills root |
40
40
  | Delete | Moves the skill to `.trash/<timestamp>-<name>` in the same root — recoverable, never a hard delete |
41
41
  | Read-only guard | Skills embedded in plugins (e.g. `office-docx`) are labelled *embedded* and their switches/deletion are disabled |
@@ -124,7 +124,7 @@ Then add the package to the profile's bundle list and restart:
124
124
  // <profile>/package.json
125
125
  {
126
126
  "dsh": { "profile": { "bundles": [/* … */ "@restregular/dsh-skill-center"] } },
127
- "dependencies": { "@restregular/dsh-skill-center": "^0.3.0" }
127
+ "dependencies": { "@restregular/dsh-skill-center": "^0.3.1" }
128
128
  }
129
129
  ```
130
130
 
@@ -137,7 +137,7 @@ because the client registers `sidebar.panellist` with `order: 1`.
137
137
  > (`@restregular/dsh-skill-center`). `@deepseek-ai/dsh-client-modules` locates a row's
138
138
  > `package.json` by that name and compares it strictly (`nearestPackage`). With a bare
139
139
  > `dsh-skill-center` the host half still mounts, but the client half is dropped **silently**:
140
- > no sidebar row, `/plugins/@restregular/dsh-skill-center/client.js` returns `404`, and nothing is
140
+ > no sidebar row, `op=diagnose` reports `client.inGraph: false` with no `client.url`, and nothing is
141
141
  > logged. 0.2.1 fixed exactly this; 0.2.0 shipped the bare name.
142
142
 
143
143
  > [!IMPORTANT]
@@ -169,8 +169,11 @@ Nothing appears in the sidebar after install + restart? Check in this order:
169
169
  2. **Did the client half reach the browser module graph?**
170
170
  `http://127.0.0.1:19387/dsh-skill-center/api?op=diagnose` — `client.inGraph` must be `true`.
171
171
  `false` while `host.mounted` is `true` is the loader-row-name / package-name mismatch above.
172
- 3. **Is the bundle served?** `http://127.0.0.1:19387/plugins/@restregular/dsh-skill-center/client.js`
173
- should return JavaScript, not `404`.
172
+ 3. **Is the bundle served?** The browser is only ever handed a **revisioned** URL
173
+ (`/plugins/@restregular/dsh-skill-center/client.js?rev=<12 hex>`), and the route deliberately rejects a
174
+ mismatched revision, so requesting the bare path answers `404` even on a healthy install. Read
175
+ `client.url` from `op=diagnose` (it now reports the row it matched in `client.entryId`) and fetch that
176
+ URL instead.
174
177
  4. **Re-run the offline composition check** (no host restart needed):
175
178
  `node verify-compose.mjs <profileDir>` — it now fails loudly on a loader row name that cannot be
176
179
  resolved back to the package.
@@ -182,7 +185,10 @@ Nothing appears in the sidebar after install + restart? Check in this order:
182
185
  1. Open a session (or stay on the home screen — see *Known limits*).
183
186
  2. Click **Skill Center** in the sidebar.
184
187
  3. Pick a skill on the left; the right pane shows provider, source, path, size and the two invocation switches.
185
- 4. Toggle a switch to rewrite the frontmatter, press **Read body** for the Markdown, or **Create** / **Delete** to manage files.
188
+ 4. Flip switches as you like: they move immediately and stay staged in the panel. The button to the left of
189
+ *Back to session* tracks the state — **未修改** (greyed, nothing staged) → **保存** (active, changes staged;
190
+ a count appears when several skills are staged) → **已保存** (greyed, written). Editing anything again puts
191
+ it back to 保存. Press **Read body** for the Markdown, or **Create** / **Delete** to manage files.
186
192
 
187
193
  ## HTTP API
188
194
 
@@ -231,6 +237,10 @@ Two halves, no shared imports:
231
237
  `<projectRoot>/.dsh/skills`, `<projectRoot>/.agents/skills`, `$DSH_HOME/skills`, `$DSH_AGENTS_HOME/skills`.
232
238
  - Frontmatter edits are string-level: only the invocation keys are touched, everything else is preserved
233
239
  byte for byte, including line-ending style.
240
+ - For skills inside a managed root, `list` takes the invocation policy **from the file on disk** instead of
241
+ the registry snapshot. The skill catalog is cached and the filesystem watcher invalidates it a moment
242
+ after a write, so a panel that trusted the snapshot would show its own stale value right after a save —
243
+ which is exactly what made a flipped switch snap back until a manual refresh.
234
244
  - Delete is a `rename` into `.trash`.
235
245
  - Also registers an embedded skill provider (`ctx.skills.registerProvider`) for `skills/skill-authoring/`
236
246
  and the `skill_create` tool (`ctx.inject(['tools'], …)` + `ctx.tools.register`). The tool schema is a
@@ -258,6 +268,11 @@ Styles use host theme tokens only (`--dsw-alias-*`, `--dsw-radius-*`, `--dsw-foc
258
268
  `<style>` tag carries `data-plugin="dsh-skill-center"` so the host's ownerless-style sweep does not
259
269
  reclaim it.
260
270
 
271
+ Switch edits are staged in component state: the switch reads a local override first and the catalog
272
+ second, and the Save button's three states are derived from the staged set (`pendingEdits`) plus a
273
+ "saved once" flag. After a save the overrides are kept until a fresh catalog actually agrees with them
274
+ (`load()` drops the ones that match), so a lagging host snapshot can never bounce the UI back.
275
+
261
276
  ## Development
262
277
 
263
278
  ```bash
@@ -298,13 +313,32 @@ contract (directory name vs frontmatter, non-empty description, `resourceBase` d
298
313
  `references/`-style path cited in a table or link actually existing), the tool's JSON Schema against
299
314
  the subset `@deepseek-ai/dsh-tools` accepts, and then **really runs** `skill_create` inside a temp
300
315
  dir: create, resource placement, overwrite, duplicate names, illegal names, path traversal, flat
301
- files, explicit project roots, and the three degraded mount paths.
316
+ files, explicit project roots, and the three degraded mount paths. It also asserts that a managed-root
317
+ skill's invocation policy comes from the file on disk even when the registry snapshot disagrees.
318
+
319
+ `client-smoke.mjs` renders the real panel against the live loopback route through a mini React shim,
320
+ and then drives the staged-save flow: the switch flips with no write request, Save issues exactly one
321
+ `setInvocation` carrying both flags, the button steps 未修改 → 保存 → 已保存 (and back to 保存 on the
322
+ next edit), and a lagging catalog snapshot cannot bounce the switch back. It intercepts POSTs, so the
323
+ run never rewrites a real `SKILL.md`.
302
324
 
303
325
  > Renaming or relocating the package: the client module table caches package metadata by
304
326
  > `(loader baseUrl, loader line name)` — no file timestamps — so a running host keeps the old path.
305
327
  > The browser side uses the in-memory snapshot taken at activation and keeps working after a reload;
306
328
  > the new path is only resolved after a host restart.
307
329
 
330
+ ### Releasing
331
+
332
+ Bump `version`, commit, push, then `npm publish` from the repo root with a token that has publish
333
+ rights for the scope. The registry answers a successful publish with `202 {"success":true}` and then
334
+ holds the version in a **validating** state: until validation passes, both
335
+ `/@restregular/dsh-skill-center/<version>` and the tarball URL answer `404`, and `dist-tags` still
336
+ points at the previous release. That is the normal window, not a failure. Re-probe the version URL
337
+ with a cache-busting query (`?t=<ticks>`) rather than publishing again: a second attempt for the same
338
+ version fails with `E409 Cannot publish over previously staged version <version>` (and
339
+ `npm stage publish` with `E409 Cannot stage previously published version <version>`), while
340
+ `npm stage list` may show nothing at all.
341
+
308
342
  ## Known limits
309
343
 
310
344
  - While the panel occupies the main area, the right-hand `dsh-resource://` preview is unavailable
@@ -343,7 +377,7 @@ files, explicit project roots, and the three degraded mount paths.
343
377
  | 浏览 | 列出**当前会话作用域**下模型/用户能看到的所有技能(含插件内嵌技能),带提供方、来源根、调用策略 |
344
378
  | 搜索 / 筛选 | 按名称或描述搜索;按来源(provider source)筛选 |
345
379
  | 查看正文 | 面板内直接读 `SKILL.md` 原文(Markdown 原文,不经过右栏) |
346
- | 调用开关 | 切换 `disable-model-invocation` / `user-invocable`,直接改写 frontmatter;监听器会立刻把新目录推给模型 |
380
+ | 调用开关 | 开关先改本地状态(**点了立刻切换**,不发请求),再按「返回会话」左边的 **保存** 按钮写回 frontmatter(按钮三态:未修改 → 保存 → 已保存,多个技能待保存时显示「保存 (n)」);写盘后监听器立刻把新目录推给模型 |
347
381
  | 新建 | 在「用户 Agents 技能」或「用户 DSH 技能」根下创建 `<name>/SKILL.md` |
348
382
  | 删除 | 移动到同根目录的 `.trash/<时间戳>-<名字>`(可手工恢复,不硬删) |
349
383
  | 只读保护 | 插件内嵌技能(如 `office-docx`、`diagnose-windows-sandbox-acl`)标注「内嵌」,开关与删除按钮禁用 |
@@ -414,7 +448,7 @@ pnpm add @restregular/dsh-skill-center
414
448
  // <profile>/package.json
415
449
  {
416
450
  "dsh": { "profile": { "bundles": [/* … */ "@restregular/dsh-skill-center"] } },
417
- "dependencies": { "@restregular/dsh-skill-center": "^0.3.0" }
451
+ "dependencies": { "@restregular/dsh-skill-center": "^0.3.1" }
418
452
  }
419
453
  ```
420
454
 
@@ -425,8 +459,8 @@ pnpm add @restregular/dsh-skill-center
425
459
  > patch 里那条 loader 行的 `name` **必须逐字等于包名**(`@restregular/dsh-skill-center`)。
426
460
  > 宿主 `@deepseek-ai/dsh-client-modules` 是按这个行名去定位该行的 `package.json` 并**严格比对**
427
461
  > `name` 的(`nearestPackage`)。写成裸名 `dsh-skill-center` 时:宿主半边照样挂得上,
428
- > 但浏览器半边会被**静默丢弃** —— 侧栏没有「技能中心」、`/plugins/@restregular/dsh-skill-center/client.js`
429
- > 返回 `404`,而且日志里一个字都没有。0.2.1 修的就是这个;0.2.0 发出去的是裸名。
462
+ > 但浏览器半边会被**静默丢弃** —— 侧栏没有「技能中心」、`op=diagnose` 里 `client.inGraph` 是 `false`
463
+ > 且没有 `client.url`,而且日志里一个字都没有。0.2.1 修的就是这个;0.2.0 发出去的是裸名。
430
464
 
431
465
  > [!IMPORTANT]
432
466
  > 客户端 bundle 还必须用**包名**注册:`__ModuleLoader__.load({ id: '@restregular/dsh-skill-center' })`,
@@ -453,8 +487,10 @@ pnpm add @restregular/dsh-skill-center
453
487
  profile 的 `node_modules`。
454
488
  2. **浏览器半边进模块图了吗**:`…/api?op=diagnose` 里的 `client.inGraph` 必须是 `true`。
455
489
  若 `host.mounted` 为 `true` 而它是 `false`,就是上面那个「行名 ≠ 包名」。
456
- 3. **bundle 有没有被服务**:`http://127.0.0.1:19387/plugins/@restregular/dsh-skill-center/client.js`
457
- 应返回 JavaScript 而不是 `404`。
490
+ 3. **bundle 有没有被服务**:浏览器只会拿到**带版本号**的地址
491
+ (`/plugins/@restregular/dsh-skill-center/client.js?rev=<12 位十六进制>`),而路由**故意拒绝**
492
+ 版本不匹配的请求 —— 所以即使一切正常,直接要裸路径也会回 `404`。请从 `op=diagnose` 的
493
+ `client.url` 取地址(现在它还会在 `client.entryId` 里告诉你命中哪一行),再取那个地址。
458
494
  4. **跑离线合成自检**(不用重启宿主):`node verify-compose.mjs <profileDir>`,
459
495
  现在它会因为「行名解析不回本包」直接 FAIL。
460
496
  5. `pnpm add` 必须**在 profile 目录里**执行;装到别处(全局 `npm i -g`、别的目录)profile 合成不到。
@@ -469,6 +505,9 @@ pnpm add @restregular/dsh-skill-center
469
505
  - 写操作只针对**落在受管技能根里、真实存在于磁盘**的文件:
470
506
  `<projectRoot>/.dsh/skills`、`<projectRoot>/.agents/skills`、`$DSH_HOME/skills`、`$DSH_AGENTS_HOME/skills`。
471
507
  - frontmatter 改写是字符串级编辑,只动与调用策略相关的键,其余逐字保留(含行尾风格)。
508
+ - 受管根内的技能,`list` 的调用策略**以磁盘上的文件为准**,不照抄注册表快照:技能目录是缓存的,
509
+ 而 watcher 的失效事件总在写盘之后一小会儿才到 —— 只信快照的面板会在刚保存完的瞬间把界面弹回
510
+ 旧值,这正是「拨完开关又自己跳回去、手动刷新才变」的根因。
472
511
  - 删除只 rename 进 `.trash`。
473
512
  - 另注册一个内嵌技能提供方(`ctx.skills.registerProvider`,指向包内 `skills/skill-authoring/`)
474
513
  和 `skill_create` 工具(`ctx.inject(['tools'], …)` + `ctx.tools.register`)。
@@ -493,6 +532,11 @@ ctx.slots.inject('main', () => ctx.slots.register({
493
532
  样式只用宿主主题令牌 `--dsw-alias-*` / `--dsw-radius-*` / `--dsw-focus-ring-*`,
494
533
  `<style>` 带 `data-plugin="dsh-skill-center"`(避免被宿主的无主样式回收误删)。
495
534
 
535
+ 开关的改动先落在组件状态里:开关的值取「本地覆盖 → 目录快照」的先后顺序,
536
+ 保存按钮的三态由「待保存集合 + 是否保存过」推出。保存之后本地覆盖会继续留着,
537
+ 直到某次 `list` 的快照真的与它一致(`load()` 只撤销已经追平的那些)——
538
+ 所以宿主的目录快照慢半拍也不会把界面弹回去。
539
+
496
540
  ### API 示例
497
541
 
498
542
  ```powershell
@@ -537,12 +581,28 @@ node "$here\verify-skill.mjs"
537
581
  (目录名 vs frontmatter、description 非空、`resourceBase` 目录真实存在、正文表格/链接里点到的
538
582
  `references/` 等路径是否真的存在)、检查工具 schema 是否落在 `@deepseek-ai/dsh-tools` 支持的子集内,
539
583
  然后**真的跑一遍** `skill_create`:新建、资源落盘、覆盖、重名、非法名、路径穿越、平坦文件、
540
- 显式项目根,以及三条降级路径。
584
+ 显式项目根,以及三条降级路径;另外还断言:受管根技能的调用策略取自磁盘文件,即使注册表快照
585
+ 给的是旧值。
586
+
587
+ `client-smoke.mjs` 用迷你 React 把真实面板渲染在真宿主回环路由上,再驱动「暂存 + 保存」全流程:
588
+ 拨开关不发任何写请求、点保存只发一次带两个策略字段的 `setInvocation`、按钮按
589
+ 未修改 → 保存 → 已保存 走(再改一次回到保存)、以及宿主目录快照慢半拍也弹不回开关。
590
+ 它拦下了所有 POST,所以跑一遍不会改动任何真实 `SKILL.md`。
541
591
 
542
592
  > 搬迁/改名注意:客户端模块表按 `(loader baseUrl, loader 行名)` 缓存包元信息,不含文件时间戳。
543
593
  > 把包挪到别处后,运行中的宿主仍握着旧路径;浏览器端用的是激活时读进内存的快照,
544
594
  > 所以刷新页面照常可用,**下次重启宿主**才会解析新路径。
545
595
 
596
+ ### 发版
597
+
598
+ 改 `version` → 提交 → 推送 → 在仓库根用有该 scope 发布权限的 token 执行 `npm publish`。
599
+ 注册表对成功的发布回 `202 {"success":true}`,随后该版本进入 **validating** 阶段:验证通过之前,
600
+ `/@restregular/dsh-skill-center/<版本>` 与 tarball 地址都回 `404`,`dist-tags` 仍指向上一个版本。
601
+ 这是正常窗口,不是失败。**用带时间戳参数的版本地址重新探测**(`?t=<ticks>`)即可,
602
+ 不要重复发布:同一版本再发一次会回
603
+ `E409 Cannot publish over previously staged version <版本>`(`npm stage publish` 则回
604
+ `E409 Cannot stage previously published version <版本>`),而 `npm stage list` 可能什么都不显示。
605
+
546
606
  ### 已知边界
547
607
 
548
608
  - 面板占中央区域时,右栏的 `dsh-resource://` 预览不可用(宿主规定:无会话面时 `sidebarRight.openResource` 抛错)。
package/client/client.js CHANGED
@@ -83,6 +83,9 @@ window.__ModuleLoader__.load({
83
83
  noBody: '(正文为空)',
84
84
  created: '已创建',
85
85
  saved: '已保存',
86
+ unmodified: '未修改',
87
+ save: '保存',
88
+ saveHint: '把改动写回 SKILL.md 的 frontmatter',
86
89
  back: '返回会话',
87
90
  retry: '重试',
88
91
  modelOff: '模型关闭',
@@ -132,6 +135,9 @@ window.__ModuleLoader__.load({
132
135
  noBody: '(empty body)',
133
136
  created: 'Created',
134
137
  saved: 'Saved',
138
+ unmodified: 'Unmodified',
139
+ save: 'Save',
140
+ saveHint: 'Write the changes back into the SKILL.md frontmatter',
135
141
  back: 'Back to session',
136
142
  retry: 'Retry',
137
143
  modelOff: 'model off',
@@ -389,6 +395,17 @@ window.__ModuleLoader__.load({
389
395
  const [body, setBody] = React.useState(undefined);
390
396
  const [pendingDelete, setPendingDelete] = React.useState(false);
391
397
  const [creating, setCreating] = React.useState(false);
398
+ /** 已点开关、还没点"保存"的改动:技能名 → 部分调用策略。 */
399
+ const [pendingEdits, setPendingEdits] = React.useState({});
400
+ /**
401
+ * 面板显示值(技能名 → 部分调用策略),覆盖目录快照里的同名字段。
402
+ * 为什么要本地留存:宿主的技能目录是**缓存**的,写盘之后 chokidar 失效
403
+ * 事件到达前那次 `list` 仍是旧值 —— 只信快照的话,刚点完开关的界面会
404
+ * 自己弹回去(这正是"点了 toggle 不切换、手动刷新才切换"的原因)。
405
+ */
406
+ const [shownEdits, setShownEdits] = React.useState({});
407
+ /** 本次进入面板后是否保存过:决定按钮显示"未修改"还是"已保存"。 */
408
+ const [savedOnce, setSavedOnce] = React.useState(false);
392
409
 
393
410
  const load = React.useCallback(async () => {
394
411
  setBusy(true);
@@ -397,6 +414,29 @@ window.__ModuleLoader__.load({
397
414
  const payload = await apiGet('list', pickScope(sessions, scopeCache));
398
415
  setCatalog(payload);
399
416
  setSelected((previous) => previous ?? payload.skills[0]?.name);
417
+ // 快照已经追平本地面板值的字段就地撤销覆盖,覆盖集合只留"宿主还没追上"的部分。
418
+ setShownEdits((previous) => {
419
+ const next = {};
420
+ let changed = false;
421
+ for (const [name, patch] of Object.entries(previous)) {
422
+ const skill = payload.skills.find((candidate) => candidate.name === name);
423
+ if (skill === undefined) { changed = true; continue; }
424
+ const fresh = {
425
+ modelInvocable: skill.modelInvocable !== false,
426
+ userInvocable: skill.userInvocable !== false,
427
+ };
428
+ const keep = {};
429
+ if (patch.modelInvocable !== undefined && patch.modelInvocable !== fresh.modelInvocable) {
430
+ keep.modelInvocable = patch.modelInvocable;
431
+ }
432
+ if (patch.userInvocable !== undefined && patch.userInvocable !== fresh.userInvocable) {
433
+ keep.userInvocable = patch.userInvocable;
434
+ }
435
+ if (Object.keys(keep).length > 0) next[name] = keep;
436
+ else changed = true;
437
+ }
438
+ return changed ? next : previous;
439
+ });
400
440
  } catch (cause) {
401
441
  setError(String(cause?.message ?? cause));
402
442
  } finally {
@@ -423,7 +463,7 @@ window.__ModuleLoader__.load({
423
463
  /**
424
464
  * 跑一个写操作,然后重读目录。
425
465
  * @param action - 返回 Promise 的动作。
426
- * @param okMessage - 成功提示。
466
+ * @param okMessage - 成功提示;不传就不显示绿色提示条。
427
467
  */
428
468
  const mutate = async (action, okMessage) => {
429
469
  setBusy(true);
@@ -440,16 +480,77 @@ window.__ModuleLoader__.load({
440
480
  }
441
481
  };
442
482
 
443
- const setInvocation = (skill, patch) => mutate(
444
- () => apiPost({
445
- op: 'setInvocation',
446
- ...pickScope(sessions, scopeCache),
447
- name: skill.name,
448
- modelInvocable: patch.modelInvocable ?? skill.modelInvocable,
449
- userInvocable: patch.userInvocable ?? skill.userInvocable,
450
- }),
451
- t('saved'),
452
- );
483
+ /**
484
+ * 一个技能此刻该显示的调用策略:本地改动优先,其次目录快照。
485
+ * @param skill - 目录条目。
486
+ * @returns `{ modelInvocable, userInvocable }`。
487
+ */
488
+ const shownInvocation = (skill) => {
489
+ const local = shownEdits[skill.name] ?? {};
490
+ return {
491
+ modelInvocable: local.modelInvocable ?? (skill.modelInvocable !== false),
492
+ userInvocable: local.userInvocable ?? (skill.userInvocable !== false),
493
+ };
494
+ };
495
+
496
+ /**
497
+ * 拨一个开关:只改本地状态,界面立刻反映,写盘交给"保存"。
498
+ * @param skill - 目录条目。
499
+ * @param patch - 本次改动的字段。
500
+ */
501
+ const editInvocation = (skill, patch) => {
502
+ setShownEdits((previous) => ({
503
+ ...previous,
504
+ [skill.name]: { ...(previous[skill.name] ?? {}), ...patch },
505
+ }));
506
+ setPendingEdits((previous) => ({
507
+ ...previous,
508
+ [skill.name]: { ...(previous[skill.name] ?? {}), ...patch },
509
+ }));
510
+ setSavedOnce(false);
511
+ setError(undefined);
512
+ setNotice(undefined);
513
+ };
514
+
515
+ /**
516
+ * 保存全部未保存的调用策略改动:逐个 POST,然后重读目录。
517
+ * 重读拿到的可能仍是旧快照(目录缓存),所以显示值继续由 shownEdits 兜住,
518
+ * 直到快照追平 —— 撤销覆盖的逻辑在 load() 里。
519
+ */
520
+ const saveEdits = async () => {
521
+ const entries = Object.entries(pendingEdits);
522
+ if (entries.length === 0) return;
523
+ setBusy(true);
524
+ setError(undefined);
525
+ setNotice(undefined);
526
+ try {
527
+ for (const [name, patch] of entries) {
528
+ const skill = skills.find((candidate) => candidate.name === name);
529
+ if (skill === undefined) continue;
530
+ const target = { ...shownInvocation(skill), ...patch };
531
+ await apiPost({
532
+ op: 'setInvocation',
533
+ ...pickScope(sessions, scopeCache),
534
+ name,
535
+ modelInvocable: target.modelInvocable,
536
+ userInvocable: target.userInvocable,
537
+ });
538
+ }
539
+ setPendingEdits({});
540
+ setSavedOnce(true);
541
+ await load();
542
+ } catch (cause) {
543
+ setError(String(cause?.message ?? cause));
544
+ } finally {
545
+ setBusy(false);
546
+ }
547
+ };
548
+
549
+ /** 有多少个技能带着未保存的改动。 */
550
+ const pendingCount = Object.keys(pendingEdits).length;
551
+ const saveLabel = pendingCount === 0
552
+ ? (savedOnce ? t('saved') : t('unmodified'))
553
+ : pendingCount === 1 ? t('save') : `${t('save')} (${pendingCount})`;
453
554
 
454
555
  const removeSkill = (skill) => mutate(
455
556
  async () => {
@@ -457,6 +558,18 @@ window.__ModuleLoader__.load({
457
558
  setSelected(undefined);
458
559
  setBody(undefined);
459
560
  setPendingDelete(false);
561
+ setPendingEdits((previous) => {
562
+ if (!(skill.name in previous)) return previous;
563
+ const next = { ...previous };
564
+ delete next[skill.name];
565
+ return next;
566
+ });
567
+ setShownEdits((previous) => {
568
+ if (!(skill.name in previous)) return previous;
569
+ const next = { ...previous };
570
+ delete next[skill.name];
571
+ return next;
572
+ });
460
573
  },
461
574
  t('deleted'),
462
575
  );
@@ -481,6 +594,14 @@ window.__ModuleLoader__.load({
481
594
  ? t('subtitle')
482
595
  : `${t('total', { n: skills.length })} · ${scopeText}`),
483
596
  h('span', { className: 'dsh-sc-spacer', key: 'sp' }),
597
+ h('button', {
598
+ key: 'save',
599
+ type: 'button',
600
+ className: `dsh-sc-btn${pendingCount > 0 ? ' dsh-sc-btn-primary' : ''}`,
601
+ disabled: busy || pendingCount === 0,
602
+ title: t('saveHint'),
603
+ onClick: saveEdits,
604
+ }, saveLabel),
484
605
  h('button', {
485
606
  key: 'back',
486
607
  type: 'button',
@@ -579,19 +700,19 @@ window.__ModuleLoader__.load({
579
700
  h('div', { key: 'sw', className: 'dsh-sc-section' }, [
580
701
  h(Switch, {
581
702
  key: 'model',
582
- checked: current.modelInvocable !== false,
703
+ checked: shownInvocation(current).modelInvocable,
583
704
  disabled: busy || current.writable !== true,
584
705
  label: t('model'),
585
706
  hint: t('modelHint'),
586
- onChange: (value) => setInvocation(current, { modelInvocable: value }),
707
+ onChange: (value) => editInvocation(current, { modelInvocable: value }),
587
708
  }),
588
709
  h(Switch, {
589
710
  key: 'user',
590
- checked: current.userInvocable !== false,
711
+ checked: shownInvocation(current).userInvocable,
591
712
  disabled: busy || current.writable !== true,
592
713
  label: t('user'),
593
714
  hint: t('userHint'),
594
- onChange: (value) => setInvocation(current, { userInvocable: value }),
715
+ onChange: (value) => editInvocation(current, { userInvocable: value }),
595
716
  }),
596
717
  ]),
597
718
  current.writable !== true
package/lib/index.js CHANGED
@@ -71,6 +71,15 @@ const BUNDLED_SKILL_NAMES = ['skill-authoring'];
71
71
  /** provider 名:面板的 provider 列显示这个名字。 */
72
72
  const BUNDLED_SKILL_PROVIDER = 'dsh-skill-center';
73
73
 
74
+ /**
75
+ * 浏览器模块图里本插件那一行的 id 候选:图按**包名**编号,插件 id 只是宿主侧名字。
76
+ * 两个都收,免得 loader 行名或注册 id 变一处就误报。
77
+ */
78
+ const BROWSER_ENTRY_IDS = new Set([
79
+ '@restregular/dsh-skill-center',
80
+ 'dsh-skill-center',
81
+ ]);
82
+
74
83
  /**
75
84
  * 随包技能的 rank,与 `@deepseek-ai/dsh-skill` 的 `BUNDLED_SKILL_RANK` 一致:
76
85
  * 项目根(100/200)与用户根(400/500)的同名技能都能盖过它。
@@ -200,6 +209,22 @@ function frontmatterField(text, key) {
200
209
  return raw.replace(/^["']/u, '').replace(/["']$/u, '');
201
210
  }
202
211
 
212
+ /**
213
+ * 读 frontmatter 里的布尔字段,取值规则与 dsh-skill-filesystem 的
214
+ * `frontmatterBoolean` 一致(true/false/1/0/yes/no/on/off,大小写不敏感)。
215
+ * @param text - 文件全文。
216
+ * @param key - 字段名。
217
+ * @returns 布尔值,或 undefined(缺字段或值非法)。
218
+ */
219
+ function frontmatterBoolean(text, key) {
220
+ const raw = frontmatterField(text, key);
221
+ if (raw === undefined) return undefined;
222
+ const value = raw.toLowerCase();
223
+ if (value === 'true' || value === '1' || value === 'yes' || value === 'on') return true;
224
+ if (value === 'false' || value === '0' || value === 'no' || value === 'off') return false;
225
+ return undefined;
226
+ }
227
+
203
228
  // ---------------------------------------------------------------------------
204
229
  // 随包技能:注册成 provider,资源用 resourceBase 目录暴露
205
230
  // ---------------------------------------------------------------------------
@@ -487,13 +512,29 @@ function describeSkill(skill, roots) {
487
512
  const root = rootOfPath(path, roots);
488
513
  const onDisk = path !== undefined && existsSync(path) && statSync(path).isFile();
489
514
  let bytes;
515
+ let invocation;
490
516
  if (onDisk) {
491
517
  try {
492
518
  bytes = statSync(path).size;
493
519
  } catch {
494
520
  bytes = undefined;
495
521
  }
522
+ // 受管根里的技能以**磁盘为准**:技能目录是缓存的,写完盘之后 watcher 的失效
523
+ // 事件到达之前,快照里的调用策略还是旧值 —— 面板若照抄快照,刚拨完开关的界面
524
+ // 会被自己重读回来的旧值弹回去(手动刷新才会变)。这里现读一次 frontmatter 求解。
525
+ if (root !== undefined) {
526
+ try {
527
+ const text = readFileSync(path, 'utf8');
528
+ invocation = {
529
+ modelInvocable: frontmatterBoolean(text, 'disable-model-invocation') !== true,
530
+ userInvocable: frontmatterBoolean(text, 'user-invocable') !== false,
531
+ };
532
+ } catch {
533
+ invocation = undefined;
534
+ }
535
+ }
496
536
  }
537
+ const effective = invocation ?? skill.invocation;
497
538
  return {
498
539
  name: skill.name,
499
540
  description: skill.description,
@@ -502,8 +543,8 @@ function describeSkill(skill, roots) {
502
543
  source: skill.source,
503
544
  path,
504
545
  directory: path === undefined ? undefined : dirname(path),
505
- modelInvocable: skill.invocation?.modelInvocable !== false,
506
- userInvocable: skill.invocation?.userInvocable !== false,
546
+ modelInvocable: effective?.modelInvocable !== false,
547
+ userInvocable: effective?.userInvocable !== false,
507
548
  rootId: root?.id,
508
549
  rootLabel: root?.label,
509
550
  writable: root !== undefined && onDisk,
@@ -551,7 +592,10 @@ async function opDiagnose(ctx, request) {
551
592
  const clientModules = ctx.get('clientModules');
552
593
  const graph = clientModules?.graph?.();
553
594
  const entries = Array.isArray(graph?.entries) ? graph.entries : [];
554
- const mine = entries.find((entry) => entry.id === 'dsh-skill-center');
595
+ // 浏览器模块图按**包名**给行编号(客户端 bundle 的注册 id 也是包名),
596
+ // 插件 id 只是宿主侧的 loader 名字:只按 'dsh-skill-center' 找会永远找不到,
597
+ // diagnose 就会把"客户端半边好好地挂在图里"误报成 inGraph: false。
598
+ const mine = entries.find((entry) => BROWSER_ENTRY_IDS.has(entry.id));
555
599
  let catalog;
556
600
  try {
557
601
  const catalogRead = await readCatalog(ctx, request);
@@ -564,6 +608,7 @@ async function opDiagnose(ctx, request) {
564
608
  host: { mounted: true, route: ROUTE },
565
609
  client: {
566
610
  inGraph: mine !== undefined,
611
+ entryId: mine?.id,
567
612
  graphRevision: graph?.rev,
568
613
  graphEntryCount: entries.length,
569
614
  url: mine?.url,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@restregular/dsh-skill-center",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "技能中心 — DeepSeek Harness 技能管理面板:浏览当前会话可见的所有技能、查看正文、开关模型/用户调用、新建与删除;随包附带 skill-authoring 指导技能与 skill_create 模型工具。 · Skill Center for DeepSeek Harness: browse and manage the skills visible to the current session, with a bundled skill-authoring skill and a skill_create model tool.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",