dsh-plugin-tool-management 0.9.1 → 0.11.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 (79) hide show
  1. package/CHANGELOG.md +119 -1
  2. package/README.md +227 -201
  3. package/README_EN.md +227 -199
  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 +144 -12
  21. package/lib/client.js +8437 -5929
  22. package/lib/compat/preset-reach.js +1 -10
  23. package/lib/compat/probe.js +158 -25
  24. package/lib/context-inject.js +396 -53
  25. package/lib/host-names.js +12 -0
  26. package/lib/http-fence.js +35 -15
  27. package/lib/hub.js +31 -3
  28. package/lib/imports/parsers.js +15 -9
  29. package/lib/imports/upload.js +43 -4
  30. package/lib/index.js +940 -3765
  31. package/lib/mcp/loader-token.js +238 -0
  32. package/lib/mcp/manager.js +1681 -0
  33. package/lib/mcp/override-blocks.js +20 -9
  34. package/lib/mcp/patch-yaml.js +351 -0
  35. package/lib/mcp/secret-guard.js +145 -0
  36. package/lib/mcp/state-section.js +64 -21
  37. package/lib/{rules → memories}/archive-engine.js +65 -10
  38. package/lib/{rules → memories}/archive.js +6 -7
  39. package/lib/memories/constants.js +128 -0
  40. package/lib/memories/index-io.js +330 -0
  41. package/lib/memories/projection.js +280 -0
  42. package/lib/memories/service.js +686 -0
  43. package/lib/memories/snapshot.js +672 -0
  44. package/lib/ops/candidates.js +64 -0
  45. package/lib/ops/compat.js +136 -0
  46. package/lib/ops/ctx.js +9 -0
  47. package/lib/ops/memory.js +678 -0
  48. package/lib/ops/prompts.js +107 -0
  49. package/lib/ops/scene-records.js +460 -0
  50. package/lib/ops/scene-sync.js +17 -0
  51. package/lib/ops/sessions.js +603 -0
  52. package/lib/ops/trash.js +140 -0
  53. package/lib/paths.js +103 -0
  54. package/lib/prompts/preset-id.js +49 -0
  55. package/lib/{agents-md → prompts}/service.js +1 -1
  56. package/lib/request-gate.js +320 -0
  57. package/lib/scene-prompt-sync.js +4 -4
  58. package/lib/scenes/candidates.js +344 -0
  59. package/lib/{history → sessions}/bridge.js +22 -5
  60. package/lib/sessions/history.js +323 -0
  61. package/lib/{history → sessions}/tombstone.js +1 -2
  62. package/lib/{history → sessions}/workspace.js +92 -24
  63. package/lib/skills/catalog.js +6 -9
  64. package/lib/skills/core.js +79 -43
  65. package/lib/skills/readonly-discovery.js +4 -1
  66. package/lib/skills/service.js +98 -13
  67. package/lib/subagents/catalog.js +31 -15
  68. package/lib/subagents/service.js +506 -89
  69. package/lib/subagents/tools.js +29 -4
  70. package/lib/tools/deps.js +8 -0
  71. package/lib/tools/mcp.js +110 -0
  72. package/lib/tools/memory.js +87 -0
  73. package/lib/tools/prompt.js +70 -0
  74. package/lib/tools/skills.js +139 -0
  75. package/lib/tools/subagent.js +40 -0
  76. package/package.json +105 -102
  77. package/lib/agents-md/preset-id.js +0 -49
  78. package/lib/history/projcache.js +0 -335
  79. package/lib/rules/service.js +0 -2971
@@ -1,12 +1,13 @@
1
1
  // src/subagents/service.ts —— 轻量子智能体:人设发现(TTL 扫描)+ 官方 ctx.subagents.start 薄封装。
2
2
  // 设计 §3:人设 = $DSH_HOME/tool-management/agents/<name>.md(frontmatter 可选,缺省派生);v1 串行运行;
3
- // spawn provider 缺失时经 createRequire 挂载官方 dsh-subagent-spawn-in-process(宿主侧包)。
3
+ // provider 缺失时经 createRequire 挂载宿主侧包:`spawn`(新会话,默认)与 `fork`(继承本次会话)。
4
4
  //
5
5
  // v0.4 目录变更:人设由 `$DSH_HOME/subagents/` 搬到 `$DSH_HOME/tool-management/agents/`
6
6
  // (插件产生的文件统一收在 tool-management/ 下)。旧目录在首次扫描时搬入,见 relocateLegacyPersonas。
7
7
  import { createRequire } from 'node:module';
