dsh-plugin-tool-management 0.12.0 → 0.13.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.
@@ -41,7 +41,7 @@ function isEnabled(row) {
41
41
  * 返回 `''` 表示没有可注入的技能 —— 没装技能的用户零 token 成本。
42
42
  * 名字按字典序排序:底层目录枚举顺序变化时文本仍逐字节稳定(前缀缓存契约)。
43
43
  */
44
- export function renderSkillCatalog(data, maxEntries = SKILL_CATALOG_MAX_ENTRIES, maxDescription = SKILL_CATALOG_DESCRIPTION_MAX_LENGTH) {
44
+ export function renderSkillCatalog(data, maxEntries = SKILL_CATALOG_MAX_ENTRIES, maxDescription = SKILL_CATALOG_DESCRIPTION_MAX_LENGTH, listToolVisible = true) {
45
45
  const roots = (data && typeof data === 'object' ? data.roots : undefined) ?? [];
46
46
  const byName = new Map();
47
47
  if (Array.isArray(roots)) {
@@ -77,8 +77,13 @@ export function renderSkillCatalog(data, maxEntries = SKILL_CATALOG_MAX_ENTRIES,
77
77
  // (2026-09-18 起,见 context-inject.ts 的 DOMAIN_FRAME 的 `how` 行)—— 一处内容一个出处。
78
78
  const out = [...lines];
79
79
  const hidden = sorted.length - shown.length;
80
- if (hidden > 0)
81
- out.push('', `(另有 ${hidden} 个未列出,用 \`skill_manager_list\` 查。)`);
80
+ // 查询工具被关掉(兼容页的模型工具表)时只说"还有几个":点名一个模型手里没有的工具,
81
+ // 它只会去猜名字。数量照报 —— 那是实话,与有没有出口无关。
82
+ if (hidden > 0) {
83
+ out.push('', listToolVisible
84
+ ? `(另有 ${hidden} 个未列出,用 \`skill_manager_list\` 查。)`
85
+ : `(另有 ${hidden} 个未列出。)`);
86
+ }
82
87
  return out.join('\n');
83
88
  }
84
89
  /**
@@ -95,7 +100,7 @@ export function createSkillCatalog(deps, opts = {}) {
95
100
  try {
96
101
  const result = await deps.state();
97
102
  if (result && result.ok !== false) {
98
- value = renderSkillCatalog(result.data ?? result, opts.maxEntries, opts.maxDescription);
103
+ value = renderSkillCatalog(result.data ?? result, opts.maxEntries, opts.maxDescription, deps.listToolVisible ? deps.listToolVisible() : true);
99
104
  }
100
105
  }
101
106
  catch { /* 保留上一次的值;首次失败则维持 '' */ }
@@ -2840,6 +2840,15 @@ export async function getProviderSkill(candidate, options = {}) {
2840
2840
  typeof options.cwd === "string") {
2841
2841
  root = (await projectRoots([options.cwd])).find((item) => item.key === locator.rootKey);
2842
2842
  }
2843
+ // 自定义技能目录不住在 `userRoots()` 里,它住在状态文件的 `customRoots` 里,所以
2844
+ // `rootByKey()` 恒取不到。少了这一支就是「列得出、调不动」:`state()` 扫的是
2845
+ // `userRoots() + customRootsFromState()`,官方 catalog 因此把目录里的技能列给模型,
2846
+ // 而模型真去 `skill` 工具取正文时这里返回 undefined —— 症状是
2847
+ // `skill "brainstorming" is unknown or no longer available`(用户 2026-09-22 报的)。
2848
+ if (!root &&
2849
+ CUSTOM_ROOT_KEY_RE.test(locator.rootKey)) {
2850
+ root = customRootsFromState((await readManagerState()).state).find((item) => item.key === locator.rootKey);
2851
+ }
2843
2852
  if (!root)
2844
2853
  return undefined;
2845
2854
  try {
@@ -11,7 +11,7 @@ import { filterBySceneBinding } from './tools.js';
11
11
  */
12
12
  export const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500;
13
13
  /** 段里最多列几个人设;超出部分只报数量,让模型自己去调 subagent_manager_list。 */
14
- export const DEFAULT_CATALOG_MAX_ENTRIES = 40;
14
+ export const DEFAULT_CATALOG_MAX_ENTRIES = 50;
15
15
  /** 无描述时的占位,与 `subagent_manager_list` 工具的输出保持同一口径。 */
16
16
  const NO_DESCRIPTION = '(无描述)';
17
17
  /**
@@ -43,7 +43,7 @@ export function catalogDescription(value, maxLength) {
43
43
  * (`- **名字** — 描述`),"这是什么 + 该拿它做什么"整句交给注入通道的引导语。于是这里
44
44
  * 既没有标题也没有"可委派给下列子智能体"那句:一处内容只有一个出处。
45
45
  */
46
- export function renderSubagentCatalog(allowed, maxEntries = DEFAULT_CATALOG_MAX_ENTRIES, maxDescription = DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH) {
46
+ export function renderSubagentCatalog(allowed, maxEntries = DEFAULT_CATALOG_MAX_ENTRIES, maxDescription = DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH, listToolVisible = true) {
47
47
  if (!allowed.length)
48
48
  return '';
49
49
  const sorted = [...allowed].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
@@ -58,8 +58,12 @@ export function renderSubagentCatalog(allowed, maxEntries = DEFAULT_CATALOG_MAX_
58
58
  // 查询工具**只在真被 40 条上限截掉时**才出现:常态下不提,省常驻字符,也免得模型为了
59
59
  // 「确认一遍」去调它(用户裁定:没列出来的就是当前不想要的)。与 MCP 状态段的
60
60
  // `(另有 N 台未列出。)` 同一句式,但这里多给一个出口——不给人设就真的找不回来了。
61
- if (hidden > 0)
62
- out.push('', `(另有 ${hidden} 个未列出,用 \`subagent_manager_list\` 查。)`);
61
+ if (hidden > 0) {
62
+ // 与技能目录同一条纪律:点名一个被关掉的工具只会让模型去猜名字。数量照报。
63
+ out.push('', listToolVisible
64
+ ? `(另有 ${hidden} 个未列出,用 \`subagent_manager_list\` 查。)`
65
+ : `(另有 ${hidden} 个未列出。)`);
66
+ }
63
67
  return out.join('\n');
64
68
  }
65
69
  /**
@@ -101,7 +105,7 @@ export function createSubagentCatalog(deps, opts = {}) {
101
105
  const cached = rendered.get(depth);
102
106
  if (cached !== undefined)
103
107
  return cached;
104
- const out = renderSubagentCatalog(allowed.filter((p) => catalogInjectedAt(p, depth)), maxEntries, maxDescription);
108
+ const out = renderSubagentCatalog(allowed.filter((p) => catalogInjectedAt(p, depth)), maxEntries, maxDescription, deps.listToolVisible ? deps.listToolVisible() : true);
105
109
  rendered.set(depth, out);
106
110
  return out;
107
111
  },
@@ -22,7 +22,7 @@ export async function filterBySceneBinding(docs, enabledSceneLists) {
22
22
  export function defineSubagentManagerListTool(subagents) {
23
23
  return {
24
24
  name: 'subagent_manager_list',
25
- description: 'List available personas (pre-configured subagent profiles) with their descriptions. The same catalog is injected into your context each turn (the「可委派的子智能体」system-reminder); call this tool before subagent_manager_run for the full, always-current list.',
25
+ description: 'List personas (pre-configured subagent profiles) with their descriptions. The「可委派的子智能体」reminder carries the same catalog; call this for the always-current full list before subagent_manager_run.',
26
26
  parameters: {},
27
27
  output: {
28
28
  schema: { type: 'string' },
@@ -59,7 +59,14 @@ export function defineSubagentManagerRunTool(subagents) {
59
59
  // 「何时不用」那一段保留(2026-09-17,用户采纳的四条里的第 6 条):参照 Claude Code 的
60
60
  // Agent 工具(`AgentTool/prompt.ts:232-240`),把"不该用"写成**带替代工具**的具体清单
61
61
  // (具体路径→Read;找定义→Grep/Glob),比笼统说"这个很贵"有用得多。
62
- description: 'Run a named persona as a subagent: it gets the persona as its own system prompt, works on `task`, and returns only its final output.\n\nTwo modes: by default the child starts fresh — it cannot see this conversation, so `task` must be self-contained. With `inherit: true` the child is seeded with this conversation\'s finished turns (the same mechanism as the host\'s `subagent_fork`), so `task` only states what is new — use it for follow-ups on work already completed. Only **finished** turns are inherited: a delegation made during the current turn inherits nothing from that turn, so a mid-turn hand-off still needs a self-contained `task`.\n\nWrite `task` as the goal plus the context it needs — do not prescribe method or output format: those belong to the persona.\n\nWhen to use: work that matches one of the personas in the「可委派的子智能体」system-reminder injected into your context (or from subagent_manager_list) — a review, a focused investigation, a piece of writing — where the detail does not belong in your own context. Work that matches a persona belongs here, not in the host\'s `subagent` / `subagent_fork`: those take no persona. Use them only when no persona fits, or when you need a background run (this tool waits for the result).\n\nWhen NOT to use: reading a specific file (use Read), finding a definition (use Grep/Glob), or touching two or three files (use Read directly).',
62
+ //
63
+ // 2026-09-23(用户裁定):与官方两个委派工具的**分界规则**从描述里删掉 —— 注入通道的
64
+ // `how` 行(context-inject.ts 的 `DOMAIN_FRAME.subagents`)已经逐字说过一遍,而两份都在
65
+ // 每轮上下文里 = 同一件事付两次 token。留注入那份(它出现在"正在选工具"的那一刻)。
66
+ // 代价如实记下:子智能体域被关掉、或走压制型预设时上下文里没有那条 how 行,模型只剩本
67
+ // 描述与 `agent` 参数说明("Persona name from subagent_manager_list")—— 够它认出这条是
68
+ // 带人设的委派通道,但"没有人设贴合时才用官方那两个"这层分界就没人说了。
69
+ description: 'Run a named persona as a subagent: it gets the persona as its own system prompt, works on `task`, and returns only its final output.\n\nModes: by default a fresh child that cannot see this conversation, so `task` must be self-contained. With `inherit: true` it also gets this conversation\'s **finished** turns (like the host\'s `subagent_fork`) — the current turn is never included, so a mid-turn hand-off still needs a self-contained `task`.\n\n`task` = the goal plus the context it needs; leave method and output format to the persona.\n\nUse it when the work matches a persona in the「可委派的子智能体」reminder (a review, an investigation, a piece of writing) and the detail should not sit in your own context. Not for reading a file (Read), finding a definition (Grep/Glob), or touching two or three files.',
63
70
  parameters: {
64
71
  agent: { type: 'string', required: true, description: 'Persona name from subagent_manager_list.' },
65
72
  task: { type: 'string', required: true, description: 'The task for the subagent: the goal plus the context it needs. Self-contained by default; with inherit: true it only needs to state what is new. Leave method and output format to the persona.' },
package/lib/tools/mcp.js CHANGED
@@ -48,7 +48,7 @@ export function buildMcpTools(deps) {
48
48
  }));
49
49
  register(defineTool({
50
50
  name: 'mcp_manager_set_enabled',
51
- description: 'Enable or disable one configured MCP server (writes the patch file; takes effect via HMR).',
51
+ description: 'Enable or disable one configured MCP server (writes the patch file; hot-reloaded). Only when the user asks or approves.',
52
52
  parameters: {
53
53
  id: { type: 'string', required: true, description: 'Entry id of the MCP server, e.g. mcp-stepfun-web-search.' },
54
54
  level: { type: 'string', required: true, description: 'project or global.' },
@@ -70,7 +70,7 @@ export function buildMcpTools(deps) {
70
70
  }));
71
71
  register(defineTool({
72
72
  name: 'mcp_manager_restart',
73
- description: 'Restart one configured MCP server (disable + re-enable; reconnect and re-sync tools).',
73
+ description: 'Restart one configured MCP server (disable + re-enable; reconnects and re-syncs tools). Only when the user asks or approves.',
74
74
  parameters: {
75
75
  id: { type: 'string', required: true, description: 'Entry id of the MCP server, e.g. mcp-stepfun-web-search.' },
76
76
  level: { type: 'string', required: true, description: 'project or global.' },
@@ -85,7 +85,7 @@ export function buildMcpTools(deps) {
85
85
  }));
86
86
  register(defineTool({
87
87
  name: 'mcp_manager_add',
88
- description: 'Add a new MCP server (streamable-http or stdio) at project or global level.',
88
+ description: 'Add a new MCP server (streamable-http or stdio) at project or global level. Only on the user\'s explicit request.',
89
89
  parameters: {
90
90
  serverName: { type: 'string', required: true, description: 'Unique server name (1-32 chars, [A-Za-z0-9_-]).' },
91
91
  transport: { type: 'string', required: true, description: 'streamable-http or stdio.' },
@@ -1,5 +1,5 @@
1
1
  // 记忆(rules)域的 model 工具(2026-09-19 从 index.ts 的注册区抽出):
2
- // memory_manager_list / memory_manager_read / memory_manager_write。
2
+ // memory_manager_list / _read / _write / _set_enabled / _update。
3
3
  //
4
4
  // 活动场景的记忆正文会自动注入上下文(无需调用工具读取);这里的工具用于查询/编辑规则
5
5
  // 本身。memory_manager_write 受 tools/pre-execute 审批门禁(D2)。
@@ -9,7 +9,7 @@ export function buildMemoryTools(deps) {
9
9
  const { defineTool, register } = deps;
10
10
  register(defineTool({
11
11
  name: 'memory_manager_list',
12
- description: 'List memories under ~/.dsh/tool-management/memories (id, scene, enabled, description). The ones actually injected are carried in your context each turn (the「本机当前的场景和记忆」system-reminder); use this tool to find ids/paths or to see entries that are off. Defaults to the memories that will actually be injected; pass all=true for every entry.',
12
+ description: 'List memories (id, scene, enabled, description). What is injected each turn is in the「本机当前的场景和记忆」reminder; use this for ids/paths and for entries that are off. Defaults to the ones that would be injected; all=true for every entry.',
13
13
  parameters: {
14
14
  group: { type: 'string', description: 'Optional scene filter.' },
15
15
  all: { type: 'boolean', description: 'Include memories that are off, in an inactive scene, or shadowed (default false).' },
@@ -52,7 +52,7 @@ export function buildMemoryTools(deps) {
52
52
  }));
53
53
  register(defineTool({
54
54
  name: 'memory_manager_read',
55
- description: 'Read the full body of one memory under ~/.dsh/tool-management/memories. Call it only for memories that are not already in your context (disabled, unassigned to a scene, or dropped by the injection budget).',
55
+ description: 'Read the full body of one memory. Only for ones not already in your context (disabled, scene not active, or dropped by the byte budget).',
56
56
  parameters: {
57
57
  id: { type: 'string', required: true, description: 'Memory id like <scene>/<name>.' },
58
58
  },
@@ -64,9 +64,79 @@ export function buildMemoryTools(deps) {
64
64
  return '# ' + r.rule.id + '\n\n' + (r.rule.body || '');
65
65
  },
66
66
  }));
67
+ register(defineTool({
68
+ name: 'memory_manager_set_enabled',
69
+ // 为什么单独一个工具:注入实况里「这个域注入了几次 / 模型调了几次」都看得到,但模型此前
70
+ // 看得到一份记忆却开关不了它 —— 只能回一句"请你去界面上点"。启停是它替用户调整环境时
71
+ // 最常碰的一格,而 `rules-toggle` 这个 op 早就带齐了门禁(写令牌 + 场景冻结)。
72
+ // 三个写侧工具共用一句 consent(`Only when the user asks or approves.` / `Only on the
73
+ // user's instruction.`):这些动的都是"以后每一轮都会带上"的东西,模型不该顺手做。
74
+ // 描述同时按"省 token"重写了一遍 —— 这 20 段文字每次请求都要发出去,废话是常驻成本。
75
+ description: 'Enable or disable one memory (index switch; the .md file is untouched). Disabled memories stop being injected, freeing the byte budget. Prefer this over deleting — it is reversible. Only when the user asks or approves.',
76
+ parameters: {
77
+ id: { type: 'string', required: true, description: 'Memory id like <scene>/<name> (from memory_manager_list).' },
78
+ enabled: { type: 'boolean', required: true, description: 'true = enable, false = disable; omission is refused.' },
79
+ },
80
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
81
+ async execute(args) {
82
+ const blocked = await deps.lockedSceneGuard();
83
+ if (blocked)
84
+ throw new Error(blocked);
85
+ const r = await deps.rulesOps['rules-toggle'](args);
86
+ if (!r || r.ok === false)
87
+ throw new Error((r && r.error) || '切换记忆启停失败');
88
+ const rule = r.rule || {};
89
+ // 「启用了一条不在启用场景里的记忆」是一次静默无效:单条开关拨上去了,注入集里却没有它。
90
+ // 这一句不说,模型会以为已经生效 —— 与 skill_manager_set_enabled 说清影子副本同一条理由。
91
+ let sceneNote = '';
92
+ try {
93
+ const all = await deps.rulesOps['rules-list']({});
94
+ if (all && all.ok !== false) {
95
+ const group = String(rule.group || '');
96
+ const on = group === 'global' || (group !== '' && (all.activeMode === 'all' || String(all.activeScene || '') === group));
97
+ if (!on)
98
+ sceneNote = ' — BUT its scene「' + group + '」is not the active one, so it still will not be injected; enable that scene (or move it to global) first';
99
+ }
100
+ }
101
+ catch {
102
+ sceneNote = '';
103
+ }
104
+ return 'OK: memory ' + String(rule.id || args.id) + ' ' + (rule.enabled === false ? 'disabled' : 'enabled') + sceneNote;
105
+ },
106
+ }));
107
+ register(defineTool({
108
+ name: 'memory_manager_update',
109
+ description: 'Update a memory: body, description, or name (renaming keeps the old file in the trash). Omit a field to keep it — this merges. Read it with memory_manager_read first and change one part rather than restating the file; if a memory already covers this, update that one instead of adding a second. Only on the user\'s instruction.',
110
+ parameters: {
111
+ id: { type: 'string', required: true, description: 'Memory id like <scene>/<name> (from memory_manager_list).' },
112
+ body: { type: 'string', description: 'New Markdown body (<=256 KiB, non-empty). Omit to keep the current body.' },
113
+ description: { type: 'string', description: 'New one-sentence description (<=500 chars). Empty string = drop it and re-derive from the body; omit = keep.' },
114
+ name: { type: 'string', description: 'New memory name (= file name). Omit to keep it.' },
115
+ },
116
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
117
+ async execute(args) {
118
+ const blocked = await deps.lockedSceneGuard();
119
+ if (blocked)
120
+ throw new Error(blocked);
121
+ // 三个可改字段一个都没给 → 直接拒。`rules-update` 会按"保留现有"落一次盘并刷新
122
+ // updatedAt,模型以为改了点什么,其实只是把文件重写了一遍。
123
+ const touches = ['body', 'description', 'name'].filter((k) => args[k] !== undefined);
124
+ if (!touches.length)
125
+ throw new Error('没有要改的东西:body / description / name 至少给一个');
126
+ const r = await deps.rulesOps['rules-update'](args);
127
+ if (!r || r.ok === false)
128
+ throw new Error((r && r.error) || '更新记忆失败');
129
+ const rule = r.rule || {};
130
+ return 'OK: updated ' + String(rule.id || args.id) + '(改了 ' + touches.join('、') + ')' +
131
+ (rule.id && String(rule.id) !== String(args.id) ? ' → 现在是 ' + rule.id : '');
132
+ },
133
+ }));
67
134
  register(defineTool({
68
135
  name: 'memory_manager_write',
69
- description: 'Create a new memory as ~/.dsh/tool-management/memories/<scene>/<name>.md. The scene must already exist (use global for the always-on scene).',
136
+ // 「能推导的别记」「先查重」这两句是 0.13.0 加的:记忆是唯一直接吃注入预算的域
137
+ // (场景段 128 KiB),而模型很乐意把「这个项目用 pnpm」记一条 —— 那看一眼 `package.json`
138
+ // 就知道,记下来却永久占着每一轮请求。
139
+ description: 'Create a memory (the scene must already exist; global is the always-on one). Memories cost context on EVERY later request, so record only what cannot be re-derived from code, config or git, and check the list for one that already says this — update that instead of adding a near-duplicate. Only on the user\'s instruction.',
70
140
  parameters: {
71
141
  group: { type: 'string', required: true, description: 'Scene name (no path separators or < > : " | ? *); `global` = the always-on scene.' },
72
142
  name: { type: 'string', required: true, description: 'Memory name = .md file name without extension; <=64 chars, no path separators or < > : " | ? *, must not start with a dot.' },
@@ -78,10 +148,40 @@ export function buildMemoryTools(deps) {
78
148
  const blocked = await deps.lockedSceneGuard();
79
149
  if (blocked)
80
150
  throw new Error(blocked);
151
+ // 先扫一遍已有的:疑似重复只**说出来**,不拒绝 —— 猜错一次就把该记的东西记不下去,
152
+ // 比多记一条代价大得多;而且"像不像同一条"本来就是人判断的事。
153
+ const norm = (value) => String(value ?? '').toLowerCase().replace(/\s+/g, ' ').trim();
154
+ const wantName = norm(args && args.name);
155
+ const wantDesc = norm(args && args.description);
156
+ let similar = [];
157
+ try {
158
+ const before = await deps.rulesOps['rules-list']({});
159
+ if (before && before.ok !== false) {
160
+ similar = (before.rules || [])
161
+ .filter((x) => {
162
+ const have = norm(x.name);
163
+ const desc = norm(x.description);
164
+ if (have === wantName)
165
+ return true;
166
+ if (wantDesc !== '' && desc !== '' && desc === wantDesc)
167
+ return true;
168
+ // 名字互相包含:只在两边都不短于 4 个字符时算,否则 "git" 会把所有条目都匹配上。
169
+ return have.length >= 4 && wantName.length >= 4 && (have.includes(wantName) || wantName.includes(have));
170
+ })
171
+ .map((x) => String(x.id))
172
+ .slice(0, 3);
173
+ }
174
+ }
175
+ catch { /* 查重读不到就当没查到:它不该拦住写入 */ }
81
176
  const r = await deps.rulesOps['rules-create'](args);
82
177
  if (!r || r.ok === false)
83
178
  throw new Error((r && r.error) || '创建规则失败');
84
- return 'OK: memory ' + r.rule.id + '(场景「' + (r.rule.group || '未归属') + '」启用后自动生效)';
179
+ // 刚建好的这条自己会在列表里,从名单里去掉。
180
+ similar = similar.filter((id) => id !== String(r.rule.id));
181
+ const dup = similar.length
182
+ ? `\n注意:已有 ${similar.length} 条名字或描述与它很像(${similar.join('、')})—— 若是同一件事,改那一条而不是留着两条。`
183
+ : '';
184
+ return 'OK: memory ' + r.rule.id + '(场景「' + (r.rule.group || '未归属') + '」启用后自动生效)' + dup;
85
185
  },
86
186
  }));
87
187
  }
