dsh-paoding 0.3.7 → 0.3.9

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.
@@ -2,10 +2,12 @@
2
2
  * dsh-paoding.config.yml 键规范化与读写:main_agent_display_name /
3
3
  * roles.<toolName>.name /
4
4
  * roles.<toolName>.model / roles.<toolName>.provider(角色专用模型,生成层映射到
5
- * dsh-tool-subagent 的 agentOptions)/ roles.<toolName>.background_mode(可续模式,
5
+ * dsh-tool-subagent 的 agentOptions)/ roles.<toolName>.start_mode(角色启动方式,
6
+ * 生成层把内置委派块的块级 provider 行改写为 fork,'fresh' | 'fork')/
7
+ * roles.<toolName>.background_mode(可续模式,
6
8
  * 生成层映射到 dsh-tool-subagent 的 backgroundMode,'one-shot' | 'continuable')/
7
9
  * roles_remove 解析(normalizeRoleName /
8
- * normalizeModelRef / normalizeBackgroundMode / parseRolesRemove / yamlScalar)、
10
+ * normalizeModelRef / normalizeBackgroundMode / normalizeStartMode / parseRolesRemove / yamlScalar)、
9
11
  * 目标字段集合的规范化与写出(normalizeTargetFields / writeTargetFields —— 顶层与
10
12
  * workspaces.<path> 条目共用同一套)、按工作区条目段(normalizeWorkspaces)、