8
- import { mkdir, readdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
8
+ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
9
9
  import { join } from 'node:path';
10
+ import { isValidSegment } from '../paths.js';
10
11
  import { resolveDshHome } from '../skills/core.js';
11
12
  import { listTrashEntries, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
12
13
  import { expandUploads, planPersonaImport } from '../imports/upload.js';
@@ -97,17 +98,27 @@ export function parsePresetToolRules(frontmatter) {
97
98
  }
98
99
  return Object.keys(rules).length ? rules : undefined;
99
100
  }
100
- /** 行式 frontmatter 解析:只认 description / provider / model / tools / toolsDeny / toolsByPreset。 */
101
+ /** 行式 frontmatter 解析:只认 description / provider / model / tools / toolsDeny / toolsByPreset / catalogDepth / output。 */
101
102
  export function parsePersona(raw, fallbackName) {
102
103
  const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw);
103
104
  const data = {};
105
+ const outputLines = [];
104
106
  let body = raw;
105
107
  let toolsByPreset;
106
108
  if (m) {
107
109
  for (const line of m[1].split(/\r?\n/)) {
108
110
  const kv = /^([A-Za-z_-]+)\s*:\s*(.*)$/.exec(line.trim());
109
- if (kv)
110
- data[kv[1].toLowerCase()] = kv[2].trim();
111
+ if (!kv)
112
+ continue;
113
+ const key = kv[1].toLowerCase();
114
+ // `output:` 是**可重复**的键(一条要求一行,按顺序收集)。frontmatter 是逐行解析的,
115
+ // 多行契约塞不进一个值里;写成重复键比引入块标量语法简单,也一眼看得懂。
116
+ if (key === 'output') {
117
+ if (kv[2].trim() !== '')
118
+ outputLines.push(kv[2].trim());
119
+ continue;
120
+ }
121
+ data[key] = kv[2].trim();
111
122
  }
112
123
  toolsByPreset = parsePresetToolRules(m[1]);
113
124
  body = raw.slice(m[0].length);
@@ -116,6 +127,7 @@ export function parsePersona(raw, fallbackName) {
116
127
  // 兼容两种写法:`toolsdeny: a, b`(线上格式)与 `toolsDeny: a, b`(键名统一小写后同形)。
117
128
  const toolsDeny = listOf(data.toolsdeny);
118
129
  const firstLine = body.split(/\r?\n/).find((l) => l.trim())?.trim() ?? '';
130
+ const catalogDepth = parseCatalogDepth(data);
119
131
  return {
120
132
  name: fallbackName,
121
133
  description: data.description || firstLine,
@@ -124,43 +136,307 @@ export function parsePersona(raw, fallbackName) {
124
136
  tools,
125
137
  toolsDeny,
126
138
  ...(toolsByPreset === undefined ? {} : { toolsByPreset }),
139
+ ...(catalogDepth === undefined ? {} : { catalogDepth }),
140
+ ...(outputLines.length ? { output: outputLines.join('\n') } : {}),
127
141
  body: body.trim(),
128
142
  path: '',
129
143
  };
130
144
  }
145
+ /** 零宽空格:用来拆开 `{{`,视觉上不留痕。 */
146
+ const ZERO_WIDTH = '\u200b';
147
+ /**
148
+ * 拆开正文里的 `{{`,让它不再被宿主当作提示词变量引用。
149
+ *
150
+ * 宿主对 section 文本做**严格**变量插值(dsh-system-prompt 的 `interpolate`):
151
+ * 命中 `{{name}}` 而该变量没注册就抛错,整个子代理启动失败。人设是用户自由文本,
152
+ * 作者写 `{{foo}}` 几乎一定是字面量,却会让委派直接崩掉。这里在两个花括号之间插一个
153
+ * 零宽空格:模型看到的仍是 `{{foo}}`,宿主再也找不到 `{{`。**只拆开,不删除**——
154
+ * 用户写下的每一个可见字符都保留。
155
+ */
156
+ export function neutralizePromptVariables(text) {
157
+ // 在"后面还是左花括号"的 `{` 之后插入零宽空格,把 `{{` 拆成 `{<ZWSP>{`。
158
+ // 用前瞻断言而不是 `replace(/\{\{/g, …)`:后者的替换串**尾字符也是 `{`**,
159
+ // 遇到 `{{{` 会与被替换掉的首括号后面的原字符重新拼出 `{{`(实测踩到)。
160
+ // 前瞻写法一次扫描就够,连续多少个左括号都处理干净。
161
+ return text.replace(/\{(?=\{)/g, `{${ZERO_WIDTH}`);
162
+ }
163
+ /**
164
+ * 这个人设主要用中文写的?启发式,判错的代价只是框的语言不搭。
165
+ *
166
+ * 判定范围是**人设整体**(名字 + 描述 + 正文),不只是正文:正文可能是占位符、
167
+ * 代码片段或纯英文标识(实测有人的正文是 `123`),只看正文会把一个中文人设
168
+ * 判成英文、配上英文框。名字与描述是作者写的,同样能说明语言。
169
+ */
170
+ function isChinesePersona(name, description, body) {
171
+ const sample = `${name}\n${description}\n${body}`;
172
+ const cjk = (sample.match(/[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/g) || []).length;
173
+ if (cjk === 0)
174
+ return false;
175
+ const latin = (sample.match(/[A-Za-z]/g) || []).length;
176
+ // 汉字信息密度高于字母,1 个汉字按 2 个字母折算,避免"中文正文夹少量英文术语"被判成英文。
177
+ return cjk * 2 >= latin;
178
+ }
179
+ /**
180
+ * 人设 → 子代理系统提示词里那一段的**成品文本**(2026-09-17)。
181
+ *
182
+ * 为什么需要这层框:宿主给子代理的 persona 只占一个槽位 —— `deployment:persona-prefix`,
183
+ * section order 0,位置紧贴 `harness:identity`(order -1000,那句 "You are an AI agent
184
+ * powered by DeepSeek Harness.")。而宿主自己的 `personaPrefix` 配置是**部署级短前缀**,
185
+ * 它默认由部署方承担框架。我们把整篇人设正文直接填进这个槽,等于交出一段无标题、无归属、
186
+ * 无授权的裸文本:模型读到的是"身份句后面又跟了两句话",而不是"我被指定为一个角色"。
187
+ * 用户写的 `必须…` 因此只是背景信息,没有上位效力。
188
+ *
189
+ * 两份参考实现都靠**框**解决同一件事:Claude Code 把代理提示词放在系统提示词**首位**,
190
+ * 追加内容一律带 `#`/`##` 标题与指令段(`AgentTool/agentMemory.ts`、`memdir/memdir.ts`);
191
+ * Codex 在指令文件交界处插入来源标记 `--- project-doc ---`,让模型知道权威边界从哪开始
192
+ * (`core/src/agents_md.rs`)。两者都没有"把用户正文裸拼进系统提示词"这种做法。
193
+ *
194
+ * 框里四样东西各有分工,缺一样就退化成原来的裸文本:
195
+ * - `# 角色:名` —— 结构信号(markdown 标题是模型最强的分段线索)+ 可引用的名字;
196
+ * - 归属 + 授权一行 —— "由调用方指定、与默认倾向冲突时以它为准",给正文上位效力;
197
+ * 2026-09-17 补了半句"任务说要什么,怎么做以它为准":不写这句,模型会把 `task`
198
+ * (父代理按自己的框架写的具体指令)读成人设的上级,人设只剩风格提示的分量;
199
+ * - 边界一句 —— 角色决定**怎么做**,不改变**能做什么**。这不是装饰:官方
200
+ * `subagent:delegation` 运行时上下文已经声明"权限在启动时固定",两处不呼应的话,
201
+ * 写着"你可以随意写文件"的人设会读起来像与工具限制矛盾;
202
+ * - `## 角色定义` 标题 + 正文 —— 明确起止,把用户文本围起来。
203
+ *
204
+ * 语言跟随人设整体(名字 + 描述 + 正文):中文人设配中文框,英文配英文。
205
+ *
206
+ * 框里**不列 `description`**(2026-09-17 用户裁定,此前列):那个字段的用途是**给调用方
207
+ * 选用**(本机那份写的是"当用户提到审查或使用审查子智能体时使用"),对已经在执行的人设
208
+ * 子代理是错位信息 —— 可能被读成"满足某个条件才按这个角色"的前置条件。要给人设子代理
209
+ * 看的简介属于正文(`description` 仍照常参与语言判定与目录展示)。
210
+ *
211
+ * 正文为空时退化成只给一个名字 —— 空框比没有框更糟。
212
+ */
213
+ export function renderPersonaPrompt(persona) {
214
+ // 名字与正文/输出过同一道中和:名字原样进框时,宿主对 section 文本的严格插值会把
215
+ // `{{…}}` 当未注册变量抛错,委派直接硬失败 —— 0.9.5 的中和只盖了正文与输出,漏了名字
216
+ // (validPersonaName 不挡花括号,手写 frontmatter 造得出这种名字)。
217
+ const name = neutralizePromptVariables(String(persona.name || '').trim()) || '(unnamed)';
218
+ const raw = String(persona.body ?? '');
219
+ const body = neutralizePromptVariables(raw.trim());
220
+ if (body === '')
221
+ return name;
222
+ const rawDescription = String(persona.description ?? '').trim();
223
+ const output = neutralizePromptVariables(String(persona.output ?? '').trim());
224
+ const lines = isChinesePersona(name, rawDescription, raw)
225
+ ? [
226
+ `# 角色:${name}`,
227
+ '',
228
+ `你正在以「${name}」的身份执行本次委派任务。以下角色定义由调用方指定,是你本次运行的固定行为准则:与你的默认倾向冲突时,以它为准;任务说要什么,怎么做以它为准。它决定你如何工作,不改变你的权限范围。`,
229
+ '',
230
+ '## 角色定义',
231
+ '',
232
+ body,
233
+ ...(output === '' ? [] : ['', '## 输出要求(硬性)', '', output]),
234
+ ]
235
+ : [
236
+ `# Persona: ${name}`,
237
+ '',
238
+ `You are running this delegated task as "${name}". The persona below was specified by the caller and is your fixed operating guideline for this run: where it conflicts with your default inclinations, it wins; the task states what to achieve, and this persona governs how. It governs how you work, not what you are permitted to do.`,
239
+ '',
240
+ '## Persona',
241
+ '',
242
+ body,
243
+ ...(output === '' ? [] : ['', '## Output requirements (hard)', '', output]),
244
+ ];
245
+ return lines.join('\n');
246
+ }
247
+ /** 一个人设没写 `catalogDepth` 时的目录注入深度:1 —— 只在顶层注入目录。 */
248
+ export const DEFAULT_PERSONA_CATALOG_DEPTH = 1;
249
+ /**
250
+ * 「不限制嵌套」的目录注入深度(界面下拉的第四档,前端同值见 client.js 的
251
+ * `CATALOG_DEPTH_UNLIMITED`)。
252
+ *
253
+ * 取值远大于宿主能嵌套到的深度(官方 `dsh-tool-subagent` 默认 `maxDepth: 3`),所以判据
254
+ * `深度 < catalogDepth` 在任何可达的会话里都成立 —— 判据本身不必为它加特例分支。
255
+ */
256
+ export const UNLIMITED_PERSONA_CATALOG_DEPTH = 99;
257
+ /**
258
+ * 这个人设的目录注入深度(非法值一律退回默认,**默认从不放宽**)。
259
+ *
260
+ * 为什么非法值退回默认而不是报错:`catalogDepth` 是 frontmatter 里手写的字段,写错一个
261
+ * 数字不该让整个人设不可用;而"退回默认"是安全方向 —— 默认只注入到顶层,噪声最小。
262
+ */
263
+ export function catalogDepthOf(persona) {
264
+ const value = persona?.catalogDepth;
265
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0)
266
+ return DEFAULT_PERSONA_CATALOG_DEPTH;
267
+ return value;
268
+ }
269
+ /**
270
+ * 深度为 `depth` 的会话里,该不该注入这个人设所在的目录?
271
+ *
272
+ * 判据是 `depth < 目录注入深度`。**这个函数是三处口径的唯一来源** —— 目录要不要列出、
273
+ * 域该不该注入、实况报什么状态,全都问它。各写一份必然分叉。
274
+ *
275
+ * ⚠️ 它回答的**不是**"能不能委派"(那由官方决定,且默认能嵌套到 3 层)。这个区分是
276
+ * 2026-09-17 用户实测后纠正的:此前函数名是 `canDelegateTo`,把"目录可见性"当成了
277
+ * "委派可行性",于是同一个子会话里官方工具能委派、我们的工具被自己的守卫拦下。
278
+ */
279
+ export function catalogInjectedAt(persona, depth) {
280
+ const from = Number.isSafeInteger(depth) && depth >= 0 ? depth : 0;
281
+ return from < catalogDepthOf(persona);
282
+ }
283
+ /** frontmatter 里目录注入深度的几种写法都认(含旧的 `maxDepth`,见下)。 */
284
+ function parseCatalogDepth(data) {
285
+ // 旧键 `maxDepth` 也读:0.9.5 定稿前这个字段叫过那个名字,且当时语义是"递归上限"。
286
+ // 读它只是为了不让手写过旧键的文件静默失效;**写回时一律写新键**(见 serializePersona)。
287
+ const raw = data.catalogdepth ?? data['catalog-depth'] ?? data.catalog_depth ?? data.maxdepth ?? data['max-depth'] ?? data.max_depth;
288
+ if (raw === undefined || raw.trim() === '')
289
+ return undefined;
290
+ const value = Number(raw.trim());
291
+ if (!Number.isSafeInteger(value) || value < 0)
292
+ return undefined;
293
+ return value;
294
+ }
131
295
  const RESULT_MAX = 16 * 1024;
296
+ /**
297
+ * 从内容块数组里按 `type` 取文本并拼接 —— 官方 `finalText` 的口径
298
+ * (`dsh-subagent/lib/index.js:2658`:`blocks.filter(block => block.type === "text")`;
299
+ * `dsh-tool-subagent` 的 `outputValueText`、`withDiagnosticAndPartialText` 同样如此)。
300
+ *
301
+ * **必须按 `type` 过滤,不能只判有没有 `text` 字段**:`ContentBlock` 里 `TextBlock` 与
302
+ * `ReasoningBlock` 的结构完全相同(都是 `{ type, text: string }`,见 dsh-llm 的
303
+ * `types.d.ts:39/44`),只判字段会把**思考**当成正文拼进去。本机 298 条子代理终局消息里
304
+ * 294 条受影响:211 条思考与正文并存(返回结果的开头变成「我做了 1、2、3」这类自我校验,
305
+ * 正文被顶到后面),83 条压根没有正文。
306
+ *
307
+ * `typeof b.text === 'string'` 这层是防 provider 给出畸形块(官方工具也这么防)。
308
+ */
309
+ export function textOfBlocks(blocks, type) {
310
+ if (!Array.isArray(blocks))
311
+ return '';
312
+ return blocks
313
+ .filter((b) => b !== null && typeof b === 'object' && b.type === type && typeof b.text === 'string')
314
+ .map((b) => b.text)
315
+ .join('')
316
+ .trim();
317
+ }
318
+ /**
319
+ * 没有正文时给调用方的一句话。
320
+ *
321
+ * 要解决的是**误读**:原来一律回 `(子代理无输出)`,而「子代理没干活」和「干了活但没写出
322
+ * 正文」是两件事,报成同一句会让调用方把它当成一次空跑。所以这一句里必须同时说清两件事:
323
+ * ① 它**干了活**(产出了思考)—— 否则会被读成空跑;
324
+ * ② 这次委派**没有拿到可用结果** —— 否则会被当成一份内容读下去。
325
+ *
326
+ * 按收尾原因分档,而不是给一句通用话:实测数据推翻了「它忘了写结论」这个想当然的解释 ——
327
+ * 本机 83 条「没有正文」的终局里**没有一条是 `completed`**(max-tokens 36、error 20、
328
+ * 无收尾记录 22、aborted 5),而有正文那批 215 条里 195 条是 `completed`。官方自己也是
329
+ * 这么分的:`dsh-tool-subagent/lib/index.js:316` 对任何非 `completed` 的收尾**直接抛错**
330
+ * (`stopReasonError` → `withDiagnosticAndPartialText`),只有 `completed` 才当结果返回。
331
+ * 所以这个兜底出现的场合,对策基本都是「拆小任务 / 查诊断 / 换人设」,不是原样再发一次。
332
+ *
333
+ * 分档用的是官方 `stopReasonError` 认的那几个值,出现新值时落回最后那句通用说明。
334
+ *
335
+ * 刻意**不把思考正文塞回来**:那正是这次要修的泄漏,换个标签塞回去等于没修。
336
+ */
337
+ export function emptyResultNote(output, stopReason) {
338
+ // 连思考都没有 → 子代理确实什么都没产出,这时说「没干活」是对的。
339
+ if (textOfBlocks(output, 'reasoning') === '')
340
+ return '(子代理无输出)';
341
+ const head = '子代理没有返回正文 —— 它产出了思考';
342
+ switch (stopReason) {
343
+ case 'max-tokens':
344
+ return `(${head},但写出结论前就用完了 token 上限;需要结果请把任务拆小些再委派。)`;
345
+ case 'error':
346
+ return `(${head},但中途出错了;诊断信息见后。)`;
347
+ case 'aborted':
348
+ return `(${head},但写出结论前被中止了。)`;
349
+ case 'refusal':
350
+ return `(${head},但它拒绝了这项任务。)`;
351
+ default:
352
+ return `(${head},但没有写出结论;可以拆小任务重新委派,或你自己接着做。)`;
353
+ }
354
+ }
132
355
  export function createSubagentService(ctx, opts) {
133
356
  const dir = opts?.subagentsDir || defaultPersonasDir();
134
357
  const stateDir = opts?.stateDir && opts.stateDir.trim() !== '' ? opts.stateDir : join(resolveDshHome(), 'tool-management');
135
358
  const stateFile = join(stateDir, 'subagents-index.json');
136
359
  const req = createRequire(import.meta.url);
137
360
  let cache = null;
361
+ // ── 写操作串行队列 ──────────────────────────────────────────────────────
362
+ // 状态文件的「读-改-写」必须整体串行:两个并发开关各自读到同一份快照、各自写回,
363
+ // 后写的会把先写的改动抹掉(用户关掉的人设会自己回来)。人设文件的建/改/删也走这里,
364
+ // 否则「查重 → 写入」之间的空窗会让两次同名导入互相覆盖。
365
+ // 与 rules / skills 两域同一实现(`then(task, task)` 让前一个失败后队列照样继续)。
366
+ let mutationQueue = Promise.resolve();
367
+ const enqueueMutation = (task) => {
368
+ const queued = mutationQueue.then(task, task);
369
+ mutationQueue = queued.catch(() => undefined);
370
+ return queued;
371
+ };
138
372
  // ── 人设启用集合(子智能体开关)────────────────────────────────────────
139
373
  // subagents-index.json:{ version: 1, enabled: string[] }(与 memories-index.json 同目录约定)。
140
- // - 文件缺失/损坏 = **全部启用**(老用户升级零感知,行为与开关上线前一致);
374
+ // - 文件**缺失** = **全部启用**(老用户升级零感知,行为与开关上线前一致);
375
+ // - 文件**存在但解析失败** = **全部停用** + 告警一次(fail-closed:损坏不该把用户停用的
376
+ // 人设一次性重新暴露给模型,且用户看不到任何提示;与 rules 域索引隔离同口径);
141
377
  // - 文件一旦写出即为权威:之后新建/导入/回收站恢复的人设**自动启用**(刚建就想用是常理);
142
378
  // - 停用只影响注入与 subagent_* 工具的可见性,人设文件一个字节不动。
143
379
  // 缓存约定:undefined = 还没读过盘;null = 文件缺失(全部启用);数组 = 权威集合。
144
380
  let enabledCache = undefined;
381
+ // 与缓存配对的文件指纹(mtimeMs;文件不存在时 null)。**必须比对指纹**:这个文件可能被
382
+ // 另一个 DSH 实例(多 profile 共享 state 目录)或手工编辑改动,只在自身写入时失效缓存
383
+ // 会让插件一直返回旧集合 —— 用户停用的人设悄悄回来。一次 stat 比重新读 + 解析便宜,
384
+ // 而且立刻跟上外部改动,不引入新的 TTL 窗口。
385
+ let enabledStamp = null;
386
+ // 解析失败只告警一次:readEnabled 每次读都会走到那段,不设标志会刷屏。
387
+ let warnedBrokenState = false;
388
+ /** 文件 mtimeMs;不存在或读不到时 null(与「文件缺失」同一判定)。 */
389
+ const fileStamp = async (path) => {
390
+ try {
391
+ return (await stat(path)).mtimeMs;
392
+ }
393
+ catch {
394
+ return null;
395
+ }
396
+ };
145
397
  async function readEnabled() {
146
- if (enabledCache !== undefined)
398
+ const stamp = await fileStamp(stateFile);
399
+ if (enabledCache !== undefined && stamp === enabledStamp)
147
400
  return enabledCache;
148
401
  // 用局部变量过渡:await 之后 TS 对闭包级缓存变量的收窄会失效,直接返回会报 undefined。
149
402
  let next;
403
+ let text;
150
404
  try {
151
- const raw = JSON.parse(await readFile(stateFile, 'utf8'));
152
- next = Array.isArray(raw && raw.enabled) ? raw.enabled.map((x) => String(x)) : [];
405
+ text = await readFile(stateFile, 'utf8');
153
406
  }
154
407
  catch {
408
+ text = null;
409
+ }
410
+ if (text === null) {
411
+ // 文件不存在 → 全部启用(升级零感知,见上方约定)。
155
412
  next = null;
156
413
  }
414
+ else {
415
+ try {
416
+ const raw = JSON.parse(text);
417
+ next = Array.isArray(raw && raw.enabled) ? raw.enabled.map((x) => String(x)) : [];
418
+ }
419
+ catch (e) {
420
+ // 文件**存在**却解析失败 → fail-closed 成「谁都不启用」。沿用「全部启用」会把用户
421
+ // 停用的人设一次性全部重新暴露给模型,而用户看不到任何提示;空集合至少可见、可恢复
422
+ // (重新开启即可)。文件本身一个字节不动,修好后重启即恢复。
423
+ if (!warnedBrokenState) {
424
+ warnedBrokenState = true;
425
+ ctx.logger?.warn?.(`[dsh-plugin-tool-management] 人设索引解析失败(${message(e)});已按「全部停用」处理,请检查 ${stateFile}`);
426
+ }
427
+ next = [];
428
+ }
429
+ }
157
430
  enabledCache = next;
431
+ enabledStamp = stamp;
158
432
  return next;
159
433
  }
160
434
  async function writeEnabled(list) {
161
435
  await mkdir(stateDir, { recursive: true });
162
436
  await writeFile(stateFile, JSON.stringify({ version: 1, enabled: list }, null, 2), 'utf8');
163
437
  enabledCache = list;
438
+ // 写入后重取指纹,让下一次 readEnabled 直接命中缓存(否则会白读一次)。
439
+ enabledStamp = await fileStamp(stateFile);
164
440
  }
165
441
  /** 新建/导入/恢复的人设默认停用(v0.8.5 用户裁定,与技能 / MCP 同口径):
166
442
  * 文件缺失(含旧数据)时先把「全部启用」物化成显式全集**并排除新名**——
@@ -192,6 +468,25 @@ export function createSubagentService(ctx, opts) {
192
468
  return;
193
469
  await writeEnabled(set.filter((n) => n !== name));
194
470
  }
471
+ /**
472
+ * 启用集合的「跟随写」失败**不能吞**。原先这五处一律 `.catch(() => undefined)`,
473
+ * 而写盘失败时后果是静默改变开关状态:新建/导入/恢复的人设会停在**启用**态(下一轮就进
474
+ * 模型上下文),改名的人设会停在**停用**态 —— 用户看到的却都是成功。
475
+ *
476
+ * 沿用 `sceneSyncError` 的形状:只给**原因原文**,句子由界面按当前语言拼。
477
+ */
478
+ async function withEnabledNote(res, task) {
479
+ try {
480
+ await task();
481
+ return res;
482
+ }
483
+ catch (e) {
484
+ const reason = message(e);
485
+ return res && res.ok === false
486
+ ? { ...res, error: `${res.error};另外,启停状态没能同步(${reason})` }
487
+ : { ...res, stateSyncError: reason };
488
+ }
489
+ }
195
490
  /** list() 输出统一附上 enabled:文件缺失 → 全 true;否则按集合。 */
196
491
  function withEnabled(docs, set) {
197
492
  if (set === null)
@@ -222,55 +517,79 @@ export function createSubagentService(ctx, opts) {
222
517
  }
223
518
  return withEnabled(docs, await readEnabled());
224
519
  }
225
- // spawn provider 在场性(设计 §3.2,落地方式见 cordis.patch.yml 的取舍注释):
520
+ // provider 在场性(设计 §3.2,落地方式见 cordis.patch.yml 的取舍注释):
226
521
  // 官方通道探测(ctx.subagents.list())→ 缺失才挂载 → 失败把原因原样带出(不吞、不谎报)。
227
522
  // 不是一次性静默标记:每次调用都先探测,宿主后来注册了 provider 也能立刻跟上。
228
- let spawnMount = null;
229
- function hasSpawnProvider(runtime) {
523
+ //
524
+ // 两个 provider(2026-09-17 加 fork,理由见 runOnce 的通道说明):
525
+ // spawn = 新起的独立会话(默认);fork = **继承本次会话已完成的对话**(官方 subagent_fork 用的是它)。
526
+ const PROVIDER_PACKAGES = {
527
+ spawn: { pkg: '@deepseek-ai/dsh-subagent-spawn-in-process', label: 'spawn(新会话)' },
528
+ fork: { pkg: '@deepseek-ai/dsh-subagent-fork-in-process', label: 'fork(继承本次会话)' },
529
+ };
530
+ const providerMounts = {};
531
+ function hasProvider(runtime, kind) {
230
532
  if (typeof runtime.list !== 'function')
231
533
  return false;
232
534
  try {
233
535
  const names = runtime.list();
234
- return Array.isArray(names) && names.indexOf('spawn') >= 0;
536
+ return Array.isArray(names) && names.indexOf(kind) >= 0;
235
537
  }
236
538
  catch {
237
539
  return false;
238
540
  }
239
541
  }
240
- async function ensureSpawnProvider() {
542
+ async function ensureProvider(kind) {
241
543
  const runtime = ctx.get?.('subagents');
242
544
  if (!runtime || typeof runtime.start !== 'function') {
243
545
  throw new Error('子代理服务未挂载(ctx.subagents 缺失;请确认 DSH 版本 ≥0.1.5-rc.2)');
244
546
  }
245
- if (hasSpawnProvider(runtime))
547
+ if (hasProvider(runtime, kind))
246
548
  return runtime;
247
549
  // 宿主没有 list() 就探测不了:不重复挂载(避免同名 provider 二次注册),交给 start 报官方错误兜底。
248
550
  if (typeof runtime.list !== 'function')
249
551
  return runtime;
250
- if (!spawnMount) {
552
+ if (!providerMounts[kind]) {
553
+ const { pkg, label } = PROVIDER_PACKAGES[kind];
251
554
  try {
252
- const mod = req('@deepseek-ai/dsh-subagent-spawn-in-process');
555
+ const mod = req(pkg);
253
556
  if (!mod || typeof mod.apply !== 'function')
254
557
  throw new Error('模块未导出 apply(ctx, config)(版本不匹配?)');
255
- mod.apply(ctx, { providerName: 'spawn' });
256
- spawnMount = { ok: true };
558
+ mod.apply(ctx, { providerName: kind });
559
+ providerMounts[kind] = { ok: true };
257
560
  }
258
561
  catch (e) {
259
- spawnMount = {
562
+ providerMounts[kind] = {
260
563
  ok: false,
261
- error: 'spawn provider 不可用:宿主没有注册它,本插件挂载 @deepseek-ai/dsh-subagent-spawn-in-process 也失败('
564
+ error: `${label} provider 不可用:宿主没有注册它,本插件挂载 ${pkg} 也失败(`
262
565
  + message(e) + ')。请在宿主 profile 挂载该包(本插件已声明为可选 peerDependency),或安装后重启 DSH。',
263
566
  };
264
567
  }
265
568
  }
266
- if (!spawnMount.ok)
267
- throw new Error(spawnMount.error);
268
- if (!hasSpawnProvider(runtime))
269
- throw new Error('spawn provider 挂载后仍未出现在 ctx.subagents.list()(宿主版本不匹配?)');
569
+ const mount = providerMounts[kind];
570
+ if (!mount || !mount.ok)
571
+ throw new Error(mount ? mount.error : 'provider 挂载失败(未知原因)');
572
+ if (!hasProvider(runtime, kind))
573
+ throw new Error(`${kind} provider 挂载后仍未出现在 ctx.subagents.list()(宿主版本不匹配?)`);
270
574
  return runtime;
271
575
  }
272
- async function runOnce(parentAgent, p, task, signal, toolFilter) {
273
- const runtime = await ensureSpawnProvider();
576
+ async function runOnce(parentAgent, p, task, signal, toolFilter, inherit) {
577
+ // 这里**刻意没有**"预算用尽就拒绝"的前置检查(2026-09-17 用户实测后拆掉)。
578
+ // 它此前基于一个错误假设:以为传 `maxDepth: 1` 会让子会话再委派必然失败。实际上官方
579
+ // `dsh-tool-subagent` 的默认是 **3**、provider 只在**传了值**时才校验(`resolveChildDepth`),
580
+ // 所以子代理本来就能继续嵌套。那个检查唯一的净效果是让**我们的**工具比官方严 ——
581
+ // 同一个子会话里官方工具能委派、我们的被自己拦下 —— 而用户真想禁止嵌套也禁止不了。
582
+ //
583
+ // 通道选择(方案 C,2026-09-17):`inherit` → 官方 fork provider(子会话被**本次会话已完成的
584
+ // 对话**播种,`task` 只需写新增部分);默认 → spawn(全新会话,任务必须自包含)。
585
+ // 为什么必须有这一档:官方的 `subagent_fork` 靠"继承上下文"赢走过模型的选择 —— 本机实测
586
+ // (session-ee722e23)上下文里明明有「先在这里选人设…用 subagent_manager_run 执行」与
587
+ // `code-review` 人设,模型在**读进 README 之后**仍选了 `subagent_fork`:那能把已读内容带过去、
588
+ // 不必复述。人设通道缺这一档时,"贴合人设的任务"会持续被官方工具抢走 —— 补齐能力比改文案
589
+ // 更根本(模型选的是省力,不是没看见)。fork provider 的能力位(persona / toolFilter /
590
+ // agentOptions)与 spawn 相同,所以请求体不用分叉。
591
+ const kind = inherit === true ? 'fork' : 'spawn';
592
+ const runtime = await ensureProvider(kind);
274
593
  // 工具限制三态:对象 = 调用方已按当前预设决定;null = 调用方明确决定不加限制;
275
594
  // undefined = 调用方没决定(如别的入口直接调 runSerial)→ 回落人设里的旧格式全局名单。
276
595
  const legacyFilter = (p.tools?.length || p.toolsDeny?.length)
@@ -278,17 +597,24 @@ export function createSubagentService(ctx, opts) {
278
597
  : null;
279
598
  const resolved = toolFilter === undefined ? legacyFilter : toolFilter;
280
599
  // 官方签名:start(name, request) —— name = ctx.subagents 上的 provider 注册名。
281
- const run = await runtime.start('spawn', {
600
+ const run = await runtime.start(kind, {
282
601
  label: p.name,
283
602
  parent: parentAgent,
284
603
  // 官方 SubagentStartRequest.signal 为必填:调用方缺省时给一个永不中止的信号,不传 undefined。
285
604
  signal: signal ?? new AbortController().signal,
286
605
  prompt: [{ type: 'text', text: task }],
287
- persona: p.body,
606
+ // 人设不是裸正文:宿主的 persona 槽位(section order 0)原本是部署级短前缀,
607
+ // 框架该由填槽方承担。见 renderPersonaPrompt 的说明 —— 裸拼的后果是模型把它
608
+ // 读成身份句的续写,而不是一个被指定的角色。
609
+ persona: renderPersonaPrompt(p),
288
610
  // 工具白/黑名单 → 官方 ToolRestriction(`deny` 优先级高于 `allow`;未知名官方会直接拒绝启动,
289
611
  // 所以名单由 index.ts 按当前预设 + 当前真实存在的工具名算好再传进来,见 decideToolFilter)。
290
612
  ...(resolved === null ? {} : { toolFilter: resolved }),
291
- maxDepth: 1,
613
+ // **刻意不传 `maxDepth`**(2026-09-17 用户裁定,方案 A):它是官方的**真·递归上限**
614
+ // (`resolveChildDepth` 会抛 `SubagentDepthError`),而我们的 `catalogDepth` 只想决定
615
+ // "目录出现在哪"。传了它会让我们的工具比官方严(1 vs 3),且会要求 provider 具备
616
+ // `depthLimit` capability(不传就没这个依赖)。让 provider 用它自己的默认,与官方
617
+ // 工具的能力保持一致。
292
618
  // provider 与 model 是模型路由的两半:DSH 的 resolveModel(provider, model) 不做
293
619
  // `provider/model` 字符串拆分,只改 model 会落在**主会话的 provider** 上——跨来源
294
620
  // 指定模型(如 sensenova 的 sensenova-6.8-flash-lite)必须两个键一起给。
@@ -298,15 +624,17 @@ export function createSubagentService(ctx, opts) {
298
624
  });
299
625
  try {
300
626
  const result = await run.result; // 官方契约:child 级失败不 reject(stopReason 体现)
301
- const text = (result.output ?? [])
302
- .map((b) => (b && typeof b === 'object' && typeof b.text === 'string' ? b.text : ''))
303
- .join('').trim();
627
+ // 只取 `text` 块(见 textOfBlocks)。此前这里只判了 `typeof b.text === 'string'`,
628
+ // 于是 `reasoning` 块被当成正文 —— 子代理返回的正文里混着它的思考(计数、自我校验),
629
+ // 只有思考时整段返回思考。本机 298 条终局消息里 98.7% 命中。
630
+ const text = textOfBlocks(result.output, 'text');
304
631
  // 官方 SubagentResult 的诊断字段是 `diagnostic`(旧代码读 .detail 恒空 → 失败时模型只见空输出)。
305
632
  const diagnostic = result.diagnostic ? `\n\n[provider] ${String(result.diagnostic)}` : '';
633
+ const stopReason = String(result.stopReason ?? 'completed');
306
634
  return {
307
- text: ((text || '(子代理无输出)') + diagnostic).slice(0, RESULT_MAX),
635
+ text: ((text || emptyResultNote(result.output, stopReason)) + diagnostic).slice(0, RESULT_MAX),
308
636
  runId: String(run.id ?? ''),
309
- stopReason: String(result.stopReason ?? 'completed'),
637
+ stopReason,
310
638
  };
311
639
  }
312
640
  finally {
@@ -315,17 +643,24 @@ export function createSubagentService(ctx, opts) {
315
643
  }
316
644
  // v1 串行:同一时刻至多一个子代理运行(设计 §3.2)。
317
645
  let chain = Promise.resolve();
318
- const runSerial = (parentAgent, p, task, signal, toolFilter) => {
319
- const queued = chain.then(() => runOnce(parentAgent, p, task, signal, toolFilter), () => runOnce(parentAgent, p, task, signal, toolFilter));
646
+ const runSerial = (parentAgent, p, task, signal, toolFilter, inherit) => {
647
+ const start = () => runOnce(parentAgent, p, task, signal, toolFilter, inherit);
648
+ const queued = chain.then(start, start);
320
649
  chain = queued.catch(() => undefined);
321
650
  return queued;
322
651
  };
652
+ /** 写操作 op 名集合(HTTP 端 WRITE_OPS 由它派生)。 */
653
+ const writeOps = new Set([
654
+ 'subagent-create', 'subagent-update', 'subagent-delete', 'subagent-import',
655
+ 'subagent-toggle', 'subagent-trash-restore', 'subagent-trash-delete',
656
+ ]);
323
657
  const ops = {
324
658
  'subagent-list': async () => {
325
659
  const docs = await list();
326
660
  return {
327
661
  ok: true,
328
- subagents: docs.map((p) => ({ name: p.name, enabled: p.enabled !== false, description: p.description, provider: p.provider ?? null, model: p.model ?? null, tools: p.tools ?? null, toolsDeny: p.toolsDeny ?? null, toolsByPreset: p.toolsByPreset ?? null })),
662
+ // catalogDepth 报**生效值**(没写就是默认 1),界面与模型都不必各自知道默认是多少。
663
+ subagents: docs.map((p) => ({ name: p.name, enabled: p.enabled !== false, description: p.description, provider: p.provider ?? null, model: p.model ?? null, tools: p.tools ?? null, toolsDeny: p.toolsDeny ?? null, toolsByPreset: p.toolsByPreset ?? null, catalogDepth: catalogDepthOf(p), output: p.output ?? null })),
329
664
  };
330
665
  },
331
666
  'subagent-get': async (args) => {
@@ -334,7 +669,7 @@ export function createSubagentService(ctx, opts) {
334
669
  const p = docs.find((d) => d.name === name);
335
670
  if (!p)
336
671
  return { ok: false, error: `人设不存在: ${name}` };
337
- return { ok: true, persona: { name: p.name, description: p.description, provider: p.provider ?? '', model: p.model ?? '', tools: p.tools ?? [], toolsDeny: p.toolsDeny ?? [], toolsByPreset: p.toolsByPreset ?? {}, body: p.body } };
672
+ return { ok: true, persona: { name: p.name, description: p.description, provider: p.provider ?? '', model: p.model ?? '', tools: p.tools ?? [], toolsDeny: p.toolsDeny ?? [], toolsByPreset: p.toolsByPreset ?? {}, catalogDepth: catalogDepthOf(p), output: p.output ?? '', body: p.body } };
338
673
  },
339
674
  'subagent-create': async (args) => {
340
675
  const name = String((args && args.name) || '').trim();
@@ -354,9 +689,8 @@ export function createSubagentService(ctx, opts) {
354
689
  }
355
690
  await writeFile(target, serializePersona(args), 'utf8');
356
691
  // v0.8.5:新建默认不启动——显式集合下新名天然停用;文件缺失时先物化全集并排除新名。
357
- await materializeEnabledExcluding([name]).catch(() => undefined);
358
692
  cache = null;
359
- return { ok: true, name };
693
+ return withEnabledNote({ ok: true, name }, () => materializeEnabledExcluding([name]));
360
694
  },
361
695
  /**
362
696
  * 保存人设,**可选改名**(nextName)。
@@ -383,20 +717,28 @@ export function createSubagentService(ctx, opts) {
383
717
  const taken = await readFile(nextTarget, 'utf8').then(() => true).catch(() => false);
384
718
  if (taken)
385
719
  return { ok: false, error: `人设已存在: ${nextRaw}` };
386
- try {
387
- await rename(target, nextTarget);
388
- }
389
- catch (e) {
390
- return { ok: false, error: `人设改名失败: ${message(e)}` };
391
- }
392
720
  finalName = nextRaw;
393
721
  renamedFrom = name;
394
722
  }
395
- await writeFile(join(dir, finalName + '.md'), serializePersona({ ...args, name: finalName }), 'utf8');
723
+ // 先把新内容写进临时文件、再一次性改名到位。原先的顺序是「先 rename 旧文件 → 再
724
+ // writeFile 新文件」,writeFile 一旦失败就留下「新名字 + 旧内容」的半态 —— 用户看到
725
+ // 改名成功、内容却还是旧的。临时名以 `.` 开头且不以 `.md` 结尾,扫描时天然被忽略。
726
+ const finalTarget = join(dir, finalName + '.md');
727
+ const tmp = join(dir, `.${finalName}.tmp-${process.pid}-${Date.now()}`);
728
+ try {
729
+ await writeFile(tmp, serializePersona({ ...args, name: finalName }), 'utf8');
730
+ await rename(tmp, finalTarget);
731
+ }
732
+ catch (e) {
733
+ await rm(tmp, { force: true }).catch(() => undefined);
734
+ return { ok: false, error: `保存人设失败: ${message(e)}` };
735
+ }
736
+ // 改名时旧文件等新文件就位后再清:清失败只多一份副本,不丢数据。
396
737
  if (renamedFrom)
397
- await renamePersonaInEnabled(renamedFrom, finalName).catch(() => undefined);
738
+ await rm(join(dir, renamedFrom + '.md'), { force: true }).catch(() => undefined);
398
739
  cache = null;
399
- return { ok: true, name: finalName, ...(renamedFrom ? { renamedFrom } : {}) };
740
+ return withEnabledNote({ ok: true, name: finalName, ...(renamedFrom ? { renamedFrom } : {}) }, async () => { if (renamedFrom)
741
+ await renamePersonaInEnabled(renamedFrom, finalName); });
400
742
  },
401
743
  /**
402
744
  * 删除人设 = **移入回收站**(`hub/trash/agents-trash/<id>/persona.md`)。
@@ -414,9 +756,8 @@ export function createSubagentService(ctx, opts) {
414
756
  const moved = await moveToTrash('subagents', name, [{ from: target, dest: 'persona.md' }]);
415
757
  if (moved.ok === false)
416
758
  return { ok: false, error: `移入回收站失败: ${moved.error}` };
417
- await removePersonaFromEnabled(name).catch(() => undefined);
418
759
  cache = null;
419
- return { ok: true, name, trashId: moved.id };
760
+ return withEnabledNote({ ok: true, name, trashId: moved.id }, () => removePersonaFromEnabled(name));
420
761
  },
421
762
  'subagent-trash-list': async () => ({ ok: true, trash: await listTrashEntries('subagents') }),
422
763
  'subagent-trash-restore': async (args) => {
@@ -440,9 +781,8 @@ export function createSubagentService(ctx, opts) {
440
781
  }
441
782
  await purgeTrashEntry('subagents', id);
442
783
  // v0.8.5:回收站恢复默认不启动(与新建/导入同口径)。
443
- await materializeEnabledExcluding([entry.name]).catch(() => undefined);
444
784
  cache = null;
445
- return { ok: true, name: entry.name };
785
+ return withEnabledNote({ ok: true, name: entry.name }, () => materializeEnabledExcluding([entry.name]));
446
786
  },
447
787
  'subagent-trash-delete': async (args) => {
448
788
  const id = String((args && args.id) || '').trim();
@@ -490,11 +830,10 @@ export function createSubagentService(ctx, opts) {
490
830
  }
491
831
  imported.push(target.name);
492
832
  }
493
- if (imported.length) {
494
- await materializeEnabledExcluding(imported).catch(() => undefined);
833
+ if (imported.length)
495
834
  cache = null;
496
- }
497
- return { ok: true, imported, skipped };
835
+ return withEnabledNote({ ok: true, imported, skipped }, async () => { if (imported.length)
836
+ await materializeEnabledExcluding(imported); });
498
837
  },
499
838
  /**
500
839
  * 子智能体开关(v0.8):停用 = 不注入目录段、subagent_manager_list/run 不可见;文件本体不动。
@@ -527,6 +866,13 @@ export function createSubagentService(ctx, opts) {
527
866
  return { ok: true, name, enabled: args.enabled === true };
528
867
  },
529
868
  };
869
+ // 写 op 统一进串行队列(理由见上方 mutationQueue 的说明)。放在这里统一包、而不是逐个手写:
870
+ // 以后新增写 op 只改 writeOps 一处,不会漏掉。
871
+ for (const name of writeOps) {
872
+ const fn = ops[name];
873
+ if (typeof fn === 'function')
874
+ ops[name] = (args) => enqueueMutation(() => fn(args));
875
+ }
530
876
  const enabledStore = {
531
877
  /** 指定名单里当前被停用的(进入模式拍快照用:只记将被启用的行,退出时精确停回)。 */
532
878
  async disabledAmong(names) {
@@ -546,35 +892,64 @@ export function createSubagentService(ctx, opts) {
546
892
  const state = new Map(docs.map((d) => [d.name, d.enabled !== false]));
547
893
  return docs.map((d) => d.name).filter((n) => state.get(n) === true);
548
894
  },
549
- /** 批量启停;只碰给出的名字,人设已不存在的跳过(别把悬空名写进集合)。 */
895
+ /** 批量启停;只碰给出的名字,人设已不存在的跳过(别把悬空名写进集合)。
896
+ * 同样走写队列:它由场景档案引擎直接调用(不经 op 表),不排队就会与开关 op 互相覆盖。 */
550
897
  async setEnabled(names, enabled) {
551
- const docs = await list();
552
- const set = await readEnabled();
553
- const base = (set === null ? docs.map((d) => d.name) : set.slice()).filter((n) => docs.some((d) => d.name === n));
554
- let dirty = false;
555
- for (const n of names) {
556
- if (!docs.some((d) => d.name === n))
557
- continue;
558
- const i = base.indexOf(n);
559
- if (enabled && i < 0) {
560
- base.push(n);
561
- dirty = true;
898
+ await enqueueMutation(async () => {
899
+ const docs = await list();
900
+ const set = await readEnabled();
901
+ const base = (set === null ? docs.map((d) => d.name) : set.slice()).filter((n) => docs.some((d) => d.name === n));
902
+ let dirty = false;
903
+ for (const n of names) {
904
+ if (!docs.some((d) => d.name === n))
905
+ continue;
906
+ const i = base.indexOf(n);
907
+ if (enabled && i < 0) {
908
+ base.push(n);
909
+ dirty = true;
910
+ }
911
+ if (!enabled && i >= 0) {
912
+ base.splice(i, 1);
913
+ dirty = true;
914
+ }
562
915
  }
563
- if (!enabled && i >= 0) {
564
- base.splice(i, 1);
565
- dirty = true;
916
+ if (dirty) {
917
+ await writeEnabled(base);
918
+ cache = null;
566
919
  }
567
- }
568
- if (dirty) {
569
- await writeEnabled(base);
570
- cache = null;
571
- }
920
+ });
572
921
  },
573
922
  };
574
- return { list, runSerial, ops, enabledStore, writeOps: new Set(['subagent-create', 'subagent-update', 'subagent-delete', 'subagent-import', 'subagent-toggle', 'subagent-trash-restore', 'subagent-trash-delete']) };
923
+ return { list, runSerial, ops, enabledStore, writeOps };
924
+ }
925
+ /** 人设名长度上限(字符)。 */
926
+ const PERSONA_NAME_MAX = 64;
927
+ /**
928
+ * 官方 `tools.restrict()` 的**保留名**:名单里出现它时,官方不是"当它不存在",而是**直接抛错**
929
+ * (`dsh-tools/lib/index.js:2800`:cannot name reserved PTC mode presentation transport),
930
+ * 子代理当场起不来。
931
+ *
932
+ * 为什么必须在这里显式剔除:`run_code` 在宿主面上是**合法可见**的工具名(非 native 模式下
933
+ * 由官方补进可见集合),所以"按当前存在的工具名过滤未知项"剔不掉它 —— 过滤留下的正好是
934
+ * 会让官方抛错的那个。用户的直观预期是"写了就生效(或至少被忽略)",实际却是整个委派失败。
935
+ */
936
+ const RESERVED_TOOL_NAMES = new Set(['run_code']);
937
+ /** 把保留名从名单里剔掉,并说明剔了什么(不静默)。 */
938
+ function stripReserved(names) {
939
+ const kept = [];
940
+ const dropped = [];
941
+ for (const name of names)
942
+ (RESERVED_TOOL_NAMES.has(name) ? dropped : kept).push(name);
943
+ return { kept, dropped };
575
944
  }
576
- function validPersonaName(name) {
577
- return name.length > 0 && name.length <= 64 && !name.startsWith('.') && !/[\\/<>:"|?*]/.test(name);
945
+ /**
946
+ * 人设名合法性:谓词收敛到 `../paths.ts`(此前这里与 rules / imports / skills 各写一套,
947
+ * 缺了 Windows 保留设备名与控制字符检查 —— 设备名在 Windows 上创建即失败)。
948
+ * 注意:**不挡花括号**,手写 frontmatter 仍造得出 `{{…}}` 名字,渲染侧照旧做中和
949
+ * (见 renderPersonaPrompt)。
950
+ */
951
+ export function validPersonaName(name) {
952
+ return isValidSegment(name, PERSONA_NAME_MAX);
578
953
  }
579
954
  /** 字符串数组规范化(非数组/空项都丢掉)。 */
580
955
  function toStringList(value) {
@@ -611,7 +986,19 @@ export function serializePersona(args) {
611
986
  // 黑名单字段兼容两种入参名:toolsDeny(UI/camel)与 tools_deny(snake)。
612
987
  const toolsDeny = toStringList(args?.toolsDeny ?? args?.tools_deny);
613
988
  const byPreset = presetRulesOf(args?.toolsByPreset);
989
+ // 目录注入深度:只在显式给了合法值时写入。默认(1)**不落盘** —— 免得每个新建的人设都
990
+ // 多一行说明"它和默认一样",也保证老的人设文件回写后逐字节不变。
991
+ // 入参名兼容三种:catalogDepth(新)、maxDepth / max_depth(0.9.5 定稿前的旧名)。
992
+ const catalogDepthRaw = args?.catalogDepth ?? args?.catalog_depth ?? args?.maxDepth ?? args?.max_depth;
993
+ const catalogDepth = catalogDepthRaw === undefined || catalogDepthRaw === null || String(catalogDepthRaw).trim() === ''
994
+ ? undefined
995
+ : Number(String(catalogDepthRaw).trim());
996
+ const hasCatalogDepth = catalogDepth !== undefined && Number.isSafeInteger(catalogDepth) && catalogDepth >= 0 && catalogDepth !== DEFAULT_PERSONA_CATALOG_DEPTH;
614
997
  const body = String((args && args.body) ?? '').trim();
998
+ // `output` 可传字符串(按行拆)或数组(界面直接给数组):一条要求写成一行重复键。
999
+ const outputLines = (Array.isArray(args?.output) ? args.output.map((v) => String(v)) : String(args?.output ?? '').split(/\r?\n/))
1000
+ .map((line) => line.trim())
1001
+ .filter((line) => line !== '');
615
1002
  const lines = ['---'];
616
1003
  if (description)
617
1004
  lines.push('description: ' + description);
@@ -619,6 +1006,10 @@ export function serializePersona(args) {
619
1006
  lines.push('provider: ' + provider);
620
1007
  if (model)
621
1008
  lines.push('model: ' + model);
1009
+ if (hasCatalogDepth)
1010
+ lines.push('catalogDepth: ' + String(catalogDepth));
1011
+ for (const line of outputLines)
1012
+ lines.push('output: ' + line);
622
1013
  if (tools.length)
623
1014
  lines.push('tools: ' + tools.join(', '));
624
1015
  if (toolsDeny.length)
@@ -635,34 +1026,57 @@ export function serializePersona(args) {
635
1026
  * 按**当前会话的 Agent 预设**决定这次委派下发什么工具限制。
636
1027
  *
637
1028
  * 为什么必须按预设分:子代理跑在父会话的预设里(官方 `composeFrom(childCtx, parent.ctx)`),
638
- * 而各预设的工具集合差别极大(极简模式只有持久 shell),官方 `tools.restrict()` 遇到
639
- * 名单里不存在的工具名会直接抛错、子代理根本起不来。所以:
1029
+ * 而各预设的工具集合差别极大(极简模式只有持久 shell)。官方 `tools.restrict()` 实有
1030
+ * **四个**抛错点(`dsh-tools/lib/index.js:2790-2803`),抛了子代理就起不来:
1031
+ *
1032
+ * ① 非 scoped context 调用(宿主 `childCtx` 已满足,无风险);
1033
+ * ② `allow` 与 `deny` 同时缺省 ⇒ `restrict({})` 抛 —— 本函数用 `filter: null`(不下发)
1034
+ * 表达"不加限制",**从不**调用 `restrict({})`;
1035
+ * ③ **名单含保留名 `run_code` 即抛** —— 而它在宿主面上是合法可见名,"按现有工具名过滤"
1036
+ * 剔不掉它,所以这里**显式剔除**并写进 note(见 `stripReserved`);
1037
+ * ④ 未知名 ⇒ 抛(唯一此前被记录的那条)。
640
1038
  *
1039
+ * 所以:
641
1040
  * - 当前预设配了名单(且名单非空)→ 用它;白名单额外并入**当时真实在跑的 MCP 工具**
642
1041
  * (官方 allow 是"清单之外全砍",不并进来会把 MCP 一起砍掉;用户裁定:子代理要能
643
1042
  * 用当前启动的 MCP);
644
1043
  * - 当前预设没配 → 回落旧的全局 `tools` / `toolsDeny`(老文件行为不变);
645
- * - 名单里有已经消失的工具名 → **丢掉并在 note 里如实说明**(不接受静默失效);
1044
+ * - 名单里有已经消失的工具名、或写了保留名 → **丢掉并在 note 里如实说明**(不接受静默失效);
646
1045
  * - 判断不了当前预设(老宿主 / 异常)→ 不按模式施加,并在 note 里说明。
647
1046
  *
1047
+ * ⚠ 两种失败方向都要记账(它们互斥,且都不是"没生效"这么简单):名单全落空时本函数返回
1048
+ * `filter: null` = **放宽到不限制**(fail-open);保留名没剔干净时官方抛错 = **收紧到起不来**。
1049
+ *
648
1050
  * 三态返回:`filter: null` = 明确不加限制;`filter: {...}` = 下发该限制。
649
1051
  */
650
1052
  export function decideToolFilter(persona, presetId, known) {
651
1053
  const rule = presetId === null ? undefined : persona.toolsByPreset?.[presetId];
652
1054
  if (rule && rule.names.length) {
653
- const usable = rule.names.filter((name) => known.names.has(name));
1055
+ const knownNames = rule.names.filter((name) => known.names.has(name));
1056
+ const reserved = stripReserved(knownNames);
1057
+ const usable = reserved.kept;
654
1058
  const dropped = rule.names.filter((name) => !known.names.has(name));
655
- const droppedNote = dropped.length ? `名单里这些工具当前不存在,已忽略:${dropped.join('、')}` : undefined;
1059
+ const notes = [];
1060
+ if (dropped.length)
1061
+ notes.push(`名单里这些工具当前不存在,已忽略:${dropped.join('、')}`);
1062
+ if (reserved.dropped.length) {
1063
+ notes.push(`名单里的 ${reserved.dropped.join('、')} 是官方保留名(写进工具限制会让子代理直接起不来),已忽略`);
1064
+ }
656
1065
  if (!usable.length) {
657
- return { filter: null, note: `「${presetId}」的${rule.mode === 'allow' ? '白' : '黑'}名单里没有当前存在的工具,本次不施加工具限制` };
1066
+ const note = notes.length ? notes.join(';') : undefined;
1067
+ return { filter: null, note: note ?? `「${presetId}」的${rule.mode === 'allow' ? '白' : '黑'}名单里没有当前存在的工具,本次不施加工具限制` };
658
1068
  }
1069
+ const droppedNote = notes.length ? notes.join(';') : undefined;
659
1070
  if (rule.mode === 'allow')
660
1071
  return { filter: { allow: [...new Set([...usable, ...known.mcp])] }, ...(droppedNote === undefined ? {} : { note: droppedNote }) };
661
1072
  return { filter: { deny: usable }, ...(droppedNote === undefined ? {} : { note: droppedNote }) };
662
1073
  }
663
1074
  // 旧格式(全局名单):保持老行为,白名单同样并入 MCP。
664
- const legacyAllow = (persona.tools || []).filter((name) => known.names.has(name));
665
- const legacyDeny = (persona.toolsDeny || []).filter((name) => known.names.has(name));
1075
+ const legacyKnown = [...(persona.tools || []), ...(persona.toolsDeny || [])].filter((name) => known.names.has(name));
1076
+ const legacyReserved = stripReserved(legacyKnown);
1077
+ const legacyKept = new Set(legacyReserved.kept);
1078
+ const legacyAllow = (persona.tools || []).filter((name) => legacyKept.has(name));
1079
+ const legacyDeny = (persona.toolsDeny || []).filter((name) => legacyKept.has(name));
666
1080
  const legacyDropped = [...(persona.tools || []), ...(persona.toolsDeny || [])].filter((name) => !known.names.has(name));
667
1081
  const filter = {
668
1082
  ...(legacyAllow.length ? { allow: [...new Set([...legacyAllow, ...known.mcp])] } : {}),
@@ -678,6 +1092,9 @@ export function decideToolFilter(persona, presetId, known) {
678
1092
  }
679
1093
  if (legacyDropped.length)
680
1094
  notes.push(`名单里这些工具当前不存在,已忽略:${legacyDropped.join('、')}`);
1095
+ if (legacyReserved.dropped.length) {
1096
+ notes.push(`名单里的 ${legacyReserved.dropped.join('、')} 是官方保留名(写进工具限制会让子代理直接起不来),已忽略`);
1097
+ }
681
1098
  if (!Object.keys(filter).length) {
682
1099
  if (presetId === null && !notes.length)
683
1100
  notes.push('没能判断当前会话的 Agent 预设,本次不施加工具限制');