dsh-paoding 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +27 -32
  2. package/README_en.md +27 -32
  3. package/bin/dsh-paoding.mjs +73 -11
  4. package/docs/architecture.md +41 -34
  5. package/docs/architecture_en.md +65 -42
  6. package/docs/configuration.md +159 -65
  7. package/docs/configuration_en.md +161 -66
  8. package/docs/images/1.png +0 -0
  9. package/docs/installation.md +100 -237
  10. package/docs/installation_en.md +112 -252
  11. package/docs/orchestration.md +34 -38
  12. package/docs/orchestration_en.md +34 -38
  13. package/package.json +8 -5
  14. package/plugins/paoding-config-ui/api-core.mjs +215 -18
  15. package/plugins/paoding-config-ui/index.mjs +414 -154
  16. package/plugins/paoding-config-ui/lib/base.css +1083 -42
  17. package/plugins/paoding-config-ui/lib/client.js +2266 -252
  18. package/plugins/paoding-config-ui/package.json +2 -2
  19. package/plugins/paoding-config-ui/runtime-inventory.mjs +27 -26
  20. package/presets/orchestrator/agent.cordis.yml +19 -9
  21. package/presets/orchestrator/preset.yml +3 -2
  22. package/presets/orchestrator/restrict.mjs +2 -1
  23. package/tools/install.mjs +9 -4
  24. package/tools/lib/alloc.mjs +60 -29
  25. package/tools/lib/cli.mjs +127 -278
  26. package/tools/lib/compose.mjs +261 -95
  27. package/tools/lib/config.mjs +266 -86
  28. package/tools/lib/host.mjs +55 -24
  29. package/tools/lib/skills.mjs +72 -45
  30. package/tools/lib/state.mjs +172 -40
  31. package/tools/lib/upgrade.mjs +170 -0
  32. package/tools/lib/util.mjs +7 -0
  33. package/tools/lib/version.mjs +233 -0
  34. package/tools/lib/workspaces.mjs +74 -0
  35. package/tools/lib/yaml.mjs +92 -15
  36. package/docs/images/2.png +0 -0
  37. package/docs/images/3.png +0 -0
  38. package/docs/images/4.png +0 -0
  39. package/docs/images/5.png +0 -0
  40. package/docs/images/6.png +0 -0
  41. package/install.sh +0 -31
  42. package/tools/lib/wizard.mjs +0 -516
@@ -6,19 +6,28 @@
6
6
  * (renderCustomRoleBlocks / firstLine / insertCustomRoles)、roles_remove 委派行
7
7
  * span 删除(delegationRowSpan / composeMainPersonaEdit)、角色专用模型注入
8
8
  * (injectRoleAgentOptions,roles.<toolName>.model / .provider → 内置角色委派块
9
- * 的 agentOptions 子块)、restrict config.allow 注入(computeRestrictAllow /
10
- * injectRestrictAllow / filterHostTools)与整文 compose(composeGenerated)。
11
- * 自定义角色另有两道防护/自动化:tools 为空时拒绝安装零工具角色
12
- * (renderCustomRoleBlocks 直接 throw,防 `toolFilter.allow:` 空白名单);并把
13
- * 角色 persona 首行职责句自动追加为主 agent persona 委派行
14
- * (composeMainPersonaEdit 第 4 参 customBullets → `- <职责>: delegate to
15
- * <toolName>.`),否则主 agent 永远不会委派给自定义角色。
9
+ * 的 agentOptions 子块)、角色可续模式改写/兜底注入(injectRoleBackgroundMode,
10
+ * roles.<toolName>.background_mode = 'continuable' → 内置角色委派块的
11
+ * backgroundMode 行:源模板已预置该行时原位改写其值,块内无该行时在 toolName
12
+ * 行行尾兜底注入;'one-shot' 不动源行 —— 源模板四块各预置
13
+ * `backgroundMode: one-shot`(可见性),全默认产物与源零 diff)、
14
+ * restrict config.allow 注入(computeRestrictAllow /
15
+ * injectRestrictAllow / filterUsableTools)与整文 compose(composeGenerated)。
16
+ * allow 过滤走白名单语义(usableWith / presetUniverse ∪ inventory,见
17
+ * filterUsableTools 上方说明):手写进配置、既不在 preset 自带工具面也不在
18
+ * 检测库存的名字一律从 allow 丢弃,不再「非 host 依赖名直通」。
19
+ * 自定义角色另有三道防护/自动化:toolName 保留名校验(assertCustomToolName,
20
+ * 内置角色 / restrictBase / subagent* / mcp__ 前缀 / YAML 字面量一律 throw);
21
+ * tools 为空时拒绝安装零工具角色(renderCustomRoleBlocks 直接 throw,防
22
+ * `toolFilter.allow:` 空白名单);并把角色 persona 首行职责句自动追加为主
23
+ * agent persona 委派行(composeMainPersonaEdit 第 4 参 customBullets →
24
+ * `- <职责>: delegate to <toolName>.`),否则主 agent 永远不会委派给自定义角色。
16
25
  * 依赖 util(ROLES / warn / DEFAULT_MAIN_AGENT_PERSONA_EXTRA)、config
17
- * (normalizeMainAgentName / normalizeModelRef / yamlScalar)与 host
18
- * (isHostDependent);被 wizard / state 引用。
26
+ * (normalizeModelRef / yamlScalar)与 host
27
+ * (isHostDependent);被 state 引用。
19
28
  */
20
29
  import { DEFAULT_MAIN_AGENT_PERSONA_EXTRA, ROLES, warn } from './util.mjs'
21
- import { normalizeMainAgentName, normalizeModelRef, yamlScalar } from './config.mjs'
30
+ import { normalizeModelRef, yamlScalar } from './config.mjs'
22
31
  import { isHostDependent } from './host.mjs'
23
32
 
24
33
  // ── restrict.mjs main-agent allow base ──────────────────────────────────────
