dsh-paoding 0.2.2 → 0.3.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.
@@ -1,30 +1,41 @@
1
1
  /**
2
- * dsh-paoding.config.yml 键规范化与读写:main_agent_name / roles.<toolName>.name /
2
+ * dsh-paoding.config.yml 键规范化与读写:main_agent_display_name /
3
+ * roles.<toolName>.name /
3
4
  * roles.<toolName>.model / roles.<toolName>.provider(角色专用模型,生成层映射到
4
- * dsh-tool-subagent 的 agentOptions)/ roles_remove 解析(normalizeRoleName(兼容
5
- * 别名 normalizeMainAgentName)/ normalizeModelRef / parseRolesRemove / yamlScalar)、
5
+ * dsh-tool-subagent 的 agentOptions)/ roles.<toolName>.background_mode(可续模式,
6
+ * 生成层映射到 dsh-tool-subagent 的 backgroundMode,'one-shot' | 'continuable')/
7
+ * roles_remove 解析(normalizeRoleName /
8
+ * normalizeModelRef / normalizeBackgroundMode / parseRolesRemove / yamlScalar)、
9
+ * 目标字段集合的规范化与写出(normalizeTargetFields / writeTargetFields —— 顶层与
10
+ * workspaces.<path> 条目共用同一套)、按工作区条目段(normalizeWorkspaces)、
6
11
  * 文件加载与规范化(loadConfig / normalizeConfig)、序列化与落盘(serializeConfig /
7
12
  * saveConfig)。
8
13
  * 依赖 util(ROLES / warn)与 yaml(preprocessPatchText / parseYamlSubset);
9
- * 被 compose / wizard / state / cli 引用。
14
+ * 被 compose / state / cli / alloc 引用。
10
15
  */
11
16
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
12
17
  import path from 'node:path'
13
18
  import { ROLES, warn } from './util.mjs'
14
19
  import { parseYamlSubset, preprocessPatchText } from './yaml.mjs'
15
20
 
16
- // ── config keys: main_agent_name / roles.<toolName>.name / roles_remove ─────
21
+ // ── config keys: main_agent_display_name / roles.<toolName>.name / roles_remove ─────
22
+
23
+ // main_agent_name 迁移提示的每进程去重标记:normalizeConfig 在一次进程里会被
24
+ // 调多次(collectState 与 generateAndInstall 各 loadConfig 一遍,测试里更多),
25
+ // 裸 warn 会同文案刷屏;标记置位后同一进程不再重复提醒。
26
+ let mainAgentNameMigrationWarned = false
17
27
 
18
28
  /**
19
- * 校验并规范化一个显示名(配置键 main_agent_name 或 roles.<toolName>.name,
20
- * label 形参用于报错文案,缺省 'main_agent_name' 时行为与文案和原实现逐字
21
- * 一致)。null/undefined/非 string/空串 → null(= 维持默认,不改名);否则存
22
- * trim 后值,trim 后 1-60 字符且原值不得含 \r \n(违规直接 throw —— 配置错了
23
- * 宁可报错,也不要静默失效)。
24
- * main_agent_name:persona 身份行与 preset.yml 的 name: 行两处共用同一个值;
29
+ * 校验并规范化一个显示名(配置键 main_agent_display_name 或
30
+ * roles.<toolName>.name,label 形参用于报错文案;现存调用点都显式传 label,
31
+ * 缺省 'name' 仅兜底)。null/undefined/非 string/空串 → null(= 维持默认,
32
+ * 不改名);否则存 trim 后值,trim 后 1-60 字符且原值不得含 \r \n(违规直接
33
+ * throw —— 配置错了宁可报错,也不要静默失效)。
34
+ * main_agent_display_name:仅 preset.yml 的 name: 行(显示名),不动 persona
35
+ * 身份行,报错 label 用键名本身;
25
36
  * roles.<toolName>.name:仅作用于该内置角色 persona 走默认时的首行身份句主语。
26
37
  */