11
13
  * 文件加载与规范化(loadConfig / normalizeConfig)、序列化与落盘(serializeConfig /
@@ -86,6 +88,27 @@ export function normalizeBackgroundMode(value, label = 'background_mode') {
86
88
  return value
87
89
  }
88
90
 
91
+ /**
92
+ * 校验并规范化 roles.<toolName>.start_mode(角色启动方式,映射到 dsh-tool-subagent
93
+ * 委派块的块级 provider 行:'fresh' = spawn 全新启动,'fork' = 以主 agent 已完成的
94
+ * 对话轮次为初始内容启动)。合法值仅 'fresh' | 'fork':缺省/null → 缺省值
95
+ * 'fresh'(静默,与 name/model/provider 的 null 口径一致);非字符串或字符串但
96
+ * 不在合法值集 → warn 后回落 'fresh'(与 normalizeBackgroundMode 的非法值处理
97
+ * 同款:类型/取值不对降级提醒而非中断加载)。返回恒为合法值之一,生成层
98
+ * (compose)据此决定是否把块级 provider 行改写为 fork。
99
+ * 注意:本键与 roles.<toolName>.provider(LLM 路由,与 model 配对映射到
100
+ * agentOptions 子行)毫无关系 —— 子 agent 的启动提供方只有委派块里那行块级
101
+ * provider,本键是它的配置面。
102
+ */
103
+ export function normalizeStartMode(value, label = 'start_mode') {
104
+ if (value === null || value === undefined) return 'fresh'
105
+ if (typeof value !== 'string' || (value !== 'fresh' && value !== 'fork')) {
106
+ warn(`${label}: 非法值已忽略(${JSON.stringify(value)}),回落 'fresh'(可选: 'fresh' / 'fork')`)
107
+ return 'fresh'
108
+ }
109
+ return value
110
+ }
111
+
89
112
  /**
90
113
  * 解析配置键 roles_remove:string[],只收 ROLES 内置角色名(search_external /
91
114
  * design / implement);含其他名字 warn 并忽略;去重保序。非数组 → []。
@@ -138,7 +161,7 @@ function yamlFlowScalar(value) {
138
161
  /**
139
162
  * Load the config file when present. Returns null when the file does not
140
163
  * exist; throws when it exists but cannot be parsed. Shape:
141
- * { profile, roles: { <toolName>: { name?, model?, provider?, background_mode?, persona?, tools[] } },
164
+ * { profile, roles: { <toolName>: { name?, model?, provider?, start_mode?, background_mode?, persona?, tools[] } },
142
165
  * roles_remove[] (内置角色删除), main_agent_extra[], main_agent_remove[],
143
166
  * main_agent_skills[], main_agent_skills_inline[],
144
167
  * main_agent_persona_extra? (string|null),
@@ -220,7 +243,7 @@ function normalizeWorkspaces(raw) {
220
243
 
221
244
  /**
222
245
  * 目标字段集合规范化:顶层(除 profile 外)与 workspaces.<path> 条目共用的
223
- * 同一套键 —— roles(条目含 background_mode)/ roles_remove / main_agent_extra(含 has_main_agent_extra
246
+ * 同一套键 —— roles(条目含 start_mode / background_mode)/ roles_remove / main_agent_extra(含 has_main_agent_extra
224
247
  * 内部标记)/ main_agent_remove / main_agent_skills / main_agent_skills_inline /
225
248
  * main_agent_persona_extra / main_agent_display_name / skills。
226
249
  * 语义与抽取前逐字一致;
@@ -243,18 +266,21 @@ export function normalizeTargetFields(raw) {
243
266
  warn(`roles.${toolName}.${key}: 非字符串值已忽略(${JSON.stringify(v)})`)
244
267
  }
245
268
  }
246
- // 角色条目字段顺序 { name?, model?, provider?, background_mode?, persona?, tools? }:
269
+ // 角色条目字段顺序 { name?, model?, provider?, start_mode?, background_mode?, persona?, tools? }:
247
270
  // name(roles.<toolName>.name,内置角色可选显示名)与主 agent 显示名同约束,
248
271
  // 仅字符串收(trim 后空/违规由 normalizeRoleName 处理);缺省/null → null
249
272
  // (= 保持该角色默认身份句)。model / provider(roles.<toolName>.model /
250
273
  // .provider,角色专用模型)镜像同款收法,违规由 normalizeModelRef 处理;
251
274
  // 缺省/null → null(= 不注入 agentOptions,子 agent 继承主 agent 当前模型)。
252
- // background_mode(roles.<toolName>.background_mode,角色可续模式)仅收
275
+ // start_mode(roles.<toolName>.start_mode,角色启动方式)仅收 'fresh' | 'fork',
276
+ // 其余 warn 回落 'fresh'(normalizeStartMode)。background_mode
277
+ // (roles.<toolName>.background_mode,角色可续模式)仅收
253
278
  // 'one-shot' | 'continuable',其余 warn 回落 'one-shot'(normalizeBackgroundMode)。
254
279
  roles[toolName] = {
255
280
  name: typeof raw.name === 'string' ? normalizeRoleName(raw.name, `roles.${toolName}.name`) : null,
256
281
  model: typeof raw.model === 'string' ? normalizeModelRef(raw.model, `roles.${toolName}.model`) : null,
257
282
  provider: typeof raw.provider === 'string' ? normalizeModelRef(raw.provider, `roles.${toolName}.provider`) : null,
283
+ start_mode: normalizeStartMode(raw.start_mode, `roles.${toolName}.start_mode`),
258
284
  background_mode: normalizeBackgroundMode(raw.background_mode, `roles.${toolName}.background_mode`),
259
285
  persona: typeof raw.persona === 'string' && raw.persona.trim() !== '' ? raw.persona : null,
260
286
  tools,
@@ -359,7 +385,7 @@ function writeTargetFields(lines, pad, target) {
359
385
  // roles.<toolName>.model / .provider:仅非 null 写出(null/缺省 = 不注入
360
386
  // agentOptions,子 agent 继承主 agent 当前模型);写前过一次 normalizeModelRef
361
387
  // (与上方 name 写法一致,防手工构造的 config 绕过规范化),字段序 name →
362
- // model → provider → background_mode → persona → tools 固定。
388
+ // model → provider → start_mode → background_mode → persona → tools 固定。
363
389
  if (role.model !== null && role.model !== undefined) {
364
390
  const model = normalizeModelRef(role.model, `roles.${toolName}.model`)
365
391
  if (model !== null) lines.push(`${pad} model: ${yamlScalar(model)}`)
@@ -368,6 +394,10 @@ function writeTargetFields(lines, pad, target) {
368
394
  const provider = normalizeModelRef(role.provider, `roles.${toolName}.provider`)
369
395
  if (provider !== null) lines.push(`${pad} provider: ${yamlScalar(provider)}`)
370
396
  }
397
+ // roles.<toolName>.start_mode:恒写出(含缺省 fresh)—— 与 background_mode 同
398
+ // 款「旋钮可见、读写往返幂等」口径(loadConfig 也恒收此键)。写前过一次判定
399
+ // (防手工构造的 config 绕过规范化):非 'fork' 一律按 'fresh' 落盘。
400
+ lines.push(`${pad} start_mode: ${role.start_mode === 'fork' ? 'fork' : 'fresh'}`)
371
401
  // roles.<toolName>.background_mode:恒写出(含缺省 one-shot)—— 配置文件里
372
402
  // 旋钮对用户可见、读写往返幂等(loadConfig 也恒收此键)。写前过一次判定
373
403
  // (防手工构造的 config 绕过规范化):非 'continuable' 一律按 'one-shot' 落盘。
@@ -1,27 +1,40 @@
1
1
  /**
2
- * 面板一键升级执行器 —— spawn `dsh plugin --profile <profile> update dsh-paoding`
3
- * 原地换版本(Web 面板「升级」按钮专用;CLI 不走这里)。
2
+ * 面板一键升级执行器 —— 按 profile 分流的原地换版本(Web 面板「升级」按钮
3
+ * 专用;CLI 不走这里)。
4
4
  *
5
5
  * 唯一官方安装通道是 DSH 插件通道:包由 pnpm 装进 profile
6
- * ($DSH_HOME/profiles/<name>/node_modules/dsh-paoding),升级 = 让 dsh 的插件
7
- * 转发器对该包做 pnpm update + bundle reconcile。本模块只负责「拿目标版本 →
8
- * 探测自身 profile → 跑子进程」这一段编排;profile 名从本文件运行时真实路径
9
- * 反推(registry 安装形态必然位于 <...>/profiles/<name>/node_modules/ 之下),
10
- * 开发 link: 直连形态的真实路径在仓库里、匹配不到,如实报「无法就地升级」。
6
+ * ($DSH_HOME/profiles/<name>/node_modules/dsh-paoding)。升级子进程按
7
+ * profile 分流:
8
+ * - 常规 profile:spawn `dsh plugin --profile <profile> update dsh-paoding`,
9
+ * 让 dsh 的插件转发器对该包做 pnpm update + bundle reconcile;
10
+ * - desktop profile:dsh CLI 在启动器里硬拒 desktop profile(报
11
+ * `profile "desktop" is managed exclusively by the Electron application`,
12
+ * 只有桌面 Electron App 内部 CLI 豁免),转发器这条路必炸——改为在
13
+ * profile 目录里直跑 `pnpm add dsh-paoding@<目标版本>`:本包已在 profile
14
+ * package.json 的 dsh.profile.bundles 清单里,无需 bundle reconcile;用
15
+ * 精确版本而非 update,避开 pnpm 新鲜度防护 / 镜像 packument 滞后造成的
16
+ * 静默空转。
17
+ *
18
+ * 本模块只负责「拿目标版本 → 探测自身 profile → 跑子进程」这一段编排;
19
+ * profile 名从本文件运行时真实路径反推(registry 安装形态必然位于
20
+ * <...>/profiles/<name>/node_modules/ 之下),开发 link: 直连形态的真实路径
21
+ * 在仓库里、匹配不到,如实报「无法就地升级」。
11
22
  *
12
23
  * 零依赖纯 Node(>=18):目标版本复用 version.mjs 的 fetchLatestFromRegistry
13
24
  * / fetchLatestFromMirror(官方源失败再走 npmmirror 镜像——点「升级」的用户
14
25
  * 网络本来就在出状况,多一路快路能实打实救回一次升级),子进程、时钟全部
15
26
  * 可注入便于单测。升级后不做磁盘后验
16
- * ——本进程代码不随升级变化,无从对账;成功与否以 dsh 子进程退出码为准,
27
+ * ——本进程代码不随升级变化,无从对账;成功与否以升级子进程退出码为准,
17
28
  * 新版本在重启 DSH 后生效(preset 由新包的启动自愈钩子自动重生成)。
18
29
  *
19
- * 失败路径全部诚实暴露(不静默):拿不到版本、开发形态、超时、退出码非 0,
20
- * 都原样带回 error + 子进程输出尾部,调用方(面板路由)转成 500 醒目展示
21
- * ——升级是用户主动点的高级操作,失败被吞掉比失败本身更糟。
30
+ * 失败路径全部诚实暴露(不静默):拿不到版本、开发形态、profile 未初始化、
31
+ * 超时、退出码非 0,都原样带回 error + 子进程输出尾部,调用方(面板路由)
32
+ * 转成 500 醒目展示——升级是用户主动点的高级操作,失败被吞掉比失败本身更糟。
22
33
  */
23
34
  import os from 'node:os'
35
+ import path from 'node:path'
24
36
  import { spawn } from 'node:child_process'
37
+ import { existsSync } from 'node:fs'
25
38
  import { realpath } from 'node:fs/promises'
26
39
  import { fileURLToPath } from 'node:url'
27
40
  import { currentVersion, compareVersions, fetchLatestFromMirror, fetchLatestFromRegistry } from './version.mjs'
@@ -44,8 +57,10 @@ function tailOutput(text) {
44
57
  * 缺省子进程执行器(可被 spawnImpl 注入替换,测试用):spawn 任意命令,合并
45
58
  * 收集 stdout+stderr,timeoutMs 到点 kill 并按超时收敛。永不 reject,统一收敛
46
59
  * 为 { code, output, error? }(error 仅 spawn 本身失败(如 ENOENT)时存在)。
60
+ * timeoutLabel 只用于超时文案标明哪条通道超时(dsh 转发器 / pnpm 直装),
61
+ * 不参与子进程本身。
47
62
  */
48
- function defaultSpawn(command, args, options, timeoutMs) {
63
+ function defaultSpawn(command, args, options, timeoutMs, timeoutLabel) {
49
64
  return new Promise((resolve) => {
50
65
  const child = spawn(command, args, options)
51
66
  let output = ''
@@ -67,7 +82,7 @@ function defaultSpawn(command, args, options, timeoutMs) {
67
82
  child.on('close', (code) => {
68
83
  clearTimeout(timer)
69
84
  resolve(timedOut
70
- ? { code, output: tailOutput(output), error: `dsh plugin update 升级超时(${Math.round(timeoutMs / 1000)}s)` }
85
+ ? { code, output: tailOutput(output), error: `${timeoutLabel} 升级超时(${Math.round(timeoutMs / 1000)}s)` }
71
86
  : { code, output: tailOutput(output) })
72
87
  })
73
88
  })
@@ -91,6 +106,18 @@ export async function detectProfileName() {
91
106
  }
92
107
  }
93
108
 
109
+ /**
110
+ * desktop profile 目录:$DSH_HOME(空串视为未设)否则 ~/.dsh,再拼
111
+ * profiles/desktop,与 dsh 启动器的 profile 落位规则一致。desktop 分支的
112
+ * pnpm 子进程以此为 cwd——pnpm 的操作对象就是这份 profile,脱离目录跑不
113
+ * 到点子上。
114
+ * @returns {string}
115
+ */
116
+ function desktopProfileDir() {
117
+ const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
118
+ return path.join(home, 'profiles', 'desktop')
119
+ }
120
+
94
121
  /**
95
122
  * 执行一键升级。
96
123
  * @param {{profile?: string, fetchImpl?: Function, spawnImpl?: Function,
@@ -99,9 +126,10 @@ export async function detectProfileName() {
99
126
  * 缺省经 detectProfileName() 从运行时真实路径反推。
100
127
  * - fetchImpl / spawnImpl / now 可注入(单测用);缺省用 globalThis.fetch、
101
128
  * node:child_process 的 spawn、Date.now()。
102
- * - timeoutMs:dsh 子进程超时(含 pnpm 下载安装),默认 120s。
103
- * - spawnImpl 契约:(command, args, options, timeoutMs) → Promise resolving
104
- * { code, output, error? }(同 defaultSpawn 的收敛形状)。
129
+ * - timeoutMs:升级子进程超时(含 pnpm 下载安装),默认 120s。
130
+ * - spawnImpl 契约:(command, args, options, timeoutMs, timeoutLabel) →
131
+ * Promise resolving { code, output, error? }(同 defaultSpawn 的收敛形状;
132
+ * timeoutLabel 仅缺省实现用于超时文案,注入桩可忽略)。
105
133
  * @returns {Promise<object>} 恒不抛错,收敛为:
106
134
  * - { ok: true, upToDate: true, current } 本地已不落后 registry
107
135
  * - { ok: true, version, profile, output, elapsedMs } 升级成功(退出码 0)
@@ -149,20 +177,33 @@ async function doRunUpgrade({ profile, fetchImpl, spawnImpl, timeoutMs, now }) {
149
177
  }
150
178
  }
151
179
 
152
- // ③ spawn dsh 插件转发器:update = pnpm update + bundle reconcile。cwd 用
153
- // 系统临时目录:子进程不该在仓库或 $DSH_HOME 里跑;DSH_HOME 经环境继承,
154
- // 由子进程的 dsh 自行解析。win32 的 dsh 是 .cmd shim,必须借 shell 拉起
155
- // (Node 直接 spawn .cmd 会 EINVAL,见 CVE-2024-27980 加固),与
156
- // bin/dsh-paoding.mjs 同一方案;参数保持数组形式,转义交给 Node。
180
+ // ③ spawn 升级子进程,按 profile 分流(守卫背景见头注):
181
+ // - desktop:dsh CLI 硬拒 desktop profile(Electron 专属管理),直跑
182
+ // pnpm add 精确版本。cwd 必须是 profile 目录(pnpm 的操作对象就是它);
183
+ // 目录没有 package.json 说明 profile 未初始化,诚实失败、不硬跑。
184
+ // - 其余 profile:照走 dsh 插件转发器(update = pnpm update + bundle
185
+ // reconcile),cwd 用系统临时目录——子进程不该在仓库或 $DSH_HOME 里跑;
186
+ // DSH_HOME 经环境继承,由子进程的 dsh 自行解析。
187
+ // win32 的 dsh / pnpm 都是 .cmd shim,必须借 shell 拉起(Node 直接 spawn
188
+ // .cmd 会 EINVAL,见 CVE-2024-27980 加固),与 bin/dsh-paoding.mjs 同一
189
+ // 方案;参数保持数组形式,转义交给 Node。
190
+ const isDesktop = resolved.toLowerCase() === 'desktop'
191
+ const desktopDir = isDesktop ? desktopProfileDir() : null
192
+ if (isDesktop && !existsSync(path.join(desktopDir, 'package.json'))) {
193
+ return { ok: false, error: `desktop profile 目录不存在或未初始化: ${desktopDir}` }
194
+ }
157
195
  const useShell = process.platform === 'win32'
158
- const command = useShell ? 'dsh.cmd' : 'dsh'
196
+ const command = isDesktop ? (useShell ? 'pnpm.cmd' : 'pnpm') : useShell ? 'dsh.cmd' : 'dsh'
197
+ const args = isDesktop
198
+ ? ['add', `dsh-paoding@${targetVersion}`]
199
+ : ['plugin', '--profile', resolved, 'update', 'dsh-paoding']
159
200
  const run = typeof spawnImpl === 'function' ? spawnImpl : defaultSpawn
160
- const proc = await run(command, ['plugin', '--profile', resolved, 'update', 'dsh-paoding'], {
161
- cwd: os.tmpdir(),
201
+ const proc = await run(command, args, {
202
+ cwd: isDesktop ? desktopDir : os.tmpdir(),
162
203
  env: process.env,
163
204
  stdio: ['ignore', 'pipe', 'pipe'],
164
205
  shell: useShell,
165
- }, timeoutMs)
206
+ }, timeoutMs, isDesktop ? 'pnpm add' : 'dsh plugin update')
166
207
  const output = tailOutput(proc?.output)
167
208
 
168
209
  // ④ 无磁盘后验(旧 npx 时代读 $DSH_HOME/dsh-paoding 对账的方案已随该目录
@@ -170,7 +211,13 @@ async function doRunUpgrade({ profile, fetchImpl, spawnImpl, timeoutMs, now }) {
170
211
  // 新版本重启 DSH 后生效(preset 由新版启动钩子自动重生成)。
171
212
  if (proc?.error) return { ok: false, error: proc.error, output, elapsedMs: elapsedMs() }
172
213
  if (proc?.code !== 0) {
173
- return { ok: false, error: `dsh plugin update 退出码 ${proc?.code}`, output, elapsedMs: elapsedMs() }
214
+ // 报错文案点明实际通道:desktop 是 pnpm 直装,其余是 dsh 插件转发器。
215
+ return {
216
+ ok: false,
217
+ error: isDesktop ? `pnpm add 退出码 ${proc?.code}` : `dsh plugin update 退出码 ${proc?.code}`,
218
+ output,
219
+ elapsedMs: elapsedMs(),
220
+ }
174
221
  }
175
222
  return { ok: true, version: targetVersion, profile: resolved, output, elapsedMs: elapsedMs() }
176
223
  }