@@ -31,11 +40,62 @@ import { isHostDependent } from './host.mjs'
31
40
  export function extractMainAgentAllow(restrictSrc) {
32
41
  const match = restrictSrc.match(/const MAIN_AGENT_ALLOW = new Set\(\[([\s\S]*?)\]\)/)
33
42
  if (!match) throw new Error('MAIN_AGENT_ALLOW block not found in restrict.mjs')
34
- const names = [...match[1].matchAll(/'([^']+)'/g)].map((m) => m[1])
43
+ // 先按行剥 // 注释再提取引号串:注释里的撇号(如 don't)会被当成工具名的
44
+ // 引号对,产生幽灵工具名(allow 段常带中英文注释)。
45
+ const body = match[1]
46
+ .split('\n')
47
+ .map((line) => line.replace(/\/\/.*$/, ''))
48
+ .join('\n')
49
+ const names = [...body.matchAll(/'([^']+)'/g)].map((m) => m[1])
35
50
  if (names.length === 0) throw new Error('MAIN_AGENT_ALLOW block is empty in restrict.mjs')
36
51
  return names
37
52
  }
38
53
 
54
+ // ── shared helpers ──────────────────────────────────────────────────────────
55
+
56
+ /** 名字可用性判定(allow 过滤多处共用,白名单语义):名字必须落在 preset 自带
57
+ * 工具面(presetUniverse = restrict.mjs 主 agent 白名单 ∪ 源 preset 各角色 allow)
58
+ * 或检测清单 inventory 之中,两者都不在 → 不可用(插件停用后的 mnemon_*、手写
59
+ * 错名等不再「非 host 依赖名直通」,与文档「配置意图 ∩ 实际启用工具」口径对齐);
60
+ * host 依赖名(mcp__* / 已知插件工具)即便在 presetUniverse 里也必须真的被检测
61
+ * 到才保留。 */
62
+ function usableWith(name, presetUniverse, inventory) {
63
+ if (inventory.has(name)) return true
64
+ return !isHostDependent(name) && presetUniverse.has(name)
65
+ }
66
+
67
+ /** 角色专用模型归一(自定义块渲染与内置角色注入两处共用):model / provider 过
68
+ * normalizeModelRef;仅 model 非 null 才有效,provider-alone warn 后置 null。 */
69
+ function normalizeRoleModel(toolName, rawModel, rawProvider) {
70
+ const model = normalizeModelRef(rawModel, `roles.${toolName}.model`)
71
+ let provider = normalizeModelRef(rawProvider, `roles.${toolName}.provider`)
72
+ if (model === null && provider !== null) {
73
+ warn(`roles.${toolName}.provider: '${provider}' 仅在同时配置 model 时生效,已忽略`)
74
+ provider = null
75
+ }
76
+ return { model, provider }
77
+ }
78
+
79
+ /**
80
+ * 自定义角色 toolName 保留名校验:与内置角色(ROLES)、restrict.mjs 主 agent
81
+ * allow 白名单(restrictBase)、顶层 subagent / subagent_fork 撞名的 toolName
82
+ * 会静默遮蔽既有工具面;mcp__ 前缀闯 host MCP 工具名空间;true/false/null 等
83
+ * YAML 字面量会让生成的 `toolName: <名>` 行解析成布尔/null —— 一律在生成期
84
+ * throw(错误信息带角色名),宁可拒绝安装也不静默产出坏 preset。
85
+ */
86
+ export function assertCustomToolName(toolName, restrictBase = []) {
87
+ const reserved = new Set([...ROLES, ...restrictBase, 'subagent', 'subagent_fork'])
88
+ if (reserved.has(toolName)) {
89
+ throw new Error(`role "${toolName}": toolName 与保留名冲突(内置角色 / restrict 主 agent 白名单 / subagent*),请换一个名字`)
90
+ }
91
+ if (String(toolName).startsWith('mcp__')) {
92
+ throw new Error(`role "${toolName}": toolName 不得使用 mcp__ 前缀(与 host MCP 工具名空间冲突)`)
93
+ }
94
+ if (/^(true|false|null|~)$/i.test(toolName)) {
95
+ throw new Error(`role "${toolName}": toolName 是 YAML 字面量,生成的委派行会被解析成布尔/null,请换一个名字`)
96
+ }
97
+ }
98
+
39
99
  /** Render a persona block scalar with a given content indent. */