27
- export function normalizeRoleName(value, label = 'main_agent_name') {
38
+ export function normalizeRoleName(value, label = 'name') {
28
39
  if (value === null || value === undefined || typeof value !== 'string') return null
29
40
  const trimmed = value.trim()
30
41
  if (trimmed === '') return null
@@ -37,11 +48,6 @@ export function normalizeRoleName(value, label = 'main_agent_name') {
37
48
  return trimmed
38
49
  }
39
50
 
40
- /** 兼容别名:主 agent 显示名校验(main_agent_name),行为与文案逐字不变。 */
41
- export function normalizeMainAgentName(value) {
42
- return normalizeRoleName(value)
43
- }
44
-
45
51
  /**
46
52
  * 校验并规范化一个模型/路由引用(配置键 roles.<toolName>.model 或 .provider,
47
53
  * label 形参用于报错文案)。行为镜像 normalizeRoleName,仅长度上限不同(120):
@@ -63,6 +69,23 @@ export function normalizeModelRef(value, label = 'model') {
63
69
  return trimmed
64
70
  }
65
71
 
72
+ /**
73
+ * 校验并规范化 roles.<toolName>.background_mode(角色可续模式,映射到
74
+ * dsh-tool-subagent 实例配置键 backgroundMode)。合法值仅 'one-shot' | 'continuable':
75
+ * 缺省/null → 缺省值 'one-shot'(静默,与 name/model/provider 的 null 口径一致);
76
+ * 非字符串或字符串但不在合法值集 → warn 后回落 'one-shot'(类型/取值不对降级提醒
77
+ * 而非中断加载,与 normalizeTargetFields 的 name/model/provider 非字符串 warn 同款)。
78
+ * 返回恒为合法值之一,生成层(compose)据此决定是否注入 backgroundMode 行。
79
+ */
80
+ export function normalizeBackgroundMode(value, label = 'background_mode') {
81
+ if (value === null || value === undefined) return 'one-shot'
82
+ if (typeof value !== 'string' || (value !== 'one-shot' && value !== 'continuable')) {
83
+ warn(`${label}: 非法值已忽略(${JSON.stringify(value)}),回落 'one-shot'(可选: 'one-shot' / 'continuable')`)
84
+ return 'one-shot'
85
+ }
86
+ return value
87
+ }
88
+
66
89
  /**
67
90
  * 解析配置键 roles_remove:string[],只收 ROLES 内置角色名(search_external /
68
91
  * design / implement);含其他名字 warn 并忽略;去重保序。非数组 → []。
@@ -85,8 +108,9 @@ export function parseRolesRemove(raw) {
85
108
  return out
86
109
  }
87
110
 
88
- /** 简单 YAML 标量渲染:含空格/CJK 无需引号;含 ':' / '#'、首尾空白、纯数字等
89
- * 歧义形态时用单引号包裹并转义内部单引号。 */
111
+ /** 简单 YAML 标量渲染:含空格/CJK 无需引号;含 ':' / '#'、首/尾空白、纯数字、
112
+ * bool/null 字面量、或首字符为 YAML 指示符(- ? : , [ ] { } # & * ! | > ' " %
113
+ * @ `)等歧义形态时用单引号包裹并转义内部单引号。 */
90
114
  export function yamlScalar(value) {
91
115
  const s = String(value)
92
116
  const needsQuote =
@@ -95,27 +119,42 @@ export function yamlScalar(value) {
95
119
  /:(\s|$)/.test(s) || // ':'(键值分隔风险)
96
120
  /(^|\s)#/.test(s) || // '#'(行内注释风险)
97
121
  /^[-+]?(\d+\.?\d*|\.\d+)$/.test(s) || // 纯数字形态
98
- /^(true|false|null|~)$/i.test(s) // bool/null 字面量形态
122
+ /^(true|false|null|~)$/i.test(s) || // bool/null 字面量形态
123
+ /^[-?:,\[\]{}#&*!|>'"%@`]/.test(s) // 首字符 YAML 指示符(块/流集合、锚点、别名、标签、引号、保留符)
99
124
  return needsQuote ? `'${s.replace(/'/g, "''")}'` : s
100
125
  }
101
126
 
127
+ /** 流序列(`skills: [a, b]`)条目专用渲染:流上下文里 ',' '[' ']' '{' '}' 是
128
+ * 定界符,裸形态含它们会把序列截断成坏 YAML(块上下文合法 ≠ 流内合法)。
129
+ * yamlScalar 已加引号的原样返回(防双重包裹),裸形态补查流定界符。 */
130
+ function yamlFlowScalar(value) {
131
+ const raw = String(value)
132
+ const s = yamlScalar(raw)
133
+ return s === raw && /[,\[\]{}]/.test(raw) ? `'${raw.replace(/'/g, "''")}'` : s
134
+ }
135
+
102
136
  // ── config file (dsh-paoding.config.yml) ────────────────────────────────────
103
137
 
104
138
  /**
105
139
  * Load the config file when present. Returns null when the file does not
106
140
  * exist; throws when it exists but cannot be parsed. Shape:
107
- * { profile, roles: { <toolName>: { name?, model?, provider?, persona?, tools[] } },
141
+ * { profile, roles: { <toolName>: { name?, model?, provider?, background_mode?, persona?, tools[] } },
108
142
  * roles_remove[] (内置角色删除), main_agent_extra[], main_agent_remove[],
109
143
  * main_agent_skills[], main_agent_skills_inline[],
110
- * main_agent_persona_extra? (string|null), main_agent_name? (string|null),
111
- * skills: { <skill>: [<toolName>...] } }
144
+ * main_agent_persona_extra? (string|null),
145
+ * main_agent_display_name? (string|null),
146
+ * skills: { <skill>: [<toolName>...] },
147
+ * workspaces: { <工作区绝对路径>: <目标字段集合(同顶层、除 profile)> } }
112
148
  */
113
149
  export function loadConfig(file, yamlMod) {
114
150
  let text
115
151
  try {
116
152
  text = readFileSync(file, 'utf8')
117
- } catch {
118
- return null
153
+ } catch (err) {
154
+ // 仅「文件不存在」视为无配置(返回 null);其余读错误(权限 / EIO / 是目录等)
155
+ // 原样上抛 —— 静默吞掉会让调用方把损坏状态当「fresh」继续破坏性落盘。
156
+ if (err.code === 'ENOENT') return null
157
+ throw err
119
158
  }
120
159
  const processed = preprocessPatchText(text)
121
160
  let data = null
@@ -140,25 +179,95 @@ export function loadConfig(file, yamlMod) {
140
179
  }
141
180
 
142
181
  export function normalizeConfig(data) {
182
+ // 迁移提示(每进程至多一次,见下方模块级标记):老配置残留的 main_agent_name
183
+ // 键已随「主 agent 改名」特性整体下线 —— 该键曾一改两处(preset 显示名 +
184
+ // persona 身份行),改名会替换编排主 agent 的身份语义,故不再读取也不再返回;
185
+ // 残留行在下次「保存并应用」落盘时自然消失。
186
+ if (!mainAgentNameMigrationWarned && typeof data?.main_agent_name === 'string' && data.main_agent_name.trim() !== '') {
187
+ mainAgentNameMigrationWarned = true
188
+ warn('main_agent_name 已移除:编排主 agent 身份不再支持改名,该键已忽略')
189
+ }
190
+ return {
191
+ profile: typeof data.profile === 'string' && data.profile !== '' ? data.profile : null,
192
+ ...normalizeTargetFields(data),
193
+ workspaces: normalizeWorkspaces(data.workspaces),
194
+ }
195
+ }
196
+
197
+ /**
198
+ * 工作区条目段(顶层键 workspaces)规范化:只收 trim 后非空的字符串键(键 =
199
+ * 工作区目录绝对路径),值过 normalizeTargetFields;值不是映射、键为空串则
200
+ * warn 并跳过。没有 workspaces 键 / 不是映射 → 空对象(条目缺失不算配置
201
+ * 错误,老配置零感升级)。
202
+ */
203
+ function normalizeWorkspaces(raw) {
204
+ const out = {}
205
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out
206
+ for (const [key, value] of Object.entries(raw)) {
207
+ const wsPath = typeof key === 'string' ? key.trim() : ''
208
+ if (wsPath === '') {
209
+ warn('workspaces: 忽略空路径键')
210
+ continue
211
+ }
212
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
213
+ warn(`workspaces: '${wsPath}' 的条目不是映射,已忽略`)
214
+ continue
215
+ }
216
+ out[wsPath] = normalizeTargetFields(value)
217
+ }
218
+ return out
219
+ }
220
+
221
+ /**
222
+ * 目标字段集合规范化:顶层(除 profile 外)与 workspaces.<path> 条目共用的
223
+ * 同一套键 —— roles(条目含 background_mode)/ roles_remove / main_agent_extra(含 has_main_agent_extra
224
+ * 内部标记)/ main_agent_remove / main_agent_skills / main_agent_skills_inline /
225
+ * main_agent_persona_extra / main_agent_display_name / skills。
226
+ * 语义与抽取前逐字一致;
227
+ * 非映射输入按空对象处理(工作区条目已先过映射校验,这里只为生成层把外部
228
+ * 传入的 assignments 再规范一次时兜底)。
229
+ */
230
+ export function normalizeTargetFields(raw) {
231
+ const data = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
143
232
  const roles = {}
144
233
  const rawRoles = data.roles && typeof data.roles === 'object' && !Array.isArray(data.roles) ? data.roles : {}
145
234
  for (const [toolName, raw] of Object.entries(rawRoles)) {
146
235
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue
147
236
  const tools = Array.isArray(raw.tools) ? raw.tools.filter((t) => typeof t === 'string' && t !== '') : null
148
- // 角色条目字段顺序 { name?, model?, provider?, persona?, tools? }:name
149
- // (roles.<toolName>.name,内置角色可选显示名)与 main_agent_name 同约束,
237
+ // 非字符串(且非缺省/null)的 name/model/provider:warn 后置 null,不 throw
238
+ // —— 静默吞错改为可见提醒,但不中断整体加载(与字符串违规 throw 的口径
239
+ // 并行:格式非法宁报错,类型不对降级提醒)。
240
+ for (const key of ['name', 'model', 'provider']) {
241
+ const v = raw[key]
242
+ if (v !== undefined && v !== null && typeof v !== 'string') {
243
+ warn(`roles.${toolName}.${key}: 非字符串值已忽略(${JSON.stringify(v)})`)
244
+ }
245
+ }
246
+ // 角色条目字段顺序 { name?, model?, provider?, background_mode?, persona?, tools? }:
247
+ // name(roles.<toolName>.name,内置角色可选显示名)与主 agent 显示名同约束,
150
248
  // 仅字符串收(trim 后空/违规由 normalizeRoleName 处理);缺省/null → null
151
249
  // (= 保持该角色默认身份句)。model / provider(roles.<toolName>.model /
152
250
  // .provider,角色专用模型)镜像同款收法,违规由 normalizeModelRef 处理;
153
251
  // 缺省/null → null(= 不注入 agentOptions,子 agent 继承主 agent 当前模型)。
252
+ // background_mode(roles.<toolName>.background_mode,角色可续模式)仅收
253
+ // 'one-shot' | 'continuable',其余 warn 回落 'one-shot'(normalizeBackgroundMode)。
154
254
  roles[toolName] = {
155
255
  name: typeof raw.name === 'string' ? normalizeRoleName(raw.name, `roles.${toolName}.name`) : null,
156
256
  model: typeof raw.model === 'string' ? normalizeModelRef(raw.model, `roles.${toolName}.model`) : null,
157
257
  provider: typeof raw.provider === 'string' ? normalizeModelRef(raw.provider, `roles.${toolName}.provider`) : null,
258
+ background_mode: normalizeBackgroundMode(raw.background_mode, `roles.${toolName}.background_mode`),
158
259
  persona: typeof raw.persona === 'string' && raw.persona.trim() !== '' ? raw.persona : null,
159
260
  tools,
160
261
  }
161
262
  }
263
+ // main_agent_extra 键存在性(fix:写盘时「原配置该键是否存在」语义的依据):
264
+ // 输入带 has_main_agent_extra 标记(已归一化对象再归一化,UI/CLI/生成层二次
265
+ // 规范)时标记优先 —— 此时数据里恒为数组,只看数组会把「键缺失」误判成
266
+ // 「显式存在」,保存时把老配置物化出 `main_agent_extra: []`,读写往返丢语义;
267
+ // 裸输入(手编配置 / 临时 assignments)回落 Array.isArray 判原始键。
268
+ const hasExtra = 'has_main_agent_extra' in data
269
+ ? data.has_main_agent_extra === true
270
+ : Array.isArray(data.main_agent_extra)
162
271
  const extra = Array.isArray(data.main_agent_extra)
163
272
  ? data.main_agent_extra.filter((t) => typeof t === 'string' && t !== '')
164
273
  : []
@@ -182,112 +291,183 @@ export function normalizeConfig(data) {
182
291
  const mainPersonaExtra = typeof data.main_agent_persona_extra === 'string' ? data.main_agent_persona_extra : null
183
292
  // 内置角色删除(roles_remove):只收 ROLES 内置名,其他 warn 忽略、去重。
184
293
  const rolesRemove = parseRolesRemove(data.roles_remove)
185
- // 主 agent 名称(main_agent_name):typeof string 且 trim 后非空才收(存 trim 值);
186
- // 否则 null(= 维持默认)。长度/换行违规由 normalizeMainAgentName throw。
187
- const mainAgentName = typeof data.main_agent_name === 'string' ? normalizeMainAgentName(data.main_agent_name) : null
294
+ // 主 agent 显示名(main_agent_display_name):仅作用于 preset.yml 显示名,
295
+ // 报错 label 用键名本身;typeof string 且 trim 后非空才收,否则 null(= 默认
296
+ // 显示名「编排模式 (Orchestrator)」,工作区预设用派生名)。persona 身份行
297
+ // 不可配置 —— 编排主 agent 的身份语义不属于可改项。
298
+ const mainAgentDisplayName =
299
+ typeof data.main_agent_display_name === 'string'
300
+ ? normalizeRoleName(data.main_agent_display_name, 'main_agent_display_name')
301
+ : null
188
302
  return {
189
- profile: typeof data.profile === 'string' && data.profile !== '' ? data.profile : null,
190
303
  roles,
191
304
  roles_remove: rolesRemove,
192
305
  main_agent_extra: extra,
193
- // 内部标记:键 main_agent_extra 是否在文件里显式存在(含显式空列表)。
194
- // resolveAssignments 据此区分「显式 [] = 尊重空」与「键缺失 = 老配置,回填
195
- // 智能默认」——serializeConfig 对显式空数组落盘 `main_agent_extra: []`,
196
- // 该语义才能在 读写 往返中存活。
197
- has_main_agent_extra: Array.isArray(data.main_agent_extra),
306
+ // 内部标记:键 main_agent_extra 是否显式存在(含显式空列表;再归一化时
307
+ // 保留原值,见上方 hasExtra)。resolveAssignments 据此区分「显式 [] = 尊重
308
+ // 空」与「键缺失 = 老配置,回填智能默认」;writeTargetFields 据此决定是否
309
+ // 落盘该键 —— 键缺失(老配置)保存后仍无此键(而非物化成 `[]`),显式存在
310
+ // 才落盘(显式空写 `main_agent_extra: []`),读写 往返语义才存活。
311
+ has_main_agent_extra: hasExtra,
198
312
  main_agent_remove: remove,
199
313
  main_agent_skills: mainAgentSkills,
200
314
  main_agent_skills_inline: mainAgentSkillsInline,
201
315
  main_agent_persona_extra: mainPersonaExtra,
202
- main_agent_name: mainAgentName,
316
+ main_agent_display_name: mainAgentDisplayName,
203
317
  skills,
204
318
  }
205
319
  }
206
320
 
207
- /** Serialize the config back to YAML (fixed structure, hand-rolled writer). */
208
- export function serializeConfig(config) {
209
- const lines = ['# dsh-paoding(庖丁)安装配置 —— ./install.sh 交互向导生成;可手编;改后重跑 ./install.sh --auto 应用']
210
- lines.push(`profile: ${config.profile ?? 'web'}`)
211
- // main_agent_name:仅非 null 写出(null/缺省 = 维持默认名,不改名)。
212
- if (config.main_agent_name !== null && config.main_agent_name !== undefined) {
213
- const name = normalizeMainAgentName(config.main_agent_name)
214
- if (name !== null) lines.push(`main_agent_name: ${yamlScalar(name)}`)
321
+ /**
322
+ * 剥掉 assignments 里不属于目标字段集合的两个容器键(profile / workspaces),
323
+ * 浅拷贝返回;两键都不在场时原样返回。生成与存盘前一律过这道——UI/CLI 透传
324
+ * 的 assignments 若混入了这两个键(如把整份 existing 配置直接当 assignments),
325
+ * 也不能污染生成的 preset 或覆盖掉配置文件里对应的段。
326
+ */
327
+ export function stripNonTargetKeys(assignments) {
328
+ if (!assignments || typeof assignments !== 'object') return assignments
329
+ if (!('profile' in assignments) && !('workspaces' in assignments)) return assignments
330
+ const out = { ...assignments }
331
+ delete out.profile
332
+ delete out.workspaces
333
+ return out
334
+ }
335
+
336
+ /**
337
+ * 目标字段集合(roles → skills,除
338
+ * profile / workspaces 外的全部顶层键)写出体:pad 为该层缩进前缀(顶层 ''、workspaces.<path> 条目里
339
+ * ' '),行文本与字段序和拆分前逐字一致,顶层输出零变化
340
+ *(唯一例外见下方 main_agent_extra:键缺失的老配置保存后仍缺省,不再物化 `[]`)。
341
+ */
342
+ function writeTargetFields(lines, pad, target) {
343
+ // main_agent_display_name:仅非 null 写出(null/缺省 = preset 显示名用默认
344
+ // 「编排模式 (Orchestrator)」,工作区预设用派生名);写出前过一次
345
+ // normalizeRoleName(防手工构造的 config 绕过规范化),字段序固定。
346
+ if (target.main_agent_display_name !== null && target.main_agent_display_name !== undefined) {
347
+ const displayName = normalizeRoleName(target.main_agent_display_name, 'main_agent_display_name')
348
+ if (displayName !== null) lines.push(`${pad}main_agent_display_name: ${yamlScalar(displayName)}`)
215
349
  }
216
- lines.push('roles:')
217
- for (const [toolName, role] of Object.entries(config.roles)) {
218
- lines.push(` ${toolName}:`)
350
+ lines.push(`${pad}roles:`)
351
+ for (const [toolName, role] of Object.entries(target.roles)) {
352
+ lines.push(`${pad} ${toolName}:`)
219
353
  // roles.<toolName>.name:仅非 null 写出(null/缺省 = 保持该角色默认身份句);
220
354
  // 写在该角色 persona / tools 之前,缩进 4。
221
355
  if (role.name !== null && role.name !== undefined) {
222
356
  const name = normalizeRoleName(role.name, `roles.${toolName}.name`)
223
- if (name !== null) lines.push(` name: ${yamlScalar(name)}`)
357
+ if (name !== null) lines.push(`${pad} name: ${yamlScalar(name)}`)
224
358
  }
225
359
  // roles.<toolName>.model / .provider:仅非 null 写出(null/缺省 = 不注入
226
360
  // agentOptions,子 agent 继承主 agent 当前模型);写前过一次 normalizeModelRef
227
361
  // (与上方 name 写法一致,防手工构造的 config 绕过规范化),字段序 name →
228
- // model → provider → persona → tools 固定。
362
+ // model → provider → background_mode → persona → tools 固定。
229
363
  if (role.model !== null && role.model !== undefined) {
230
364
  const model = normalizeModelRef(role.model, `roles.${toolName}.model`)
231
- if (model !== null) lines.push(` model: ${yamlScalar(model)}`)
365
+ if (model !== null) lines.push(`${pad} model: ${yamlScalar(model)}`)
232
366
  }
233
367
  if (role.provider !== null && role.provider !== undefined) {
234
368
  const provider = normalizeModelRef(role.provider, `roles.${toolName}.provider`)
235
- if (provider !== null) lines.push(` provider: ${yamlScalar(provider)}`)
369
+ if (provider !== null) lines.push(`${pad} provider: ${yamlScalar(provider)}`)
236
370
  }
371
+ // roles.<toolName>.background_mode:恒写出(含缺省 one-shot)—— 配置文件里
372
+ // 旋钮对用户可见、读写往返幂等(loadConfig 也恒收此键)。写前过一次判定
373
+ // (防手工构造的 config 绕过规范化):非 'continuable' 一律按 'one-shot' 落盘。
374
+ lines.push(`${pad} background_mode: ${role.background_mode === 'continuable' ? 'continuable' : 'one-shot'}`)
237
375
  if (role.persona !== null && role.persona !== undefined) {
238
- lines.push(' persona: |-')
239
- for (const line of String(role.persona).split('\n')) lines.push(` ${line}`)
376
+ lines.push(`${pad} persona: |-`)
377
+ for (const line of String(role.persona).split('\n')) lines.push(`${pad} ${line}`)
240
378
  }
241
379
  if (role.tools !== null && role.tools !== undefined) {
242
- lines.push(' tools:')
243
- for (const tool of role.tools) lines.push(` - ${tool}`)
380
+ lines.push(`${pad} tools:`)
381
+ // 工具名过 yamlScalar:含 ':' / '#' / 首字符指示符的名字裸写会产出坏 YAML。
382
+ for (const tool of role.tools) lines.push(`${pad} - ${yamlScalar(tool)}`)
244
383
  }
245
384
  }
246
- if (config.roles_remove && config.roles_remove.length > 0) {
247
- lines.push('roles_remove:')
248
- for (const role of config.roles_remove) lines.push(` - ${role}`)
385
+ if (target.roles_remove && target.roles_remove.length > 0) {
386
+ lines.push(`${pad}roles_remove:`)
387
+ for (const role of target.roles_remove) lines.push(`${pad} - ${role}`)
249
388
  }
250
- // main_agent_extra:「数组存在即写」——length>0 走多行块;显式空数组写
251
- // `main_agent_extra: []`。显式空必须落盘成键:否则下次加载时键缺失,
252
- // resolveAssignments 会把空列表当老配置用智能默认回填,用户「明确不要 host
253
- // 工具」的意愿就丢了。非数组(缺省)仍整键省略。
254
- if (Array.isArray(config.main_agent_extra)) {
255
- if (config.main_agent_extra.length > 0) {
256
- lines.push('main_agent_extra:')
257
- for (const tool of config.main_agent_extra) lines.push(` - ${tool}`)
389
+ // main_agent_extra:「原配置显式写了键才写」(has_main_agent_extra 标记,见
390
+ // normalizeTargetFields 的 hasExtra;未过 normalize 的裸对象回落「数组即写」
391
+ // 旧判,标记显式 false = 键缺失 → 整键省略)。显式存在:length>0 走多行块,
392
+ // 显式空数组写 `main_agent_extra: []` —— 显式空必须落盘成键,否则下次加载
393
+ // 键缺失,resolveAssignments 会把空列表当老配置用智能默认回填,用户「明确
394
+ // 不要 host 工具」的意愿就丢了。老配置无此键 → 保存后仍无此键(而非 [])。
395
+ if (Array.isArray(target.main_agent_extra) && target.has_main_agent_extra !== false) {
396
+ if (target.main_agent_extra.length > 0) {
397
+ lines.push(`${pad}main_agent_extra:`)
398
+ for (const tool of target.main_agent_extra) lines.push(`${pad} - ${yamlScalar(tool)}`)
258
399
  } else {
259
- lines.push('main_agent_extra: []')
400
+ lines.push(`${pad}main_agent_extra: []`)
260
401
  }
261
402
  }
262
- if (config.main_agent_remove && config.main_agent_remove.length > 0) {
263
- lines.push('main_agent_remove:')
264
- for (const tool of config.main_agent_remove) lines.push(` - ${tool}`)
403
+ if (target.main_agent_remove && target.main_agent_remove.length > 0) {
404
+ lines.push(`${pad}main_agent_remove:`)
405
+ for (const tool of target.main_agent_remove) lines.push(`${pad} - ${tool}`)
265
406
  }
266
- if (config.main_agent_skills && config.main_agent_skills.length > 0) {
267
- lines.push('main_agent_skills:')
268
- for (const skill of config.main_agent_skills) lines.push(` - ${skill}`)
407
+ if (target.main_agent_skills && target.main_agent_skills.length > 0) {
408
+ lines.push(`${pad}main_agent_skills:`)
409
+ for (const skill of target.main_agent_skills) lines.push(`${pad} - ${skill}`)
269
410
  }
270
- if (config.main_agent_skills_inline && config.main_agent_skills_inline.length > 0) {
271
- lines.push('main_agent_skills_inline:')
272
- for (const skill of config.main_agent_skills_inline) lines.push(` - ${skill}`)
411
+ if (target.main_agent_skills_inline && target.main_agent_skills_inline.length > 0) {
412
+ lines.push(`${pad}main_agent_skills_inline:`)
413
+ for (const skill of target.main_agent_skills_inline) lines.push(`${pad} - ${skill}`)
273
414
  }
274
415
  // main_agent_persona_extra:只在非 null 时写出 —— '' 也写(= 明确清空,不追加任何
275
416
  // 内容),区别于缺省(= compose 回落默认常量)。
276
- if (config.main_agent_persona_extra !== null && config.main_agent_persona_extra !== undefined) {
277
- const value = String(config.main_agent_persona_extra)
417
+ if (target.main_agent_persona_extra !== null && target.main_agent_persona_extra !== undefined) {
418
+ const value = String(target.main_agent_persona_extra)
278
419
  if (value === '') {
279
- lines.push("main_agent_persona_extra: ''")
420
+ lines.push(`${pad}main_agent_persona_extra: ''`)
280
421
  } else {
281
- lines.push('main_agent_persona_extra: |-')
282
- for (const line of value.split('\n')) lines.push(` ${line}`)
422
+ lines.push(`${pad}main_agent_persona_extra: |-`)
423
+ for (const line of value.split('\n')) lines.push(`${pad} ${line}`)
424
+ }
425
+ }
426
+ if (Object.keys(target.skills).length > 0) {
427
+ lines.push(`${pad}skills:`)
428
+ for (const [skill, roleList] of Object.entries(target.skills)) {
429
+ // 技能名(键,块上下文)过 yamlScalar;角色名是流序列项,须过
430
+ // yamlFlowScalar —— 流上下文里 ',' '[' ']' 等是定界符,裸写会截断序列。
431
+ lines.push(`${pad} ${yamlScalar(skill)}: [${roleList.map((r) => yamlFlowScalar(r)).join(', ')}]`)
283
432
  }
284
433
  }
285
- if (Object.keys(config.skills).length > 0) {
286
- lines.push('skills:')
287
- for (const [skill, roleList] of Object.entries(config.skills)) {
288
- lines.push(` ${skill}: [${roleList.join(', ')}]`)
434
+ }
435
+
436
+ /**
437
+ * 工作区条目段写出(永远追加在 skills 之后、文件末尾)。路径键一律单引号
438
+ * 包裹:路径可含 ':' / '#' 等会被键值切分或行内注释截断的歧义字符,裸写有
439
+ * 歧义;内部单引号按 YAML 规则转义成 ''。键序按路径字符串排序,输出确定。
440
+ */
441
+ function writeWorkspaces(lines, workspaces) {
442
+ const wsPaths = Object.keys(workspaces)
443
+ .filter((p) => typeof p === 'string' && p.trim() !== '')
444
+ .sort()
445
+ if (wsPaths.length === 0) return
446
+ lines.push('workspaces:')
447
+ for (const wsPath of wsPaths) {
448
+ lines.push(` '${wsPath.replace(/'/g, "''")}':`)
449
+ const entry = workspaces[wsPath]
450
+ if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
451
+ // 过一层 normalizeTargetFields:手编文件里缺 roles / skills 等容器的条目
452
+ // 也能安全写出(对已归一化的条目是恒等变换,零影响)。
453
+ writeTargetFields(lines, ' ', normalizeTargetFields(entry))
289
454
  }
290
455
  }
456
+ }
457
+
458
+ /** Serialize the config back to YAML (fixed structure, hand-rolled writer). */
459
+ export function serializeConfig(config) {
460
+ const lines = ['# dsh-paoding(庖丁)安装配置 —— 可手编;改后在 设置 → 庖丁配置 应用,或重跑 node tools/install.mjs --auto']
461
+ lines.push(`profile: ${config.profile ?? 'web'}`)
462
+ writeTargetFields(lines, '', config)
463
+ // workspaces 段可选:键缺失 / 空对象整段省略(不含 workspaces 的配置输出与
464
+ // 旧版逐字节一致)。
465
+ writeWorkspaces(
466
+ lines,
467
+ config.workspaces && typeof config.workspaces === 'object' && !Array.isArray(config.workspaces)
468
+ ? config.workspaces
469
+ : {},
470
+ )
291
471
  return lines.join('\n') + '\n'
292
472
  }
293
473
 
@@ -1,17 +1,20 @@
1
1
  /**
2
2
  * 主机层(host)工具检测与 MCP 工具名解析:KNOWN_MCP_TOOLS / KNOWN_HOST_PLUGINS
3
- * 静态表、JSON-RPC initialize + tools/list 握手(stdio / streamable-http /
3
+ * 静态表(前者仅作握手失败时的降级回落,返回对象带 warning)、JSON-RPC
4
+ * initialize + tools/list 握手(stdio / streamable-http /
4
5
  * postMcp / parseHttpMessage)、host 相关判定与文案(isHostDependent /
5
6
  * pluginLabel / removedReason)与错误收尾(sanitizeError / truncate)。
6
7
  * 仅依赖 node:child_process(spawn);fetch / AbortSignal 为 Node 全局。
7
- * 被 state / compose / wizard / cli 引用。
8
+ * 被 state / compose / cli / alloc 引用。
8
9
  */
9
10
  import { spawn } from 'node:child_process'
10
11
 
11
12
  /**
12
13
  * Exact tool names of well-known MCP servers (restrict() does not support
13
- * globs, so the names must be spelled out). Servers listed here are resolved
14
- * without a live handshake; unknown servers go through one.
14
+ * globs, so the names must be spelled out). Servers listed here still go
15
+ * through a live handshake first; this table is only the degraded fallback
16
+ * when the handshake fails (unreachable / unsupported transport), so a
17
+ * temporary outage does not silently drop their tools.
15
18
  */
16
19
  export const KNOWN_MCP_TOOLS = {
17
20
  tavily: [
@@ -60,7 +63,12 @@ export function isMcpEntry(entry) {
60
63
  const serverName = typeof cfg.serverName === 'string' ? cfg.serverName : ''
61
64
  const transport = typeof cfg.transport === 'string' ? cfg.transport : ''
62
65
  const hasCommand = typeof cfg.command === 'string' && cfg.command.length > 0
63
- if (name.endsWith('dsh-mcp-client')) return true
66
+ // 精确匹配(包名段相等):endsWith 会把 'not-dsh-mcp-client-foo' 这类恰好
67
+ // 同后缀的名字误判成 MCP 条目。unscoped 取首段;scoped 包(@scope/pkg)的
68
+ // 包名是 @ 后那段;带子路径时同样取包名所在段。
69
+ const segments = name.split('/')
70
+ const pkgName = segments[0].startsWith('@') ? (segments[1] ?? '') : (segments[0] ?? '')
71
+ if (name === 'dsh-mcp-client' || pkgName === 'dsh-mcp-client') return true
64
72
  return serverName !== '' && (['stdio', 'streamable-http', 'sse'].includes(transport) || hasCommand)
65
73
  }
66
74
 
@@ -96,12 +104,13 @@ export function truncate(text, max) {
96
104
  }
97
105
 
98
106
  /**
99
- * Resolve the exact tool names of one MCP server. Static table first, then a
100
- * live JSON-RPC handshake. Never throws: failures return { tools: [], error }.
107
+ * Resolve the exact tool names of one MCP server. A live JSON-RPC handshake
108
+ * first (工具面随服务器实际版本变化,静态表可能过时);握手失败才回落静态表
109
+ * (仅 KNOWN_MCP_TOOLS 内的服务器,降级不丢工具),返回对象带 `warning` 字段
110
+ * 供上游报告可见。 Never throws: unreachable unknown servers return
111
+ * { tools: [], error }.
101
112
  */
102
113
  export async function resolveMCP(mcp) {
103
- const known = KNOWN_MCP_TOOLS[mcp.serverName]
104
- if (known) return { tools: [...known], source: 'static' }
105
114
  try {
106
115
  if (mcp.transport === 'stdio') {
107
116
  if (!mcp.command) throw new Error('stdio transport requires config.command')
@@ -116,7 +125,12 @@ export async function resolveMCP(mcp) {
116
125
  }
117
126
  throw new Error(`unsupported transport: ${mcp.transport ?? '(none)'}`)
118
127
  } catch (err) {
119
- return { tools: [], source: 'failed', error: sanitizeError(err?.message ?? err) }
128
+ const message = sanitizeError(err?.message ?? err)
129
+ // 已知服务器握手失败:回落静态表,降级不丢工具;warning 让上游报告
130
+ // 能看见「这是过时的静态表,不是实时握手结果」。
131
+ const known = KNOWN_MCP_TOOLS[mcp.serverName]
132
+ if (known) return { tools: [...known], source: 'static', warning: `static table used; server unreachable (${message})` }
133
+ return { tools: [], source: 'failed', error: message }
120
134
  }
121
135
  }
122
136
 
@@ -138,22 +152,14 @@ export function handshakeStdio(mcp) {
138
152
  if (settled) return
139
153
  settled = true
140
154
  clearTimeout(timer)
141
- try {
142
- child.kill('SIGTERM')
143
- } catch {
144
- /* already gone */
145
- }
155
+ killChild()
146
156
  reject(new Error(sanitizeError(message)))
147
157
  }
148
158
  function finish(tools) {
149
159
  if (settled) return
150
160
  settled = true
151
161
  clearTimeout(timer)
152
- try {
153
- child.kill('SIGTERM')
154
- } catch {
155
- /* already gone */
156
- }
162
+ killChild()
157
163
  resolve(tools)
158
164
  }
159
165
  function send(payload) {
@@ -163,6 +169,23 @@ export function handshakeStdio(mcp) {
163
169
  /* the exit handler will surface the failure */
164
170
  }
165
171
  }
172
+ // kill 后挂一次性 close 清理:销毁残留 stdio 流(幂等),调用方不等 close
173
+ // 返回也不残留句柄、不把事件循环挂住。
174
+ function killChild() {
175
+ try {
176
+ child.kill('SIGTERM')
177
+ } catch {
178
+ /* already gone */
179
+ }
180
+ child.once('close', () => {
181
+ child.stdin.destroy()
182
+ child.stdout.destroy()
183
+ child.stderr.destroy()
184
+ })
185
+ child.stdin.destroy()
186
+ child.stdout.destroy()
187
+ child.stderr.destroy()
188
+ }
166
189
 
167
190
  child.stdout.on('data', (chunk) => {
168
191
  stdoutBuf += chunk.toString()
@@ -178,6 +201,12 @@ export function handshakeStdio(mcp) {
178
201
  continue
179
202
  }
180
203
  if (msg.id === 1) {
204
+ // initialize 失败必须立刻走 fail 路径:继续发 tools/list 只会把错误
205
+ // 归因到 tools/list(或超时),掩盖真实失败原因。
206
+ if (msg.error) {
207
+ fail(`initialize error: ${JSON.stringify(msg.error).slice(0, 300)}`)
208
+ return
209
+ }
181
210
  send(LIST_REQUEST)
182
211
  } else if (msg.id === 2) {
183
212
  if (msg.error) {
@@ -260,9 +289,11 @@ export function parseHttpMessage(text) {
260
289
  }
261
290
 
262
291
  /**
263
- * Whether `name` can only be provided by the host patch layer. Only these
264
- * names are filtered against the detection inventory; everything else is
265
- * guaranteed by the standard composition and stays untouched.
292
+ * Whether `name` can only be provided by the host patch layer. These names
293
+ * survive generation only when actually detected; every other name must at
294
+ * least belong to the preset universe (restrict base ∪ the preset's own role
295
+ * allow lists) or be detected — both halves are enforced by the compose layer
296
+ * (usableWith in compose.mjs).
266
297
  */
267
298
  export function isHostDependent(name) {
268
299
  if (name.startsWith('mcp__')) return true
@@ -284,5 +315,5 @@ export function removedReason(name, mcpReports) {
284
315
  }
285
316
  const plugin = KNOWN_HOST_PLUGINS.find((p) => p.tools.includes(name))
286
317
  if (plugin) return `${pluginLabel(plugin)} 已停用`
287
- return 'host 工具未启用'
318
+ return '不在 preset 自带工具面与检测库存中(对应插件 / MCP 未启用,或名字有误),已移除'
288
319
  }