dsh-plugin-tool-management 0.8.0 → 0.9.0

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +67 -1
  2. package/README.md +53 -48
  3. package/README_EN.md +47 -44
  4. package/docs/images/1-EN.png +0 -0
  5. package/docs/images/1.png +0 -0
  6. package/docs/images/2-EN.png +0 -0
  7. package/docs/images/2.png +0 -0
  8. package/docs/images/3-EN.png +0 -0
  9. package/docs/images/3.png +0 -0
  10. package/docs/images/4-EN.png +0 -0
  11. package/docs/images/4.png +0 -0
  12. package/docs/images/5-EN.png +0 -0
  13. package/docs/images/5.png +0 -0
  14. package/docs/images/6-EN.png +0 -0
  15. package/docs/images/6.png +0 -0
  16. package/docs/images/7-EN.png +0 -0
  17. package/docs/images/7.png +0 -0
  18. package/docs/images/8-EN.png +0 -0
  19. package/docs/images/8.png +0 -0
  20. package/docs/update.md +39 -0
  21. package/lib/agents-md/preset-id.js +1 -1
  22. package/lib/agents-md/service.js +46 -9
  23. package/lib/approval-policy.js +1 -1
  24. package/lib/client.js +431 -135
  25. package/lib/compat/preset-reach.js +155 -93
  26. package/lib/compat/probe.js +6 -4
  27. package/lib/context-inject.js +304 -0
  28. package/lib/hub.js +155 -13
  29. package/lib/imports/upload.js +34 -14
  30. package/lib/index.js +870 -226
  31. package/lib/mcp/state-section.js +46 -15
  32. package/lib/rules/archive-engine.js +182 -57
  33. package/lib/rules/archive.js +57 -1
  34. package/lib/rules/service.js +386 -156
  35. package/lib/scene-prompt-sync.js +101 -12
  36. package/lib/skills/catalog.js +125 -0
  37. package/lib/skills/core.js +38 -4
  38. package/lib/skills/service.js +36 -10
  39. package/lib/subagents/catalog.js +15 -5
  40. package/lib/subagents/service.js +39 -28
  41. package/lib/subagents/tools.js +18 -13
  42. package/package.json +14 -9
  43. package/screenshots.json +8 -8
  44. package/docs/images/1/345/234/272/346/231/257.png +0 -0
  45. package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
  46. package/docs/images/2MCP.png +0 -0
  47. package/docs/images/2MCP_en.png +0 -0
  48. package/docs/images/3/346/212/200/350/203/275.png +0 -0
  49. package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
  50. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
  51. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
  52. package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
  53. package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
  54. package/docs/images/6/350/256/260/345/277/206.png +0 -0
  55. package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
  56. package/docs/images/7/344/274/232/350/257/235.png +0 -0
  57. package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
  58. package/docs/images/8/345/205/274/345/256/271.png +0 -0
  59. package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
  60. package/lib/prompt-sections.js +0 -148
@@ -1,12 +1,15 @@
1
1
  // dsh-plugin-tool-management —— 场景绑定提示词 ⇄ 全局基线(`~/.dsh/AGENTS.md`)同步。
2
2
  //
3
3
  // 用户裁定(2026-09-15):「切换场景,对应的提示词直接把 AGENTS.md 直接修改」。
4
- // 因此场景绑定的提示词不再作为 systemPrompt 段注入,而是**真写全局基线文件**,
5
- // 与「提示词」页的「应用」走同一条路(覆盖前多代备份到 `agents-md/__last-applied__/`)。
4
+ // 因此场景绑定的提示词不再作为提示词段/注入发送,而是**真写全局基线文件**,
5
+ // 与「提示词」页的「应用」走同一条路(覆盖前多代备份到 `prompts/__last-applied__/`)。
6
+ // 例外(2026-09-16):预设**挂不到**官方 `dsh-agent-instructions` 行时(极简),文件没人读,
7
+ // 注入通道会拿同一份正文兜底送达(rules 服务的 `promptText()`)。
6
8
  //
7
- // 四个动作会触发同步(调用方在 op 层包一层 `withSync`):
9
+ // 六个动作会触发同步(调用方在 op 层包一层 `withSync`):
8
10
  // ① 启用/切换场景 ② 关掉场景(恢复进场景前的基线)
9
11
  // ③ 改场景绑定的预设 ④ 编辑"正在驱动基线的那份预设"的正文
12
+ // ⑤ 从回收站恢复预设(绑定重新变活) ⑥ 应用提示词(场景接管时只放行绑定的那一份)
10
13
  //
11
14
  // 「进场景前的基线」快照存在 hub 的 `scene-baseline.json`:进场景时记下当时的
12
15
  // AGENTS.md 正文(以及当时匹配到的预设 id),关掉场景时**按原文**写回 ——
@@ -15,6 +18,11 @@
15
18
  //
16
19
  // 幂等与如实:文件已经是那份(`duplicate`)时不写盘;没有场景驱动、也没有快照时什么都不做;
17
20
  // 写失败**不改**原操作的成功结论,而是把原因放进 `error` 交给界面显示成警告。