40
100
  export function renderPersona(text, indent) {
41
101
  const pad = ' '.repeat(indent)
@@ -85,14 +145,16 @@ export function replaceRoleIdentity(text, displayName) {
85
145
  * Render the delegation block(s) for custom roles, matching the existing
86
146
  * delegation-* indentation (4-space list items).
87
147
  */
88
- export function renderCustomRoleBlocks(roles, skillsMap) {
148
+ export function renderCustomRoleBlocks(roles, skillsMap, restrictBase = []) {
89
149
  const custom = Object.entries(roles).filter(([toolName]) => !ROLES.includes(toolName))
90
150
  if (custom.length === 0) return ''
91
151
  const chunks = []
92
152
  for (const [toolName, role] of custom) {
93
- // 零工具防护(与 composeGenerated 内置角色循环同款):tools 为空(缺省或
94
- // `tools:` 空列表)会生成 `toolFilter.allow:`(YAML null),子 agent 创建后
95
- // 零工具——宁可拒绝安装,也不静默生成一个干不了活的角色。
153
+ // 保留名校验(见 assertCustomToolName)+ 零工具防护(与 composeGenerated
154
+ // 内置角色循环同款):tools 为空(缺省或 `tools:` 空列表)会生成
155
+ // `toolFilter.allow:`(YAML null),子 agent 创建后零工具——宁可拒绝安装,
156
+ // 也不静默生成一个干不了活的角色。
157
+ assertCustomToolName(toolName, restrictBase)
96
158
  if ((role.tools ?? []).length === 0) {
97
159
  throw new Error(`role "${toolName}" would end up with an empty toolFilter.allow — refusing to install a zero-tool role.`)
98
160
  }
@@ -101,16 +163,14 @@ export function renderCustomRoleBlocks(roles, skillsMap) {
101
163
  toolName,
102
164
  skillsMap,
103
165
  )
104
- // 角色专用模型(roles.<toolName>.model / .provider):自定义角色与内置角色
105
- // 同形状(provider: spawn / toolName / agentOptions? / persona / toolFilter)。
106
- // 先归一再判 provider-alone(防 UI / 手工构造的 assignments 绕过规范化);
107
- // 仅 model 非 null 才插 agentOptions 子块,provider-alone 仅 warn 忽略。
108
- const model = normalizeModelRef(role.model ?? null, `roles.${toolName}.model`)
109
- let provider = normalizeModelRef(role.provider ?? null, `roles.${toolName}.provider`)
110
- if (model === null && provider !== null) {
111
- warn(`roles.${toolName}.provider: '${provider}' 仅在同时配置 model 时生效,已忽略`)
112
- provider = null
113
- }
166
+ // 角色专用模型与可续模式(roles.<toolName>.model / .provider / .background_mode):
167
+ // 自定义角色与内置角色同形状(provider: spawn / toolName / agentOptions? /
168
+ // backgroundMode / persona / toolFilter)。先归一再判 provider-alone(防 UI /
169
+ // 手工构造的 assignments 绕过规范化);仅 model 非 null 才插 agentOptions 子块,
170
+ // provider-alone 仅 warn 忽略。backgroundMode 行恒写(与内置块源模板预置口径
171
+ // 一致:行始终可见),continuable → continuable,one-shot/缺省/异常值 →
172
+ // one-shot(与 config 解析回落口径一致)。
173
+ const { model, provider } = normalizeRoleModel(toolName, role.model ?? null, role.provider ?? null)
114
174
  // agentOptions 子行(键序固定 provider 在前、model 在后,与 dsh-tool-subagent
115
175
  // Config 字段序一致);model 缺省时整块不出现。
116
176
  const agentOptionsLines = []
@@ -119,6 +179,9 @@ export function renderCustomRoleBlocks(roles, skillsMap) {
119
179
  if (provider !== null) agentOptionsLines.push(` provider: ${yamlScalar(provider)}`)
120
180
  agentOptionsLines.push(` model: ${yamlScalar(model)}`)
121
181
  }
182
+ // backgroundMode 行恒写(键序固定在 agentOptions? 之后、persona 之前,与内置
183
+ // 角色委派块的预置/注入落点键序一致);非 continuable 一律落 one-shot。
184
+ const backgroundModeLines = [` backgroundMode: ${role.background_mode === 'continuable' ? 'continuable' : 'one-shot'}`]
122
185
  chunks.push(
123
186
  [
124
187
  ` # ${toolName}:自定义角色(${firstLine(role.persona || '')})`,
@@ -126,13 +189,16 @@ export function renderCustomRoleBlocks(roles, skillsMap) {
126
189
  ` name: '@deepseek-ai/dsh-tool-subagent'`,
127
190
  ' config:',
128
191
  ' provider: spawn',
129
- ` toolName: ${toolName}`,
192
+ // toolName / 工具条目过 yamlScalar:纯数字、YAML 字面量形态等歧义名
193
+ // 必须带引号落盘,否则委派行会被解析成数字/布尔/null。
194
+ ` toolName: ${yamlScalar(toolName)}`,
130
195
  ...agentOptionsLines,
196
+ ...backgroundModeLines,
131
197
  ' persona: |-',
132
198
  ...persona.split('\n').map((l) => ` ${l}`),
133
199
  ' toolFilter:',
134
200
  ' allow:',
135
- ...(role.tools ?? []).map((t) => ` - ${t}`),
201
+ ...(role.tools ?? []).map((t) => ` - ${yamlScalar(t)}`),
136
202
  ].join('\n'),
137
203
  )
138
204
  }
@@ -146,21 +212,21 @@ export function firstLine(text) {
146
212
 
147
213
  /**
148
214
  * Compute the orchestrator-restrict config.allow: restrict.mjs base, minus
149
- * host-dependent names not detected, plus main-agent extra tools and custom
150
- * role toolNames, minus main_agent_remove entries and removed builtin role
151
- * toolNames (roles_remove). Returns null when the result equals the base (no
152
- * injection needed) — but only when no builtin role was removed: once
153
- * roles_remove is non-empty the explicit allow MUST be injected, otherwise the
154
- * static restrict.mjs MAIN_AGENT_ALLOW would keep delegating to the deleted
155
- * role (删除不生效).
215
+ * names outside the preset universe and detection inventory, plus main-agent
216
+ * extra tools and custom role toolNames, minus main_agent_remove entries and
217
+ * removed builtin role toolNames (roles_remove). Returns null when the result
218
+ * equals the base (no injection needed) — but only when no builtin role was
219
+ * removed: once roles_remove is non-empty the explicit allow MUST be injected,
220
+ * otherwise the static restrict.mjs MAIN_AGENT_ALLOW would keep delegating to
221
+ * the deleted role (删除不生效).
156
222
  */
157
- export function computeRestrictAllow(base, inventory, mainAgentExtra, mainAgentRemove, customToolNames, removedRoleNames = []) {
223
+ export function computeRestrictAllow(base, presetUniverse, inventory, mainAgentExtra, mainAgentRemove, customToolNames, removedRoleNames = []) {
158
224
  const kept = []
159
225
  for (const name of base) {
160
- if (!isHostDependent(name) || inventory.has(name)) kept.push(name)
226
+ if (usableWith(name, presetUniverse, inventory)) kept.push(name)
161
227
  }
162
228
  for (const name of mainAgentExtra) {
163
- if (!kept.includes(name) && (!isHostDependent(name) || inventory.has(name))) kept.push(name)
229
+ if (!kept.includes(name) && usableWith(name, presetUniverse, inventory)) kept.push(name)
164
230
  }
165
231
  for (const name of customToolNames) {
166
232
  if (!kept.includes(name)) kept.push(name)
@@ -187,7 +253,8 @@ export function injectRestrictAllow(srcText, allowList) {
187
253
  * used to inline the selected skills' full SKILL.md bodies (composeGenerated,
188
254
  * the main-agent skills step) and to append the user-editable persona tail
189
255
  * (main_agent_persona_extra). Locates the block structurally (same scan as
190
- * locatePersonaBlocks' regex branch): marker row → `text: |-` → first content
256
+ * locatePersonaBlocks' regex branch): marker row → `prefix: |-` (legacy
257
+ * `text: |-`) → first content
191
258
  * line sets the indent (6 in the source preset) → block ends at the first
192
259
  * non-empty line indented shallower than the content. Replaces the span
193
260
  * [dashStart, end) with the original content + suffixText re-rendered as a
@@ -197,9 +264,17 @@ export function appendToMainPersona(srcText, suffixText) {
197
264
  const marker = `- id: persona\n name: '@deepseek-ai/dsh-persona'`
198
265
  const idx = srcText.indexOf(marker)
199
266
  if (idx === -1) throw new Error('main agent persona block (`- id: persona` / @deepseek-ai/dsh-persona) not found')
200
- const personaStart = srcText.indexOf('text: |-', idx)
201
- if (personaStart === -1) throw new Error('main agent persona block: `text: |-` scalar not found')
202
- const dashStart = personaStart + 'text: '.length // points at the `|` of `|-`
267
+ // Persona config scalar: `prefix: |-` per the current dsh-persona schema
268
+ // (required `prefix`); `text: |-` is the legacy pre-rename name, still
269
+ // located so old files compose unchanged.
270
+ const prefixStart = srcText.indexOf('prefix: |-', idx)
271
+ const textStart = prefixStart === -1 ? srcText.indexOf('text: |-', idx) : -1
272
+ if (prefixStart === -1 && textStart === -1) {
273
+ throw new Error('main agent persona block: `prefix: |-` (or legacy `text: |-`) scalar not found')
274
+ }
275
+ const keyStart = prefixStart !== -1 ? prefixStart : textStart
276
+ const keyLen = prefixStart !== -1 ? 'prefix: '.length : 'text: '.length
277
+ const dashStart = keyStart + keyLen // points at the `|` of `|-`
203
278
  const contentStart = dashStart + '|-'.length + 1
204
279
  const firstLineEnd = srcText.indexOf('\n', contentStart)
205
280
  const firstLine = srcText.slice(contentStart, firstLineEnd === -1 ? srcText.length : firstLineEnd)
@@ -223,7 +298,9 @@ export function insertCustomRoles(srcText, customBlock) {
223
298
  const marker = '# ── remaining model-facing rows'
224
299
  const idx = srcText.indexOf(marker)
225
300
  if (idx === -1) throw new Error('remaining model-facing rows marker not found')
226
- return srcText.slice(0, idx) + customBlock + srcText.slice(idx)
301
+ // 自定义块与 marker 注释之间补一个空行,与全文「块间空行分隔」的风格一致
302
+ // (customBlock 自带行尾换行;marker 前原有的空行留在 customBlock 之前)。
303
+ return srcText.slice(0, idx) + customBlock + '\n' + srcText.slice(idx)
227
304
  }
228
305
 
229
306
  /**
@@ -280,22 +357,17 @@ export function delegationRowSpan(srcText, role) {
280
357
  }
281
358
 
282
359
  /**
283
- * 角色专用模型注入(roles.<toolName>.model / .provider 生效路径):在 role(内置
284
- * 角色名,下划线)的既有委派块内、`toolName:` 行行尾插入 agentOptions 子块,返回
285
- * 零宽 span edit { start, end, text }(start = end = toolName 行行尾换行符下标;
286
- * 原换行符保留在 text 之后,故 text 不带尾换行)。子块键序固定 provider 在前、
287
- * model 在后(与 dsh-tool-subagent Config 字段序一致),provider 缺省时不出该行。
288
- * 块定位:`- id: delegation-<rowId>`(rowId = 角色名 `_` 换 `-`,找不到 throw);
360
+ * 内置角色委派块边界定位(roleToolNameLineEnd / injectRoleBackgroundMode 共用):
361
+ * 块定位 `- id: delegation-<rowId>`(rowId = 角色名 `_` 换 `-`,找不到 throw);
289
362
  * 块结束 = min(其后首个 `\n - id: delegation-` 下标, '# ── remaining
290
- * model-facing rows' 注释下标, srcText.length),保证 toolName 搜索不越界命中
291
- * 别的块。返回坐标为源文本原坐标,与 allow/persona/删除 edits 一起按 start 降序
292
- * 应用。
363
+ * model-facing rows' 注释下标, srcText.length),保证块内行搜索不越界命中
364
+ * 别的块。errorKey 用于把报错归因到具体配置键(model / background_mode)。
293
365
  */
294
- export function injectRoleAgentOptions(srcText, role, model, provider) {
366
+ function roleDelegationBlockBounds(srcText, role, errorKey) {
295
367
  const rowId = role.replace(/_/g, '-') // 委派行 id 用连字符(toolName 用下划线)
296
368
  const marker = `- id: delegation-${rowId}`
297
369
  const idx = srcText.indexOf(marker)
298
- if (idx === -1) throw new Error(`委派行缺失(roles.${role}.model 引用了 '${role}',但源文本里找不到 "${marker}")`)
370
+ if (idx === -1) throw new Error(`委派行缺失(roles.${role}.${errorKey} 引用了 '${role}',但源文本里找不到 "${marker}")`)
299
371
  const nextIdx = srcText.indexOf('\n - id: delegation-', idx + marker.length)
300
372
  const tailIdx = srcText.indexOf('# ── remaining model-facing rows')
301
373
  // 块结束取三者最小(无下一条/无尾注释时以 srcText.length 兜底)。
@@ -304,13 +376,41 @@ export function injectRoleAgentOptions(srcText, role, model, provider) {
304
376
  tailIdx === -1 ? srcText.length : tailIdx,
305
377
  srcText.length,
306
378
  )
379
+ return { idx, blockEnd }
380
+ }
381
+
382
+ /**
383
+ * 定位内置角色委派块内 8 空格缩进的 `toolName:` 行行尾换行符下标
384
+ * (injectRoleAgentOptions / injectRoleBackgroundMode 兜底注入共用的插入点):
385
+ * 块边界见 roleDelegationBlockBounds,toolName 搜索限定块内不越界命中别的块。
386
+ * errorKey 用于把报错归因到具体配置键(model / background_mode)。
387
+ */
388
+ function roleToolNameLineEnd(srcText, role, errorKey) {
389
+ const { idx, blockEnd } = roleDelegationBlockBounds(srcText, role, errorKey)
307
390
  const toolNameMarker = '\n toolName: ' // 8 空格缩进 = config 键层
308
391
  const toolNameIdx = srcText.indexOf(toolNameMarker, idx)
309
392
  if (toolNameIdx === -1 || toolNameIdx >= blockEnd) {
310
- throw new Error(`委派块缺 toolName 行(roles.${role}.model:"${marker}" 块内找不到 8 空格缩进的 "toolName: " 行)`)
393
+ throw new Error(`委派块缺 toolName 行(roles.${role}.${errorKey}:"${marker}" 块内找不到 8 空格缩进的 "toolName: " 行)`)
311
394
  }
312
395
  // 插入点 = toolName 行行尾换行符的下标;零宽插入后该换行符仍在 text 之后。
313
396
  const lineEnd = srcText.indexOf('\n', toolNameIdx + 1)
397
+ if (lineEnd === -1) {
398
+ // 与其余 marker 缺失同款哨兵:不静默错位(-1 会把注入行插到文首)。
399
+ throw new Error(`委派块 toolName 行缺换行符(roles.${role}.${errorKey}:"${marker}" 块的 toolName 行是最后一行且无换行,无法定位插入点)`)
400
+ }
401
+ return lineEnd
402
+ }
403
+
404
+ /**
405
+ * 角色专用模型注入(roles.<toolName>.model / .provider 生效路径):在 role(内置
406
+ * 角色名,下划线)的既有委派块内、`toolName:` 行行尾插入 agentOptions 子块,返回
407
+ * 零宽 span edit { start, end, text }(start = end = toolName 行行尾换行符下标;
408
+ * 原换行符保留在 text 之后,故 text 不带尾换行)。子块键序固定 provider 在前、
409
+ * model 在后(与 dsh-tool-subagent Config 字段序一致),provider 缺省时不出该行。
410
+ * 返回坐标为源文本原坐标,与 allow/persona/删除 edits 一起按 start 降序应用。
411
+ */
412
+ export function injectRoleAgentOptions(srcText, role, model, provider) {
413
+ const lineEnd = roleToolNameLineEnd(srcText, role, 'model')
314
414
  const text =
315
415
  '\n' +
316
416
  [
@@ -322,30 +422,81 @@ export function injectRoleAgentOptions(srcText, role, model, provider) {
322
422
  }
323
423
 
324
424
  /**
325
- * 主 persona 整块改造(改名 + 删委派 bullet + 自定义角色自动委派行),返回一个
425
+ * 角色可续模式改写/兜底注入(roles.<toolName>.background_mode = 'continuable'
426
+ * 生效路径)。源模板的四个内置角色委派块各预置一行 `backgroundMode: one-shot`
427
+ * (产品口径:旋钮在源里可见,值 = dsh-tool-subagent 缺省),本函数语义因此是
428
+ * 「改写或兜底注入」,块内 span 查找既有行,杜绝任何情况下同块出现两行
429
+ * backgroundMode:
430
+ * - 块内已有该行(源模板预置 / 手工加过):整行原位改写为规范的
431
+ * `backgroundMode: continuable`(位置不动 → 键序 toolName → agentOptions? →
432
+ * backgroundMode 天然成立);该行值已是 continuable 时返回 null(无改动,
433
+ * 与其它「无差异不产 edit」口径一致)。
434
+ * - 块内无该行(防手工删掉的老配置/异常源):兜底在 `toolName:` 行行尾零宽
435
+ * 注入一行(与 injectRoleAgentOptions 同锚点,两者并存靠收集序定键序 ——
436
+ * edits 按 start 降序稳定排序,同 start 保持收集序,composeGenerated 先收集
437
+ * 本 edit 再收集 agentOptions edit,先收集者居后)。
438
+ * 返回 span edit { start, end, text }(坐标为源文本原坐标,与 allow/persona/
439
+ * 删除 edits 一起按 start 降序应用;改写路径 start/end = 既有行行首/行尾,
440
+ * 兜底路径 start = end = toolName 行行尾换行符下标),无任何改动时返回 null。
441
+ * 调用方(composeGenerated)仅对 'continuable' 调本函数;'one-shot'/缺省不调
442
+ * 用 —— 源预置行原样保留,全默认配置的生成产物与源逐字节一致。手工构造的
443
+ * assignments 绕过规范化时按同口径兜底(非 continuable 即不调用)。
444
+ */
445
+ export function injectRoleBackgroundMode(srcText, role) {
446
+ const { idx, blockEnd } = roleDelegationBlockBounds(srcText, role, 'background_mode')
447
+ // 块内 span 查既有行:8 空格缩进(config 键层,与 roleToolNameLineEnd 的
448
+ // toolName 定位同口径),行首带 \n 保证不误匹配块首部分行或 persona 内容行。
449
+ const bgMarker = '\n backgroundMode:'
450
+ const bgIdx = srcText.indexOf(bgMarker, idx)
451
+ if (bgIdx !== -1 && bgIdx < blockEnd) {
452
+ const lineStart = bgIdx + 1 // 跳过行首 \n,定位到行首
453
+ const nextNl = srcText.indexOf('\n', lineStart)
454
+ const lineEnd = nextNl === -1 ? srcText.length : nextNl
455
+ const line = srcText.slice(lineStart, lineEnd)
456
+ const value = line.slice(line.indexOf(':') + 1).trim()
457
+ if (value === 'continuable') return null // 已是目标值:不动(保持零 diff)
458
+ // 整行原位改写(缩进一并规范成 8 空格),不新增行 → 不可能重复键。
459
+ return { start: lineStart, end: lineEnd, text: ' backgroundMode: continuable' }
460
+ }
461
+ // 兜底:块内无该行(手工删掉的老配置 / 异常源)→ 沿用 toolName 行行尾注入。
462
+ const lineEnd = roleToolNameLineEnd(srcText, role, 'background_mode')
463
+ return { start: lineEnd, end: lineEnd, text: '\n backgroundMode: continuable' }
464
+ }
465
+
466
+ /**
467
+ * 主 persona 整块改造(删委派 bullet + 自定义角色自动委派行),返回一个
326
468
  * span edit { start, end, text }(start/end 为源文本原坐标、随其它 edits 降序应用),
327
469
  * 无任何改动时返回 null(产物零 diff)。结构扫描与 appendToMainPersona 相同:
328
- * `- id: persona` 行 → `text: |-` → 首内容行定缩进 → 浅缩进行收尾。
329
- * - 改名:main_agent_name 非空时,把身份行 label 换成它(正则不中则 warn 不崩);
470
+ * `- id: persona` 行 → `prefix: |-`(旧名 `text: |-` 兼容)→ 首内容行定缩进 → 浅缩进行收尾。
330
471
  * - 删 bullet:对每个 removedRoles 角色,删掉 content 中 trimmed 匹配
331
472
  * `^- .*delegate to <role>\.?$` 的行(限定行首 `- `,不误删 SOP 段落);
332
- * - 自定义角色自动委派行:customBullets(第 4 参,元素 { toolName, duty })逐个
473
+ * - 自定义角色自动委派行:customBullets(第 3 参,元素 { toolName, duty })逐个
333
474
  * 渲染成 `- <duty>: delegate to <toolName>.`(duty 去掉尾部 `。`/`.`/`!`/`!`
334
475
  * 与空白),插到 body 中最后一个匹配 `^- .*delegate to \S+\.?$` 的行之后;
335
476
  * 一条都没有时退到匹配 `^Decompose the task` 的行之后;仍没有则退到首行之后。
336
477
  * 其余文本与空行结构保持不变——没有这行指引,主 agent persona 就不含自定义
337
478
  * 角色,主 agent 永远不会委派给它。
479
+ * 身份行(You are the …)不可改名:编排主 agent 的 persona 身份语义不属于
480
+ * 可配置项(preset 显示名归 main_agent_display_name,见 state.mjs)。
338
481
  * 返回值经 renderPersona 以块标量整体回填;技能行/extra 追加在 edits 应用后由
339
482
  * appendToMainPersona 基于已改造文本追加,顺序天然正确。
340
483
  */
341
- export function composeMainPersonaEdit(srcText, mainAgentName, removedRoles, customBullets = []) {
342
- if (!mainAgentName && removedRoles.length === 0 && customBullets.length === 0) return null
484
+ export function composeMainPersonaEdit(srcText, removedRoles, customBullets = []) {
485
+ if (removedRoles.length === 0 && customBullets.length === 0) return null
343
486
  const marker = `- id: persona\n name: '@deepseek-ai/dsh-persona'`
344
487
  const idx = srcText.indexOf(marker)
345
488
  if (idx === -1) throw new Error('main agent persona block (`- id: persona` / @deepseek-ai/dsh-persona) not found')
346
- const personaStart = srcText.indexOf('text: |-', idx)
347
- if (personaStart === -1) throw new Error('main agent persona block: `text: |-` scalar not found')
348
- const dashStart = personaStart + 'text: '.length // points at the `|` of `|-`
489
+ // Persona config scalar: `prefix: |-` per the current dsh-persona schema
490
+ // (required `prefix`); `text: |-` is the legacy pre-rename name, still
491
+ // located so old files compose unchanged.
492
+ const prefixStart = srcText.indexOf('prefix: |-', idx)
493
+ const textStart = prefixStart === -1 ? srcText.indexOf('text: |-', idx) : -1
494
+ if (prefixStart === -1 && textStart === -1) {
495
+ throw new Error('main agent persona block: `prefix: |-` (or legacy `text: |-`) scalar not found')
496
+ }
497
+ const keyStart = prefixStart !== -1 ? prefixStart : textStart
498
+ const keyLen = prefixStart !== -1 ? 'prefix: '.length : 'text: '.length
499
+ const dashStart = keyStart + keyLen // points at the `|` of `|-`
349
500
  const contentStart = dashStart + '|-'.length + 1
350
501
  const firstLineEnd = srcText.indexOf('\n', contentStart)
351
502
  const firstLine = srcText.slice(contentStart, firstLineEnd === -1 ? srcText.length : firstLineEnd)
@@ -361,17 +512,6 @@ export function composeMainPersonaEdit(srcText, mainAgentName, removedRoles, cus
361
512
  }
362
513
  if (end === contentStart) throw new Error('main agent persona block: no content lines found')
363
514
  let body = personaDefaultText(srcText.slice(dashStart, end))
364
- if (mainAgentName) {
365
- // 身份行 label 替换:仅匹配行首的 "You are the … agent powered by the {{model}} model."
366
- const identityRe = /^You are the [^\n]*?agent powered by the \{\{model\}\} model\./
367
- const match = body.match(identityRe)
368
- if (!match) {
369
- warn('main_agent_name: 主 persona 身份行未匹配,agent.cordis.yml 保留默认身份行')
370
- } else {
371
- const renamed = `You are the ${mainAgentName} agent powered by the {{model}} model.`
372
- body = body.slice(0, match.index) + renamed + body.slice(match.index + match[0].length)
373
- }
374
- }
375
515
  if (removedRoles.length > 0) {
376
516
  // 删委派 bullet:行首 `- ` 且含 `delegate to <role>`;SOP 段落等不命中。
377
517
  const dropRe = removedRoles.map((role) => new RegExp(`^- .*delegate to ${role}\\.?$`))
@@ -407,9 +547,10 @@ export function composeMainPersonaEdit(srcText, mainAgentName, removedRoles, cus
407
547
  return { start: dashStart, end, text: renderPersona(body, indent) }
408
548
  }
409
549
 
410
- /** Keep only host-dependent names that were actually detected. */
411
- export function filterHostTools(tools, inventory) {
412
- return tools.filter((name) => !isHostDependent(name) || inventory.has(name))
550
+ /** Keep only names that fall inside the preset universe or the detection
551
+ * inventory (see usableWith); everything else is dropped from the allow list. */
552
+ export function filterUsableTools(tools, presetUniverse, inventory) {
553
+ return tools.filter((name) => usableWith(name, presetUniverse, inventory))
413
554
  }
414
555
 
415
556
  /**
@@ -422,16 +563,25 @@ export function composeGenerated(srcText, blocks, personaBlocks, assignments, in
422
563
  const skillsMap = assignments.skills ?? {}
423
564
  const customToolNames = Object.keys(assignments.roles).filter((n) => !ROLES.includes(n))
424
565
  // roles_remove:被删除的内置角色不产 allow/persona edits(委派行整条删除时避免重叠),
425
- // 主 persona bullet 与 restrict allow 也据此裁剪。main_agent_name 两处共用归一化值。
566
+ // 主 persona bullet 与 restrict allow 也据此裁剪。persona 身份行不可改名
567
+ // (preset 显示名归 main_agent_display_name,见 state.mjs)。
426
568
  const removedRoles = ROLES.filter((role) => (assignments.roles_remove ?? []).includes(role))
427
- const mainAgentName = normalizeMainAgentName(assignments.main_agent_name)
569
+
570
+ // presetUniverse = restrict.mjs 主 agent 白名单 ∪ 源 preset 各角色 allow 自带面。
571
+ // 生成期「名字可用」白名单的前半边(后半边是检测清单 inventory):手写进配置、
572
+ // 既不在 preset 自带面也不在检测库存的名字(停用插件残留的 mnemon_*、错名等)
573
+ // 不再直通,一律从 allow 丢弃——与文档「配置意图 ∩ 实际启用工具」口径对齐。
574
+ const presetUniverse = new Set(restrictBase)
575
+ for (const block of blocks.values()) {
576
+ for (const name of block.names) presetUniverse.add(name)
577
+ }
428
578
 
429
579
  // Role allow results (default roles only; custom roles are rendered fresh).
430
580
  const roleResults = []
431
581
  for (const role of ROLES) {
432
582
  if (removedRoles.includes(role)) continue // 已删除角色:不产 roleResults / allow edits
433
583
  const intent = assignments.roles[role]?.tools ?? blocks.get(role).names
434
- const filtered = filterHostTools(intent, inventory)
584
+ const filtered = filterUsableTools(intent, presetUniverse, inventory)
435
585
  if (filtered.length === 0) {
436
586
  throw new Error(`role "${role}" would end up with an empty toolFilter.allow — refusing to install a zero-tool role.`)
437
587
  }
@@ -456,7 +606,10 @@ export function composeGenerated(srcText, blocks, personaBlocks, assignments, in
456
606
  // persona delegate bullet / skills 分配键一律不受影响(不产对应 edit)。
457
607
  for (const role of ROLES) {
458
608
  if (removedRoles.includes(role)) continue // 已删除角色:persona 随委派行整条删除
459
- const configured = assignments.roles[role]?.persona ?? null
609
+ // 空串与 null 同语义(= 走默认 persona):?? 口径会把 '' 当「已自定义」
610
+ // 而生成空 persona 块,统一成 || 判定(与 renderCustomRoleBlocks 的
611
+ // `persona ? ... : 默认` 口径一致)。
612
+ const configured = assignments.roles[role]?.persona || null
460
613
  const displayNameRaw = assignments.roles[role]?.name ?? null
461
614
  // 空串/null 视为无;字符串取 trim 值(与 config 端 normalizeRoleName 同语义,
462
615
  // 防 UI / 手工构造的 assignments 绕过规范化)。
@@ -505,7 +658,7 @@ export function composeGenerated(srcText, blocks, personaBlocks, assignments, in
505
658
  const span = delegationRowSpan(srcText, role)
506
659
  edits.push({ start: span.start, end: span.end, text: '' })
507
660
  }
508
- // 主 persona 改造(改名 + 删委派 bullet + 自定义角色自动委派行):无任何改动时
661
+ // 主 persona 改造(删委派 bullet + 自定义角色自动委派行):无任何改动时
509
662
  // 不产 edit(产物零 diff)。自定义角色取 persona 首行职责句生成 delegate 行
510
663
  // (firstLine 对空 persona 返回占位「自定义角色」,中文占位可直接用)——没有这
511
664
  // 行指引,主 agent persona 就不含该角色,永远不会委派给它。
@@ -513,30 +666,42 @@ export function composeGenerated(srcText, blocks, personaBlocks, assignments, in
513
666
  toolName: n,
514
667
  duty: firstLine(assignments.roles[n]?.persona || ''),
515
668
  }))
516
- const personaEdit = composeMainPersonaEdit(srcText, mainAgentName, removedRoles, customBullets)
669
+ const personaEdit = composeMainPersonaEdit(srcText, removedRoles, customBullets)
517
670
  if (personaEdit !== null) edits.push(personaEdit)
518
671
 
519
- // 角色专用模型注入(roles.<toolName>.model / .provider → 委派块 agentOptions):
520
- // 与 roles_remove 委派行删除 edits 同段收集。被删角色不注入(委派行已整条删除,
521
- // 模型配置 warn 忽略);仅 model 非 null 才注入,provider-alone 仅 warn 忽略
522
- // (先 normalize 再判断,防手工构造的 assignments 绕过规范化)。
672
+ // 角色专用模型 / 可续模式注入(roles.<toolName>.model / .provider → 委派块
673
+ // agentOptions;roles.<toolName>.background_mode = 'continuable' → 委派块
674
+ // backgroundMode 行改写/兜底注入):与 roles_remove 委派行删除 edits 同段收集。
675
+ // 被删角色不注入(委派行已整条删除,模型 / background_mode 配置 warn 忽略);
676
+ // 仅 model 非 null 才注入 agentOptions,provider-alone 仅 warn 忽略(先 normalize
677
+ // 再判断,防手工构造的 assignments 绕过规范化);background_mode 仅 'continuable'
678
+ // 才调 injectRoleBackgroundMode('one-shot'/缺省不调 —— 源模板四块各预置的
679
+ // `backgroundMode: one-shot` 原样保留,全默认配置生成产物与源逐字节一致)。
680
+ // 键序与收集序:源块已有该行时走改写路径(edit start = 既有行行首,位于
681
+ // toolName 行行尾之后 → 降序应用先改写该行,agentOptions 随后插在更前的
682
+ // toolName 行行尾,键序 toolName → agentOptions? → backgroundMode 自然成立);
683
+ // 源块无该行时走兜底注入路径,与 agentOptions 同锚点(start 相等)→ backgroundMode
684
+ // edit 必须先于 agentOptions edit 收集(先收集者居后),与自定义块键序一致。
523
685
  for (const role of ROLES) {
524
686
  const rawModel = assignments.roles[role]?.model ?? null
525
687
  const rawProvider = assignments.roles[role]?.provider ?? null
688
+ const rawBackgroundMode = assignments.roles[role]?.background_mode ?? null
526
689
  if (removedRoles.includes(role)) {
527
690
  const model = normalizeModelRef(rawModel, `roles.${role}.model`)
528
691
  const provider = normalizeModelRef(rawProvider, `roles.${role}.provider`)
529
692
  if (model !== null || provider !== null) {
530
693
  warn(`roles.${role}.model: 该角色已在 roles_remove 中删除,模型配置被忽略`)
531
694
  }
695
+ if (rawBackgroundMode === 'continuable') {
696
+ warn(`roles.${role}.background_mode: 该角色已在 roles_remove 中删除,background_mode 配置被忽略`)
697
+ }
532
698
  continue
533
699
  }
534
- const model = normalizeModelRef(rawModel, `roles.${role}.model`)
535
- let provider = normalizeModelRef(rawProvider, `roles.${role}.provider`)
536
- if (model === null && provider !== null) {
537
- warn(`roles.${role}.provider: '${provider}' 仅在同时配置 model 时生效,已忽略`)
538
- provider = null
700
+ if (rawBackgroundMode === 'continuable') {
701
+ const backgroundModeEdit = injectRoleBackgroundMode(srcText, role)
702
+ if (backgroundModeEdit !== null) edits.push(backgroundModeEdit)
539
703
  }
704
+ const { model, provider } = normalizeRoleModel(role, rawModel, rawProvider)
540
705
  if (model !== null) edits.push(injectRoleAgentOptions(srcText, role, model, provider))
541
706
  }
542
707
  // 注入 edit(零宽,位于 toolName 行行尾)与该块的 allow edit / persona edit
@@ -548,7 +713,7 @@ export function composeGenerated(srcText, blocks, personaBlocks, assignments, in
548
713
  edits.sort((a, b) => b.start - a.start)
549
714
  for (const edit of edits) out = out.slice(0, edit.start) + edit.text + out.slice(edit.end)
550
715
 
551
- const customBlock = renderCustomRoleBlocks(assignments.roles, skillsMap)
716
+ const customBlock = renderCustomRoleBlocks(assignments.roles, skillsMap, restrictBase)
552
717
  if (customBlock !== '') out = insertCustomRoles(out, customBlock)
553
718
 
554
719
  const mainAgentRemove = assignments.main_agent_remove ?? []
@@ -556,8 +721,8 @@ export function composeGenerated(srcText, blocks, personaBlocks, assignments, in
556
721
  // (undetected host tools, typos, names never present) and skip them.
557
722
  if (mainAgentRemove.length > 0) {
558
723
  const removable = new Set(
559
- [...restrictBase, ...(assignments.main_agent_extra ?? []), ...customToolNames].filter(
560
- (n) => !isHostDependent(n) || inventory.has(n),
724
+ [...restrictBase, ...(assignments.main_agent_extra ?? []), ...customToolNames].filter((n) =>
725
+ usableWith(n, presetUniverse, inventory),
561
726
  ),
562
727
  )
563
728
  for (const name of mainAgentRemove) {
@@ -566,6 +731,7 @@ export function composeGenerated(srcText, blocks, personaBlocks, assignments, in
566
731
  }
567
732
  const restrictAllow = computeRestrictAllow(
568
733
  restrictBase,
734
+ presetUniverse,
569
735
  inventory,
570
736
  assignments.main_agent_extra ?? [],
571
737
  mainAgentRemove,