@@ -8,7 +8,7 @@ export function buildPromptTools(deps) {
8
8
  const { defineTool, register } = deps;
9
9
  register(defineTool({
10
10
  name: 'prompt_manager_list',
11
- description: 'List AGENTS.md presets (id, active state, file path). Read that file to see a preset body. The preset in effect right now is injected into your context each turn (the「本机提示词」system-reminder, whose「来源:」line names the file). Defaults to the preset currently in effect; pass all=true for the whole library.',
11
+ description: 'List AGENTS.md presets (id, active state, file path); read that file for a preset body. The one in effect is in the「本机提示词」reminder, whose「来源:」line names the file. Defaults to the one in effect; all=true for the whole library.',
12
12
  parameters: {
13
13
  all: { type: 'boolean', description: 'Include inactive presets (default false).' },
14
14
  },
@@ -47,7 +47,7 @@ export function buildPromptTools(deps) {
47
47
  }));
48
48
  register(defineTool({
49
49
  name: 'prompt_manager_apply',
50
- description: 'Apply one AGENTS.md preset by id; effective on the next turn. If a scene currently drives the baseline, this does NOT refuse: it rebinds that scene\'s prompt binding to the preset and re-syncs the scene, so ~/.dsh/AGENTS.md ends up holding the scene\'s (new) binding — tell the user which scene was rebound. Otherwise the preset is written to ~/.dsh/AGENTS.md directly. A locked scene blocks the call.',
50
+ description: 'Apply one AGENTS.md preset by id; effective next turn. If a scene drives the baseline this does NOT refuse — it rebinds that scene to the preset and re-syncs it, so tell the user which scene was rebound. A locked scene blocks the call. Only on the user\'s instruction.',
51
51
  parameters: {
52
52
  id: { type: 'string', required: true, description: 'Preset id (lowercase letters, digits, hyphens).' },
53
53
  },
@@ -1,5 +1,5 @@
1
1
  // 技能域的 model 工具(2026-09-19 从 index.ts 的注册区抽出):
2
- // skill_manager_list / skill_manager_set_enabled / skill_manager_create。
2
+ // skill_manager_list / skill_manager_read / skill_manager_set_enabled / skill_manager_create。
3
3
  import { text } from './deps.js';
4
4
  export function buildSkillTools(deps) {
5
5
  const { defineTool, register } = deps;
@@ -7,7 +7,7 @@ export function buildSkillTools(deps) {
7
7
  // tools/pre-execute hook below (the model must ask before writing files).
8
8
  register(defineTool({
9
9
  name: 'skill_manager_list',
10
- description: 'List DSH skills with enabled state, effective/shadowed status and source file path. The injected「本机技能目录」system-reminder carries callable skills and summaries only; use this tool to get a source file path (read the file for the full body) and to see entries that are off. Defaults to enabled skills only; pass all=true for every entry. A copy marked "shadowed by <root>" stays inactive even if enabled.',
10
+ description: 'List skills with enabled state, effective/shadowed status and source file path. The「本机技能目录」reminder carries callable skills and summaries only; use this for entries that are off and to see which source wins a name collision, and skill_manager_read for a body. Defaults to enabled only; all=true for every entry. A copy marked "shadowed by <root>" stays inactive even if enabled.',
11
11
  parameters: {
12
12
  all: { type: 'boolean', description: 'Include disabled and shadowed entries (default false).' },
13
13
  },
@@ -67,9 +67,87 @@ export function buildSkillTools(deps) {
67
67
  return header + (shown.map((row) => row.text).join('\n') || '(none)') + notice;
68
68
  },
69
69
  }));
70
+ // 一次拿到技能正文。此前读正文要两步:`skill_manager_list` 拿路径 → 自己再去读那个文件。
71
+ // 少的那一步不只是省一次调用 —— 目录里只有名字没有路径,而**同名技能常几份并存**(dsh /
72
+ // agents / claude / 自定义目录各一份),模型自己挑路径时很容易读到被覆盖的那份影子副本:
73
+ // 读完以为在用这个技能,其实生效的是另一份。这里先走 `skill-state`(core 的 markWinners
74
+ // 已在上面标出胜出者),再走 `skill-detail` 读那一份 —— 读到的永远是目录列出来的那一份。
75
+ register(defineTool({
76
+ name: 'skill_manager_read',
77
+ description: 'Read one skill\'s full SKILL.md body by name — what the「本机技能目录」reminder deliberately leaves out (it has names and summaries only). Resolves name collisions the same way, so you get the copy actually in effect; pass root only to inspect a specific (e.g. shadowed) source. Read-only.',
78
+ parameters: {
79
+ name: { type: 'string', required: true, description: 'Skill name, exactly as it appears in the catalog (kebab-case).' },
80
+ root: { type: 'string', description: 'Source root key (dsh/hub/agents/codex/claude, a project key, or a custom one). Default: the winning copy of this name.' },
81
+ },
82
+ output: { schema: { type: 'string' }, render: (_a, v) => text(v) },
83
+ async execute(args, exec) {
84
+ const wanted = String((args && args.name) || '').trim();
85
+ if (!wanted)
86
+ throw new Error('name is required');
87
+ const rootWanted = String((args && args.root) || '').trim();
88
+ const state = await deps.skillsOps['skill-state']({});
89
+ if (!state || state.ok === false)
90
+ throw new Error((state && state.error) || 'skill-state failed');
91
+ const data = state.data || {};
92
+ const hits = [];
93
+ for (const root of data.roots || []) {
94
+ for (const skill of root.skills || []) {
95
+ if (String(skill.declaredName || skill.name || '') !== wanted && String(skill.name || '') !== wanted)
96
+ continue;
97
+ if (rootWanted && String(root.key || '') !== rootWanted)
98
+ continue;
99
+ hits.push({
100
+ rootKey: String(root.key || ''),
101
+ // `skill-detail` 按**目录名**认条目(core 的 visibleEntryForRoot 比的是 entry.name),
102
+ // 而目录名与 frontmatter 的 name 可以不同 —— 传错一个就读不到。
103
+ entryName: String(skill.name || wanted),
104
+ path: String(skill.path || ''),
105
+ shadowedBy: skill.shadowedBy && skill.shadowedBy.root ? String(skill.shadowedBy.root) : '',
106
+ preferred: skill.preferred === true,
107
+ // 与 skill_manager_list 同一套启停推导(影子副本没有 enabled 字段)。
108
+ enabled: skill.enabled !== undefined
109
+ ? skill.enabled === true
110
+ : (skill.invocationPolicyValid && skill.modelInvocable && skill.userInvocable && skill.managerEnabled !== false),
111
+ });
112
+ }
113
+ }
114
+ if (!hits.length) {
115
+ throw new Error(rootWanted
116
+ ? `no skill named "${wanted}" in source "${rootWanted}" — call skill_manager_list (pass all=true) to see what is installed`
117
+ : `no skill named "${wanted}" — call skill_manager_list (pass all=true) to see what is installed`);
118
+ }
119
+ // 胜出者优先:同名的影子副本读得到,但那不是生效的一份,所以默认不选它。
120
+ const picked = hits.find((hit) => !hit.shadowedBy && hit.enabled)
121
+ || hits.find((hit) => !hit.shadowedBy)
122
+ || hits[0];
123
+ const res = await deps.skillsOps['skill-detail']({ root: picked.rootKey, name: picked.entryName });
124
+ if (!res || res.ok === false)
125
+ throw new Error((res && res.error) || 'skill-detail failed');
126
+ const detail = res.data || res;
127
+ const marks = [];
128
+ if (picked.shadowedBy)
129
+ marks.push('shadowed by "' + picked.shadowedBy + '" — the copy actually in effect is a different one');
130
+ if (picked.preferred)
131
+ marks.push('preferred copy');
132
+ if (!picked.enabled)
133
+ marks.push('disabled');
134
+ const dup = hits.filter((hit) => !hit.shadowedBy).length > 1
135
+ ? hits.filter((hit) => !hit.shadowedBy).map((hit) => hit.rootKey).filter((k) => k !== picked.rootKey)
136
+ : [];
137
+ const header = 'Skill ' + wanted + ' · source ' + picked.rootKey + (marks.length ? ' (' + marks.join('; ') + ')' : '') + '\n' +
138
+ 'Path: ' + (detail.path || picked.path || '(unknown)') + '\n' +
139
+ 'Description: ' + String(detail.description || '(none)').replace(/\s+/g, ' ').trim() + '\n' +
140
+ (dup.length ? 'Other callable copies with the same name: ' + dup.join(', ') + ' (this one wins; read them only to compare)\n' : '') +
141
+ '--- SKILL.md body ---\n';
142
+ // 注入边界:正文读进来就永久留在这一轮的 transcript 里,压制型预设下目录也可能不在 ——
143
+ // 与 skill_manager_list 同一句说明,别让模型以为"读过就等于技能开着"。
144
+ const notice = await deps.reachNoticeForAgent(deps.presetRoster(), exec && exec.agent && exec.agent.ctx, deps.injectNoticeOptions());
145
+ return header + String(detail.body || '').trim() + notice;
146
+ },
147
+ }));
70
148
  register(defineTool({
71
149
  name: 'skill_manager_set_enabled',
72
- description: 'Enable or disable one DSH skill (manager policy only; source files are never modified). Enabling a shadowed copy has no effect.',
150
+ description: 'Enable or disable one DSH skill (manager policy only; source files are never modified). Enabling a shadowed copy has no effect. Only when the user asks or approves.',
73
151
  parameters: {
74
152
  name: { type: 'string', required: true, description: 'Skill name (kebab-case).' },
75
153
  enabled: { type: 'boolean', required: true, description: 'true to enable, false to disable.' },
@@ -1,11 +1,12 @@
1
1
  // 子智能体域的 model 工具(2026-09-19 从 index.ts 的注册区抽出):
2
- // subagent_manager_list / subagent_manager_run。
2
+ // subagent_manager_list / subagent_manager_run / _set_enabled / _create / _update。
3
3
  //
4
4
  // exec.agent / exec.signal 由工具运行时提供(parent 与取消信号的官方通道)。
5
5
  //
6
6
  // 两个工具**各自** try/catch:一个注册失败不该把另一个也带走,而且失败必须说得出
7
7
  // 「是哪一个没注册上」——只打一行日志时,模型侧只会「查无此工具」、界面毫无痕迹。
8
8
  import { defineSubagentManagerListTool, defineSubagentManagerRunTool } from '../subagents/tools.js';
9
+ import { text } from './deps.js';
9
10
  export function buildSubagentTools(deps) {
10
11
  const { defineTool, register } = deps;
11
12
  const recordFailure = (name, e) => {
@@ -16,7 +17,7 @@ export function buildSubagentTools(deps) {
16
17
  console.error('[dsh-plugin-tool-management] subagent tool registration failed:', name, deps.message(e));
17
18
  };
18
19
  try {
19
- // 与其余 12 个工具同一条注册通道:defineTool 负责编译 parameters(object root + required),
20
+ // 与其余 19 个工具同一条注册通道:defineTool 负责编译 parameters(object root + required),
20
21
  // 裸 register 会把未编译的参数声明直接发给模型 API。
21
22
  register(defineTool(defineSubagentManagerListTool({
22
23
  list: () => deps.subagentService.list(),
@@ -37,4 +38,118 @@ export function buildSubagentTools(deps) {
37
38
  catch (e) {
38
39
  recordFailure('subagent_manager_run', e);
39
40
  }
41
+ // ── 管理侧的三个(启停 / 建 / 改)────────────────────────────────────────────
42
+ // 与上面两个不同,这三个**不依赖** provider:动的只是 hub 里的人设文件与启停名单,
43
+ // 宿主没挂 `dsh-subagent-*` 也照样该能用(恰恰那时更需要:能把人设建好、开关拨对,
44
+ // 等 provider 回来就可用)。所以各自单独 try/catch,不与 `_run` 绑在一起失败。
45
+ //
46
+ // 参数表刻意只给 name / description / body / output 四个:工具限制(tools / toolsDeny /
47
+ // toolsByPreset)与 provider/model 留给面板。理由不是"怕模型不会填",而是填错的后果
48
+ // 不对称 —— 一个错误的 toolsDeny 会静默改变子代理能做什么,而模型看不到自己改对了没有。
49
+ const ops = () => deps.subagentService.ops;
50
+ try {
51
+ register(deps.defineTool({
52
+ name: 'subagent_manager_set_enabled',
53
+ description: 'Enable or disable one persona. Only enabled personas appear in the「可委派的子智能体」catalog and can be given work by subagent_manager_run. Reversible, and the file is untouched. Only when the user asks or approves.',
54
+ parameters: {
55
+ name: { type: 'string', required: true, description: 'Persona name.' },
56
+ enabled: { type: 'boolean', required: true, description: 'true = enable, false = disable; omission is refused.' },
57
+ },
58
+ output: { schema: { type: 'string' }, render: (_a, v) => text(String(v)) },
59
+ async execute(args) {
60
+ const blocked = await deps.lockedSceneGuard();
61
+ if (blocked)
62
+ throw new Error(blocked);
63
+ const name = String((args && args.name) || '').trim();
64
+ const r = await ops()['subagent-toggle']({ name, enabled: args.enabled === true });
65
+ if (!r || r.ok === false)
66
+ throw new Error((r && r.error) || '切换人设启停失败');
67
+ // 场景内改开关要同步进当前场景档案(与 HTTP 层 syncsArchive 同一口径 —— 模型工具
68
+ // 直接调 service op,绕过了那一层包装,所以这里自己叫一声)。
69
+ const syncErr = await deps.syncSwitchToScene('subagent-toggle', { name, enabled: args.enabled === true });
70
+ return 'OK: persona ' + name + ' ' + (args.enabled === true ? 'enabled' : 'disabled') +
71
+ (syncErr ? '\nWARN: 当前场景档案未同步(' + syncErr + ')' : '');
72
+ },
73
+ }));
74
+ }
75
+ catch (e) {
76
+ recordFailure('subagent_manager_set_enabled', e);
77
+ }
78
+ try {
79
+ register(deps.defineTool({
80
+ name: 'subagent_manager_create',
81
+ description: 'Create a persona at ~/.dsh/tool-management/subagents/<name>.md. Only on the user\'s explicit request. Write `body` as who this is and how it judges its own work — not the steps or paths, those belong to the task; put hard output requirements in `output` (one per line), not buried in prose. New personas start disabled: enable with subagent_manager_set_enabled before delegating.',
82
+ parameters: {
83
+ name: { type: 'string', required: true, description: 'Persona name (= file name); ≤64 chars, no path separators or < > : " | ? *, must not start with a dot.' },
84
+ description: { type: 'string', required: true, description: 'One-line routing description: what it is for and when to pick it. This is what shows up in the delegation catalog.' },
85
+ body: { type: 'string', required: true, description: 'Markdown role definition.' },
86
+ output: { type: 'string', description: 'Output contract, one requirement per line (rendered to the persona as its own 「输出要求」 section). Omit for no hard requirements.' },
87
+ },
88
+ output: { schema: { type: 'string' }, render: (_a, v) => text(String(v)) },
89
+ async execute(args) {
90
+ const blocked = await deps.lockedSceneGuard();
91
+ if (blocked)
92
+ throw new Error(blocked);
93
+ const r = await ops()['subagent-create'](args);
94
+ if (!r || r.ok === false)
95
+ throw new Error((r && r.error) || '创建人设失败');
96
+ return 'OK: persona ' + String(r.name || args.name) + ' created(默认未启用,要委派它先 subagent_manager_set_enabled)';
97
+ },
98
+ }));
99
+ }
100
+ catch (e) {
101
+ recordFailure('subagent_manager_create', e);
102
+ }
103
+ try {
104
+ register(deps.defineTool({
105
+ name: 'subagent_manager_update',
106
+ // 为什么不是直通 `subagent-update`:那个 op 走 serializePersona **整份重写**文件,
107
+ // 缺席的字段一律按空处理。界面每次都带全量表单所以没事,模型只改一句 description
108
+ // 却直通过去,就会把人设正文与工具限制一起冲掉。这里先读现状再合并 —— 合并发生在
109
+ // 工具这一侧,op 的语义不动。
110
+ description: 'Update a persona: body, description, output contract, or name (renaming re-points scene-profile bindings). Omit a field to keep it — this merges, so you can change one part without restating the role. Only on the user\'s instruction.',
111
+ parameters: {
112
+ name: { type: 'string', required: true, description: 'Current persona name.' },
113
+ nextName: { type: 'string', description: 'New name (rename). Omit to keep it. Refused if a persona already has that name — nothing is overwritten.' },
114
+ description: { type: 'string', description: 'New one-line routing description. Omit to keep it.' },
115
+ body: { type: 'string', description: 'New Markdown role definition. Omit to keep it.' },
116
+ output: { type: 'string', description: 'New output contract, one requirement per line. Empty string clears it. Omit to keep it.' },
117
+ },
118
+ output: { schema: { type: 'string' }, render: (_a, v) => text(String(v)) },
119
+ async execute(args) {
120
+ const blocked = await deps.lockedSceneGuard();
121
+ if (blocked)
122
+ throw new Error(blocked);
123
+ const name = String((args && args.name) || '').trim();
124
+ const current = await ops()['subagent-get']({ name });
125
+ if (!current || current.ok === false)
126
+ throw new Error((current && current.error) || '人设不存在: ' + name);
127
+ const keep = current.persona || {};
128
+ const touched = ['nextName', 'description', 'body', 'output'].filter((k) => args && args[k] !== undefined);
129
+ if (!touched.length)
130
+ throw new Error('没有要改的东西:nextName / description / body / output 至少给一个');
131
+ const merged = {
132
+ name,
133
+ nextName: args.nextName,
134
+ description: args.description !== undefined ? String(args.description) : String(keep.description ?? ''),
135
+ body: args.body !== undefined ? String(args.body) : String(keep.body ?? ''),
136
+ output: args.output !== undefined ? String(args.output) : (Array.isArray(keep.output) ? keep.output.join('\n') : String(keep.output ?? '')),
137
+ provider: keep.provider ?? '',
138
+ model: keep.model ?? '',
139
+ tools: keep.tools ?? [],
140
+ toolsDeny: keep.toolsDeny ?? [],
141
+ toolsByPreset: keep.toolsByPreset ?? {},
142
+ catalogDepth: keep.catalogDepth,
143
+ };
144
+ const r = await ops()['subagent-update'](merged);
145
+ if (!r || r.ok === false)
146
+ throw new Error((r && r.error) || '更新人设失败');
147
+ return 'OK: persona ' + String(r.name || name) + ' updated(改了 ' + touched.join('、') + ')' +
148
+ (r.renamedFrom ? ' — 原名「' + r.renamedFrom + '」,场景档案里的绑定已一起改名' : '');
149
+ },
150
+ }));
151
+ }
152
+ catch (e) {
153
+ recordFailure('subagent_manager_update', e);
154
+ }
40
155
  }