21
+ //
22
+ // 这里同时是**三条状态的事实源**(用户实测反馈「显示 A、实际注入 B、A 还能删 B 不能删」后加的):
23
+ // `driver()` —— 谁在驱动基线(提示词页「应用」的守卫依据:场景接管期间不许应用别的预设);
24
+ // `refs()` —— 每个预设被谁引用(删除保护的唯一依据:场景绑定 / 基线当前内容 / 退出恢复目标);
25
+ // `baseline()` —— 进场景前的基线快照(退出场景后要恢复的那一份)。
18
26
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
19
27
  import { dirname } from 'node:path';
20
28
  export function createScenePromptSync(deps) {
@@ -54,9 +62,12 @@ export function createScenePromptSync(deps) {
54
62
  const sp = r && r.ok ? r.scenePrompt : null;
55
63
  if (!sp)
56
64
  return null;
65
+ const scene = sp.scene ? String(sp.scene) : null;
57
66
  return {
58
- scene: sp.scene ? String(sp.scene) : null,
67
+ scene,
59
68
  presetId: sp.presetId ? String(sp.presetId) : null,
69
+ // 显示名缺失时退回场景名(老载荷 / 桩),宁可粗糙也不要空字符串。
70
+ label: sp.label ? String(sp.label) : (scene || ''),
60
71
  missing: sp.missing === true,
61
72
  duplicate: sp.duplicate === true,
62
73
  };
@@ -66,18 +77,96 @@ export function createScenePromptSync(deps) {
66
77
  return null;
67
78
  }
68
79
  }
80
+ /** 一个场景是否真的在驱动基线:有场景、绑定非空且预设正文读得到。 */
81
+ const drives = (s) => !!s && s.scene !== null && !s.missing && s.presetId !== null;
82
+ async function driver() {
83
+ const current = await state();
84
+ if (!drives(current))
85
+ return null;
86
+ return { scene: current.scene, label: current.label || current.scene, presetId: current.presetId };
87
+ }
88
+ async function applyGuard(id) {
89
+ const current = await driver();
90
+ if (!current)
91
+ return null;
92
+ // 同一份 = 重新应用(把被手改的基线写回场景绑定的内容)→ 放行。
93
+ if (current.presetId === String(id ?? '').trim())
94
+ return null;
95
+ return current;
96
+ }
97
+ async function refs() {
98
+ const out = new Map();
99
+ const add = (id, ref) => {
100
+ const list = out.get(id);
101
+ if (!list) {
102
+ out.set(id, [ref]);
103
+ return;
104
+ }
105
+ const dup = list.some((x) => {
106
+ if (x.kind !== ref.kind)
107
+ return false;
108
+ if (x.kind === 'scene' && ref.kind === 'scene')
109
+ return x.scene === ref.scene;
110
+ return true;
111
+ });
112
+ if (!dup)
113
+ list.push(ref);
114
+ };
115
+ // 三类探测各自失败都算「没探全」→ 整体返回 null(放行删除 + warn),
116
+ // 与原有「探测失败不拦删除、最坏少一份副本」的口径一致。
117
+ let complete = true;
118
+ try {
119
+ const r = await deps.rules.ops['rules-list']({});
120
+ if (!r || r.ok !== true) {
121
+ complete = false;
122
+ }
123
+ else {
124
+ for (const s of (Array.isArray(r.scenes) ? r.scenes : [])) {
125
+ const id = s && typeof s.prompt === 'string' ? s.prompt : '';
126
+ if (id === '')
127
+ continue;
128
+ const scene = String((s && s.name) || '');
129
+ add(id, { kind: 'scene', scene, label: String((s && s.label) || scene), active: s && s.active === true });
130
+ }
131
+ }
132
+ }
133
+ catch (e) {
134
+ complete = false;
135
+ warn(`scene-prompt: refs probe (scenes) failed: ${String(e)}`);
136
+ }
137
+ try {
138
+ const cur = await deps.agentsMd.getCurrent();
139
+ if (!cur || cur.ok !== true)
140
+ complete = false;
141
+ else if (cur.presetId)
142
+ add(String(cur.presetId), { kind: 'file' });
143
+ }
144
+ catch (e) {
145
+ complete = false;
146
+ warn(`scene-prompt: refs probe (baseline file) failed: ${String(e)}`);
147
+ }
148
+ try {
149
+ const saved = await readBaseline();
150
+ if (saved && saved.content !== null && saved.presetId)
151
+ add(saved.presetId, { kind: 'restore' });
152
+ }
153
+ catch (e) {
154
+ complete = false;
155
+ warn(`scene-prompt: refs probe (snapshot) failed: ${String(e)}`);
156
+ }
157
+ return complete ? out : null;
158
+ }
69
159
  async function sync() {
70
160
  const current = await state();
71
161
  if (!current)
72
162
  return { unchanged: true };
73
- const driving = current.scene !== null && !current.missing && current.presetId !== null;
74
- if (driving) {
163
+ if (drives(current)) {
75
164
  if (current.duplicate)
76
165
  return { applied: current.presetId, unchanged: true };
77
166
  // 进场景的第一笔写入之前,先记下当时的基线(已有快照就不覆盖:连续切场景时
78
167
  // 基线始终是"进场景之前"那一份)。
79
- const baseline = await readBaseline();
80
- if (!baseline || baseline.content === null) {
168
+ const saved = await readBaseline();
169
+ if (!saved || saved.content === null) {
81
170
  const cur = await deps.agentsMd.getCurrent();
82
171
  await writeBaseline({
83
172
  v: 1,
@@ -92,10 +181,10 @@ export function createScenePromptSync(deps) {
92
181
  return { applied: current.presetId };
93
182
  }
94
183
  // 没有场景驱动 → 如果刚从驱动态退出,把进场景前的基线写回去。
95
- const baseline = await readBaseline();
96
- if (!baseline || baseline.content === null)
184
+ const saved = await readBaseline();
185
+ if (!saved || saved.content === null)
97
186
  return { unchanged: true };
98
- const res = await deps.agentsMd.restore(baseline.content);
187
+ const res = await deps.agentsMd.restore(saved.content);
99
188
  if (res && res.ok === false)
100
189
  return { error: String(res.error || '恢复 AGENTS.md 失败') };
101
190
  await writeBaseline({ v: 1, at: Date.now(), presetId: null, content: null });
@@ -108,5 +197,5 @@ export function createScenePromptSync(deps) {
108
197
  const agentsMd = await sync();
109
198
  return { ...res, agentsMd };
110
199
  }
111
- return { sync, withSync, state };
200
+ return { sync, withSync, state, driver, applyGuard, refs };
112
201
  }
@@ -0,0 +1,125 @@
1
+ // src/skills/catalog.ts —— 技能目录段(注入通道的数据源)。
2
+ //
3
+ // 为什么需要它:技能目录本来是官方 `@deepseek-ai/dsh-tool-skill` 的活 —— 它随预设挂载,
4
+ // 极简这类预设没挂,模型连"有哪些技能"都不知道(网页版把宿主那两条行交给预设后尤其如此)。
5
+ // 本模块提供同一份信息的**兜底通道**:只在预设没挂官方那一行时注入(判定在
6
+ // src/context-inject.ts 的 `CARRIER_FACT_OF`),格式与官方目录同形(`- \`name\`: description`)。
7
+ //
8
+ // 数据源用**插件自己的技能清单**(技能服务的 `skill-state`):它已经含启停 / 被同名覆盖 /
9
+ // 同名首选的判定 —— 那正是用户在技能页管的那些开关;官方注册表里的同一份策略也由本插件的
10
+ // provider 喂进去,所以两边看到的是同一批技能。
11
+ //
12
+ // 与官方目录的两处有意差别:
13
+ // - 只列**当前启用且可被模型调用**的(官方只过滤可调用性,不管用户在插件页关掉了谁)。
14
+ // - 结尾多一行:本预设没有官方 `skill` 加载工具(目录与工具是一起挂的),需要正文时得先
15
+ // `skill_manager_list` 取源文件路径再读那个文件 —— 不写这句,模型会以为有 `skill` 工具可调。
16
+ //
17
+ // 同步性:`text()` 必须同步返回(注入通道每个 step 同步取文本),而技能清单是异步读盘 →
18
+ // 与子智能体目录同构的 stale-while-revalidate:`text()` 返回缓存值并在超龄时后台重算,
19
+ // `refresh()` 供技能写操作后立即重算。
20
+ /** 描述截断长度(与官方目录的 `catalogDescriptionMaxLength` 默认值一致)。 */
21
+ export const SKILL_CATALOG_DESCRIPTION_MAX_LENGTH = 500;
22
+ /** 目录最多列几条;超出只报数量(让模型去调 `skill_manager_list`)。 */
23
+ export const SKILL_CATALOG_MAX_ENTRIES = 50;
24
+ /** 描述归一化 + 截断(与官方 `catalogDescription` 同款:折叠空白、超出补省略号)。 */
25
+ function catalogDescription(value, maxLength) {
26
+ const normalized = String(value ?? '').replaceAll(/\s+/g, ' ').trim();
27
+ if (normalized.length <= maxLength)
28
+ return normalized;
29
+ return `${normalized.slice(0, maxLength - 3)}...`;
30
+ }
31
+ /** 与 `skill_manager_list` / 界面(`isSkillEnabled`)同一套启停推导。 */
32
+ function isEnabled(row) {
33
+ if (row.enabled !== undefined)
34
+ return row.enabled === true;
35
+ return row.invocationPolicyValid === true && row.modelInvocable === true && row.userInvocable === true && row.managerEnabled !== false;
36
+ }
37
+ /**
38
+ * 渲染技能目录段(纯函数,便于单独推理)。
39
+ *
40
+ * 返回 `''` 表示没有可注入的技能 —— 没装技能的用户零 token 成本。
41
+ * 名字按字典序排序:底层目录枚举顺序变化时文本仍逐字节稳定(前缀缓存契约)。
42
+ */
43
+ export function renderSkillCatalog(data, maxEntries = SKILL_CATALOG_MAX_ENTRIES, maxDescription = SKILL_CATALOG_DESCRIPTION_MAX_LENGTH) {
44
+ const roots = (data && typeof data === 'object' ? data.roots : undefined) ?? [];
45
+ const byName = new Map();
46
+ if (Array.isArray(roots)) {
47
+ for (const root of roots) {
48
+ const skills = (root && typeof root === 'object' ? root.skills : undefined) ?? [];
49
+ if (!Array.isArray(skills))
50
+ continue;
51
+ for (const raw of skills) {
52
+ if (!raw || typeof raw !== 'object')
53
+ continue;
54
+ const row = raw;
55
+ // 影子里那份不可加载(同名只有胜出者生效),启停开关也管不到它 —— 不进目录。
56
+ if (row.shadowedBy)
57
+ continue;
58
+ if (!isEnabled(row) || row.modelInvocable === false)
59
+ continue;
60
+ const name = String(row.declaredName || row.name || '').trim();
61
+ if (name === '')
62
+ continue;
63
+ // 同名(跨根)只留一份:胜出者由 core 标出,这里按出现顺序先到先得,避免重复行。
64
+ if (byName.has(name))
65
+ continue;
66
+ byName.set(name, { name, description: catalogDescription(row.description, maxDescription) });
67
+ }
68
+ }
69
+ }
70
+ if (byName.size === 0)
71
+ return '';
72
+ const sorted = [...byName.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
73
+ const shown = sorted.slice(0, Math.max(0, maxEntries));
74
+ const lines = shown.map((row) => (row.description ? '- `' + row.name + '`: ' + row.description : '- `' + row.name + '`'));
75
+ const out = [
76
+ '## 技能',
77
+ '',
78
+ '**本预设没有官方 `skill` 加载工具;要技能正文时用 `skill_manager_list` 取源文件路径再读。**',
79
+ '',
80
+ ...lines,
81
+ ];
82
+ const hidden = sorted.length - shown.length;
83
+ if (hidden > 0)
84
+ out.push('', `(另有 ${hidden} 个未列出,用 \`skill_manager_list\` 查。)`);
85
+ return out.join('\n');
86
+ }
87
+ /**
88
+ * 创建 SWR 的技能目录。计算失败时保留上一次的值(不把段清空),首次失败维持 `''`。
89
+ * 并发调用共享同一次计算(inflight 折叠)。
90
+ */
91
+ export function createSkillCatalog(deps, opts = {}) {
92
+ const ttlMs = opts.ttlMs ?? 30_000;
93
+ const now = opts.now ?? (() => Date.now());
94
+ let value = '';
95
+ let loadedAt = Number.NEGATIVE_INFINITY;
96
+ let inflight = null;
97
+ const recompute = async () => {
98
+ try {
99
+ const result = await deps.state();
100
+ if (result && result.ok !== false) {
101
+ value = renderSkillCatalog(result.data ?? result, opts.maxEntries, opts.maxDescription);
102
+ }
103
+ }
104
+ catch { /* 保留上一次的值;首次失败则维持 '' */ }
105
+ loadedAt = now();
106
+ };
107
+ const revalidate = () => {
108
+ if (inflight)
109
+ return inflight;
110
+ inflight = recompute().finally(() => { inflight = null; });
111
+ return inflight;
112
+ };
113
+ return {
114
+ text: () => {
115
+ if (now() - loadedAt > ttlMs)
116
+ void revalidate();
117
+ return value;
118
+ },
119
+ refresh: () => {
120
+ loadedAt = Number.NEGATIVE_INFINITY;
121
+ return revalidate();
122
+ },
123
+ warm: () => revalidate(),
124
+ };
125
+ }
@@ -411,13 +411,15 @@ export function managerHomePath() {
411
411
  return join(resolveDshHome(), "tool-management");
412
412
  }
413
413
  export function managerStatePath() {
414
- return join(managerHomePath(), "state.json");
414
+ return join(managerHomePath(), "skills-state.json");
415
415
  }
416
+ /** 技能回收站:与其余四类同形(`hub/trash/<域>-trash/`)。 */
416
417
  export function trashRootPath() {
417
- return join(managerHomePath(), "trash");
418
+ return join(managerHomePath(), "trash", "skills-trash");
418
419
  }
420
+ /** 插件日志(滚动 `.1`):收在 hub 内,不再躺在 `$DSH_HOME` 根下。 */
419
421
  export function logPath() {
420
- return join(resolveDshHome(), "dsh-plugin-tool-management.log");
422
+ return join(managerHomePath(), "tool-management.log");
421
423
  }
422
424
  /**
423
425
  * 为前端内嵌目录选择器列出一个本机目录层级。
@@ -885,7 +887,7 @@ async function writeFileAtomically(path, content) {
885
887
  await fs.writeFile(temp, content, "utf8");
886
888
  // Windows 上目标文件会被杀软/索引器短暂占住(EPERM/EACCES/EBUSY)——
887
889
  // 场景模式进出时这里写的是技能来源/策略状态,rename 被撞 = 运行时已切、
888
- // 状态没落盘。与 rules-index.json 同一处理:重试瞬时占用,全失败才抛。
890
+ // 状态没落盘。与 memories-index.json 同一处理:重试瞬时占用,全失败才抛。
889
891
  await renameWithRetry(temp, path);
890
892
  }
891
893
  catch (error) {
@@ -1653,6 +1655,38 @@ async function setPolicySkillEnabled(root, name, enabled, log) {
1653
1655
  export async function setSkillEnabled(root, name, enabled, log) {
1654
1656
  return setPolicySkillEnabled(root, name, enabled, log);
1655
1657
  }
1658
+ /**
1659
+ * 批量显式停用技能(v0.8.5:新建 / 导入 / 回收站恢复默认不启动 —— 用户裁定)。
1660
+ * 直接写 disabledSkills 名单(与 setSkillEnabled 同一张表),不做 frontmatter 等
1661
+ * 二次校验——调用方传入的必然是刚落盘/刚恢复的条目。
1662
+ * 必须在调用方的写锁(service 的 write())内执行。
1663
+ * @param {{ root: string, name: string }[]} entries - root = 来源 key。
1664
+ */
1665
+ export async function markSkillsDisabled(entries, log) {
1666
+ const list = Array.isArray(entries) ? entries : [];
1667
+ const clean = list
1668
+ .map((e) => ({ root: String((e && e.root) || ""), name: String((e && e.name) || "") }))
1669
+ .filter((e) => e.root !== "" && e.name !== "");
1670
+ if (!clean.length)
1671
+ return { ok: true };
1672
+ const current = await readManagerState();
1673
+ if (current.writable === false)
1674
+ return invalidManagerStateWrite();
1675
+ let dirty = false;
1676
+ for (const e of clean) {
1677
+ const prev = current.state.disabledSkills[e.root] || [];
1678
+ if (prev.indexOf(e.name) >= 0)
1679
+ continue;
1680
+ current.state.disabledSkills[e.root] = [...prev, e.name].sort();
1681
+ dirty = true;
1682
+ }
1683
+ if (dirty) {
1684
+ await writeManagerState(current.state);
1685
+ if (log)
1686
+ log("skill-disable-default", `默认停用 ${clean.map((e) => `${e.root}/${e.name}`).join("、")}`);
1687
+ }
1688
+ return { ok: true };
1689
+ }
1656
1690
  /**
1657
1691
  * 同名技能「首选来源」:默认同名技能按来源 rank 取优先级最高者生效、其余显示为被覆盖;
1658
1692
  * 这里让用户显式指定哪个同名技能生效(preferred=false 取消,回到 rank 顺序)。
@@ -9,7 +9,7 @@ import { Worker } from 'node:worker_threads';
9
9
  import { spawn } from 'node:child_process';
10
10
  import { homedir } from 'node:os';
11
11
  import { basename, join } from 'node:path';
12
- import { state, setSkillEnabled, setPreferredSkill, setSourceEnabled, setSourceRemoved, deleteSkill, restoreTrash, permanentlyDeleteTrash, importSkill, importUploadedSkill, browseDirectories, createSkill, skillDetail, listProviderCandidates, getProviderSkill, userRoots, projectRoots, readManagerState, customRootsFromState, addCustomRoot, removeCustomRoot, logPath, trashRootPath, } from './core.js';
12
+ import { state, setSkillEnabled, setPreferredSkill, markSkillsDisabled, setSourceEnabled, setSourceRemoved, deleteSkill, restoreTrash, permanentlyDeleteTrash, importSkill, importUploadedSkill, browseDirectories, createSkill, skillDetail, listProviderCandidates, getProviderSkill, userRoots, projectRoots, readManagerState, customRootsFromState, addCustomRoot, removeCustomRoot, logPath, trashRootPath, } from './core.js';
13
13
  const MAX_LOG_BYTES = 1 << 20;
14
14
  const PROVIDER_NAME = 'dsh-plugin-tool-management-external';
15
15
  const message = (e) => String((e && e.message) || e);
@@ -431,17 +431,43 @@ export function createSkillsService(ctx) {
431
431
  // requestRoot 未命中时回传原始 key,让 core 的 error.root.unknown 带上来源名
432
432
  // (否则传了不存在的 root 会收到「技能来源不存在:(空)」这种指错方向的提示)。
433
433
  const key = String(args.root || 'hub');
434
- return createSkill({ name: args.name, description: args.description, body: args.body }, log, { root: (await requestRoot(key)) || key });
434
+ const res = await createSkill({ name: args.name, description: args.description, body: args.body }, log, { root: (await requestRoot(key)) || key });
435
+ // v0.8.5:新建技能默认不启动(用户裁定)——与子智能体 / MCP 同口径。
436
+ if (res && res.ok !== false && res.name) {
437
+ await markSkillsDisabled([{ root: String(res.root || 'hub'), name: String(res.name) }], log).catch(() => undefined);
438
+ }
439
+ return res;
440
+ }), afterWrite),
441
+ 'skill-import': wrap((args) => write(async () => {
442
+ const res = await importSkill(String(args.source || ''), log, {
443
+ conflict: args.conflict === 'overwrite' ? 'overwrite' : 'skip',
444
+ dryRun: args.dryRun === true,
445
+ });
446
+ // 导入的技能默认停用;部分成功也对 imported 逐条停用。
447
+ const names = res && Array.isArray(res.imported) ? res.imported : [];
448
+ if (names.length)
449
+ await markSkillsDisabled(names.map((n) => ({ root: 'hub', name: String(n) })), log).catch(() => undefined);
450
+ return res;
451
+ }), afterWrite),
452
+ 'skill-upload': wrap((args) => write(async () => {
453
+ const res = await importUploadedSkill({ name: args.name, entries: args.entries, zip: args.zip }, log, {
454
+ conflict: args.conflict === 'overwrite' ? 'overwrite' : 'skip',
455
+ });
456
+ // 上传/导入同口径:默认停用。
457
+ const names = res && Array.isArray(res.imported) ? res.imported : (res && res.name ? [res.name] : []);
458
+ if (names.length)
459
+ await markSkillsDisabled(names.map((n) => ({ root: 'hub', name: String(n) })), log).catch(() => undefined);
460
+ return res;
435
461
  }), afterWrite),
436
- 'skill-import': wrap((args) => write(() => importSkill(String(args.source || ''), log, {
437
- conflict: args.conflict === 'overwrite' ? 'overwrite' : 'skip',
438
- dryRun: args.dryRun === true,
439
- })), afterWrite),
440
- 'skill-upload': wrap((args) => write(() => importUploadedSkill({ name: args.name, entries: args.entries, zip: args.zip }, log, {
441
- conflict: args.conflict === 'overwrite' ? 'overwrite' : 'skip',
442
- })), afterWrite),
443
462
  'skill-delete': wrap((args) => write(async () => deleteSkill(await requestRoot(String(args.root || 'dsh')), String(args.name || ''), log)), afterWrite),
444
- 'skill-trash-restore': wrap((args) => write(() => restoreTrash(String(args.id || ''), log, projectOptions())), afterWrite),
463
+ 'skill-trash-restore': wrap((args) => write(async () => {
464
+ const res = await restoreTrash(String(args.id || ''), log, projectOptions());
465
+ // 恢复的技能默认停用(v0.8.5 用户裁定:新建/导入/恢复一律不启动,手动开启)。
466
+ if (res && res.ok !== false && res.name) {
467
+ await markSkillsDisabled([{ root: String(res.root || 'hub'), name: String(res.name) }], log).catch(() => undefined);
468
+ }
469
+ return res;
470
+ }), afterWrite),
445
471
  'skill-trash-delete': wrap((args) => write(() => permanentlyDeleteTrashSafely(String(args.id || ''), log))),
446
472
  // 自定义技能目录:添加(绝对路径,只读接入)/ 移除(连同策略键)。
447
473
  'skill-custom-add': wrap((args) => write(() => addCustomRoot(args && args.path, args && args.label, log)), afterWrite),
@@ -8,9 +8,9 @@ import { filterBySceneBinding } from './tools.js';
8
8
  * 官方对应的旋钮是 `catalogDescriptionMaxLength`。
9
9
  */
10
10
  export const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500;
11
- /** 段里最多列几个人设;超出部分只报数量,让模型自己去调 subagent_list。 */
11
+ /** 段里最多列几个人设;超出部分只报数量,让模型自己去调 subagent_manager_list。 */
12
12
  export const DEFAULT_CATALOG_MAX_ENTRIES = 40;
13
- /** 无描述时的占位,与 `subagent_list` 工具的输出保持同一口径。 */
13
+ /** 无描述时的占位,与 `subagent_manager_list` 工具的输出保持同一口径。 */
14
14
  const NO_DESCRIPTION = '(无描述)';
15
15
  /**
16
16
  * 描述归一化 + 截断 —— 逐字对齐官方 `dsh-tool-skill` 的实现:
@@ -27,6 +27,13 @@ export function catalogDescription(value, maxLength) {
27
27
  *
28
28
  * 返回 `''` 表示不注入 —— `renderPrompt` 会删除空段,所以不用人设的用户零 token 成本。
29
29
  * 名字按字典序排序:即使底层目录枚举顺序变化,段文本也保持逐字节稳定(前缀缓存契约)。
30
+ *
31
+ * 文案纪律(2026-09-16 用户裁定):**只写模型能照做的事,不写实现说明**。原文两句里
32
+ * 「该人设的完整提示词会成为子代理的系统提示词」「子代理在独立上下文中执行」都是宿主内部
33
+ * 机制,模型无法据此行动;而委派的调用语义与成本(自包含任务、只回最终结果、会开新会话)
34
+ * 已经写在 `subagent_manager_run` 的描述里 —— 常驻层再重复一遍等于同一件事付两次 token。
35
+ * 第二版(用户指出"太冗余"):引导语压成**半行** —— 只留「怎么用」,"下面是名字与摘要"这类
36
+ * 自明的话删掉;域是什么由消息引导语与轨迹行名交代,细节由 `subagent_manager_list` 承担。
30
37
  */
31
38
  export function renderSubagentCatalog(allowed, maxEntries = DEFAULT_CATALOG_MAX_ENTRIES, maxDescription = DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH) {
32
39
  if (!allowed.length)
@@ -36,14 +43,17 @@ export function renderSubagentCatalog(allowed, maxEntries = DEFAULT_CATALOG_MAX_
36
43
  const lines = shown.map((p) => '- **' + p.name + '** — ' + (catalogDescription(p.description, maxDescription) || NO_DESCRIPTION));
37
44
  const hidden = sorted.length - shown.length;
38
45
  const out = [
39
- '## 子智能体(人设)',
46
+ '## 子智能体',
40
47
  '',
41
- '需要委派独立任务时,用 `subagent_run` 并指定下面的人设名。',
48
+ '**可委派给下列子智能体(调 `subagent_manager_run`)。**',
42
49
  '',
43
50
  ...lines,
44
51
  ];
52
+ // 查询工具**只在真被 40 条上限截掉时**才出现:常态下不提,省常驻字符,也免得模型为了
53
+ // 「确认一遍」去调它(用户裁定:没列出来的就是当前不想要的)。与 MCP 状态段的
54
+ // `(另有 N 台未列出。)` 同一句式,但这里多给一个出口——不给人设就真的找不回来了。
45
55
  if (hidden > 0)
46
- out.push('', `(另有 ${hidden} 个未列出,用 subagent_list 查看全部。)`);
56
+ out.push('', `(另有 ${hidden} 个未列出,用 \`subagent_manager_list\` 查。)`);
47
57
  return out.join('\n');
48
58
  }
49
59
  /**
@@ -11,12 +11,13 @@ import { resolveDshHome } from '../skills/core.js';
11
11
  import { listTrashEntries, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
12
12
  import { expandUploads, planPersonaImport } from '../imports/upload.js';
13
13
  const message = (e) => String((e && e.message) || e);
14
- /** 人设默认目录:`$DSH_HOME/tool-management/agents/`。 */
14
+ /** 人设默认目录:`$DSH_HOME/tool-management/subagents/`(域 = 子智能体,工具 `subagent_manager_*`)。 */
15
15
  export function defaultPersonasDir() {
16
- return join(resolveDshHome(), 'tool-management', 'agents');
16
+ return join(resolveDshHome(), 'tool-management', 'subagents');
17
17
  }
18
18
  /**
19
- * 旧目录 `$DSH_HOME/subagents/` → `tool-management/agents/` 一次性搬移(幂等)。
19
+ * 旧目录 `$DSH_HOME/subagents/` → `tool-management/subagents/` 一次性搬移(幂等)。
20
+ * (hub 内 `agents/` → `subagents/` 的改名由 hub.ts 的启动迁移负责。)
20
21
  * 只在目标不存在同名文件时搬(绝不覆盖);源目录保留空壳。每个进程只跑一次。
21
22
  */
22
23
  let personasRelocated = false;
@@ -131,11 +132,11 @@ const RESULT_MAX = 16 * 1024;
131
132
  export function createSubagentService(ctx, opts) {
132
133
  const dir = opts?.subagentsDir || defaultPersonasDir();
133
134
  const stateDir = opts?.stateDir && opts.stateDir.trim() !== '' ? opts.stateDir : join(resolveDshHome(), 'tool-management');
134
- const stateFile = join(stateDir, 'agents-index.json');
135
+ const stateFile = join(stateDir, 'subagents-index.json');
135
136
  const req = createRequire(import.meta.url);
136
137
  let cache = null;
137
138
  // ── 人设启用集合(子智能体开关)────────────────────────────────────────
138
- // agents-index.json:{ version: 1, enabled: string[] }(与 rules-index.json 同目录约定)。
139
+ // subagents-index.json:{ version: 1, enabled: string[] }(与 memories-index.json 同目录约定)。
139
140
  // - 文件缺失/损坏 = **全部启用**(老用户升级零感知,行为与开关上线前一致);
140
141
  // - 文件一旦写出即为权威:之后新建/导入/回收站恢复的人设**自动启用**(刚建就想用是常理);
141
142
  // - 停用只影响注入与 subagent_* 工具的可见性,人设文件一个字节不动。
@@ -161,19 +162,17 @@ export function createSubagentService(ctx, opts) {
161
162
  await writeFile(stateFile, JSON.stringify({ version: 1, enabled: list }, null, 2), 'utf8');
162
163
  enabledCache = list;
163
164
  }
164
- /** 新建/导入/恢复的人设默认启用;文件缺失(本来就全启用)时无需写。 */
165
- async function enablePersonaNames(names) {
165
+ /** 新建/导入/恢复的人设默认停用(v0.8.5 用户裁定,与技能 / MCP 同口径):
166
+ * 文件缺失(含旧数据)时先把「全部启用」物化成显式全集**并排除新名**——
167
+ * 否则新名会随"文件缺失=全启用"的兼容语义被误判为启用。已有显式集合时新名
168
+ * 不在集合里,天然停用,无需写盘。 */
169
+ async function materializeEnabledExcluding(names) {
166
170
  const set = await readEnabled();
167
- if (set === null)
171
+ if (set !== null)
168
172
  return;
169
- let dirty = false;
170
- for (const n of names)
171
- if (set.indexOf(n) < 0) {
172
- set.push(n);
173
- dirty = true;
174
- }
175
- if (dirty)
176
- await writeEnabled(set);
173
+ const docs = await list();
174
+ await writeEnabled(docs.map((d) => d.name).filter((n) => names.indexOf(n) < 0));
175
+ cache = null;
177
176
  }
178
177
  /** 改名跟随:旧名在集合里就改新名;不在(停用中)保持停用。 */
179
178
  async function renamePersonaInEnabled(from, to) {
@@ -354,8 +353,8 @@ export function createSubagentService(ctx, opts) {
354
353
  return { ok: false, error: `创建人设目录失败: ${dir}(${message(e)})` };
355
354
  }
356
355
  await writeFile(target, serializePersona(args), 'utf8');
357
- // 新建即启用(文件缺失 = 本来全启用,无需写);缓存失效让下次 list 带上新成员。
358
- await enablePersonaNames([name]).catch(() => undefined);
356
+ // v0.8.5:新建默认不启动——显式集合下新名天然停用;文件缺失时先物化全集并排除新名。
357
+ await materializeEnabledExcluding([name]).catch(() => undefined);
359
358
  cache = null;
360
359
  return { ok: true, name };
361
360
  },
@@ -412,17 +411,17 @@ export function createSubagentService(ctx, opts) {
412
411
  const exists = await readFile(target, 'utf8').then(() => true).catch(() => false);
413
412
  if (!exists)
414
413
  return { ok: false, error: `人设不存在: ${name}` };
415
- const moved = await moveToTrash('agents', name, [{ from: target, dest: 'persona.md' }]);
414
+ const moved = await moveToTrash('subagents', name, [{ from: target, dest: 'persona.md' }]);
416
415
  if (moved.ok === false)
417
416
  return { ok: false, error: `移入回收站失败: ${moved.error}` };
418
417
  await removePersonaFromEnabled(name).catch(() => undefined);
419
418
  cache = null;
420
419
  return { ok: true, name, trashId: moved.id };
421
420
  },
422
- 'subagent-trash-list': async () => ({ ok: true, trash: await listTrashEntries('agents') }),
421
+ 'subagent-trash-list': async () => ({ ok: true, trash: await listTrashEntries('subagents') }),
423
422
  'subagent-trash-restore': async (args) => {
424
423
  const id = String((args && args.id) || '').trim();
425
- const entry = await readTrashEntry('agents', id);
424
+ const entry = await readTrashEntry('subagents', id);
426
425
  if (!entry)
427
426
  return { ok: false, error: `回收站条目不存在: ${id}` };
428
427
  if (!validPersonaName(entry.name))
@@ -434,20 +433,20 @@ export function createSubagentService(ctx, opts) {
434
433
  return { ok: false, error: `无法恢复,同名人设已存在: ${entry.name}` };
435
434
  try {
436
435
  await mkdir(dir, { recursive: true });
437
- await moveOutOfTrash('agents', id, 'persona.md', target);
436
+ await moveOutOfTrash('subagents', id, 'persona.md', target);
438
437
  }
439
438
  catch (e) {
440
439
  return { ok: false, error: `恢复失败: ${message(e)}` };
441
440
  }
442
- await purgeTrashEntry('agents', id);
443
- // 恢复 = 拿回来用:自动启用(文件缺失本来就全启用,无需写)。
444
- await enablePersonaNames([entry.name]).catch(() => undefined);
441
+ await purgeTrashEntry('subagents', id);
442
+ // v0.8.5:回收站恢复默认不启动(与新建/导入同口径)。
443
+ await materializeEnabledExcluding([entry.name]).catch(() => undefined);
445
444
  cache = null;
446
445
  return { ok: true, name: entry.name };
447
446
  },
448
447
  'subagent-trash-delete': async (args) => {
449
448
  const id = String((args && args.id) || '').trim();
450
- const gone = await purgeTrashEntry('agents', id);
449
+ const gone = await purgeTrashEntry('subagents', id);
451
450
  if (!gone)
452
451
  return { ok: false, error: `回收站条目不存在: ${id}` };
453
452
  return { ok: true, id };
@@ -492,13 +491,13 @@ export function createSubagentService(ctx, opts) {
492
491
  imported.push(target.name);
493
492
  }
494
493
  if (imported.length) {
495
- await enablePersonaNames(imported).catch(() => undefined);
494
+ await materializeEnabledExcluding(imported).catch(() => undefined);
496
495
  cache = null;
497
496
  }
498
497
  return { ok: true, imported, skipped };
499
498
  },
500
499
  /**
501
- * 子智能体开关(v0.8):停用 = 不注入目录段、subagent_list/run 不可见;文件本体不动。
500
+ * 子智能体开关(v0.8):停用 = 不注入目录段、subagent_manager_list/run 不可见;文件本体不动。
502
501
  * `enabled` 必须显式给布尔值 —— 与 rules-toggle 同一条口径:不按"翻转"推断,
503
502
  * 免得参数丢了时界面以为改了、实际什么都没动。
504
503
  */
@@ -535,6 +534,18 @@ export function createSubagentService(ctx, opts) {
535
534
  const state = new Map(docs.map((d) => [d.name, d.enabled !== false]));
536
535
  return names.filter((n) => state.get(n) === false);
537
536
  },
537
+ /** 指定名单里当前**开着**的(进入模式把未勾的关掉时,只记将被关闭的行)。 */
538
+ async enabledAmong(names) {
539
+ const docs = await list();
540
+ const state = new Map(docs.map((d) => [d.name, d.enabled !== false]));
541
+ return names.filter((n) => state.get(n) === true);
542
+ },
543
+ /** 当前开着的人设全名单(档案页把页面的「开关」落成场景绑定时用)。 */
544
+ async enabledNames() {
545
+ const docs = await list();
546
+ const state = new Map(docs.map((d) => [d.name, d.enabled !== false]));
547
+ return docs.map((d) => d.name).filter((n) => state.get(n) === true);
548
+ },
538
549
  /** 批量启停;只碰给出的名字,人设已不存在的跳过(别把悬空名写进集合)。 */
539
550
  async setEnabled(names, enabled) {
540
551
  const docs = await list();