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.
- package/README.md +26 -22
- package/README_en.md +26 -22
- package/bin/dsh-paoding.mjs +73 -11
- package/docs/architecture.md +36 -31
- package/docs/architecture_en.md +54 -38
- package/docs/configuration.md +151 -57
- package/docs/configuration_en.md +155 -60
- package/docs/installation.md +96 -234
- package/docs/installation_en.md +111 -252
- package/docs/orchestration.md +24 -24
- package/docs/orchestration_en.md +24 -24
- package/package.json +8 -5
- package/plugins/paoding-config-ui/api-core.mjs +215 -18
- package/plugins/paoding-config-ui/index.mjs +413 -154
- package/plugins/paoding-config-ui/lib/base.css +1083 -42
- package/plugins/paoding-config-ui/lib/client.js +2266 -252
- package/plugins/paoding-config-ui/package.json +2 -2
- package/plugins/paoding-config-ui/runtime-inventory.mjs +27 -26
- package/presets/orchestrator/agent.cordis.yml +19 -9
- package/presets/orchestrator/preset.yml +3 -2
- package/presets/orchestrator/restrict.mjs +2 -1
- package/tools/install.mjs +9 -4
- package/tools/lib/alloc.mjs +60 -29
- package/tools/lib/cli.mjs +127 -279
- package/tools/lib/compose.mjs +261 -95
- package/tools/lib/config.mjs +266 -86
- package/tools/lib/host.mjs +55 -24
- package/tools/lib/skills.mjs +72 -45
- package/tools/lib/state.mjs +172 -40
- package/tools/lib/upgrade.mjs +170 -0
- package/tools/lib/util.mjs +7 -0
- package/tools/lib/version.mjs +233 -0
- package/tools/lib/workspaces.mjs +74 -0
- package/tools/lib/yaml.mjs +92 -15
- package/install.sh +0 -31
- package/tools/lib/wizard.mjs +0 -516
package/tools/lib/util.mjs
CHANGED
|
@@ -13,6 +13,13 @@ export const SRC_DIR = path.join(REPO_ROOT, 'presets', 'orchestrator')
|
|
|
13
13
|
|
|
14
14
|
export const ROLES = ['search_external', 'design', 'implement']
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* profile 名白名单(bin/dsh-paoding.mjs 与 lib/cli.mjs 两处共用,抽在此处防两边
|
|
18
|
+
* 漂移):首字符字母数字,其余限 [A-Za-z0-9._-] —— profile 名会被拼进 dsh 子进程
|
|
19
|
+
* 命令行(win32 经 shell 拼接),在解析源头就排除空格与 shell 元字符。
|
|
20
|
+
*/
|
|
21
|
+
export const PROFILE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
22
|
+
|
|
16
23
|
/**
|
|
17
24
|
* 主 agent persona 尾部追加规则(默认值)。
|
|
18
25
|
* 用户可在配置 UI 的「人设追加」里编辑/清空;配置键 main_agent_persona_extra:
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 版本提示 —— 检测线上是否有比本地更新的版本(Web 面板 + CLI 共用)。
|
|
3
|
+
*
|
|
4
|
+
* 检测源两级:npm registry 的 dist-tags/latest 优先(无认证无限流,发布即
|
|
5
|
+
* 可见,不像 GitHub release 需要另行打 tag),GitHub releases/latest 兜底。
|
|
6
|
+
*
|
|
7
|
+
* 零依赖纯 Node(>=18):不引 cordis / npm 依赖,fetch 可注入(缺省用
|
|
8
|
+
* globalThis.fetch),超时用 AbortSignal.timeout()。所有失败路径都不抛错,
|
|
9
|
+
* 一律收敛为 { ok: false, error } 或带 error 字段的结果对象——调用方
|
|
10
|
+
* (面板 / CLI 收尾)按「失败即静默」处理,绝不影响主流程。
|
|
11
|
+
*
|
|
12
|
+
* 放在 tools/lib(而非 plugins/paoding-config-ui/lib)的理由:插件 UI → tools
|
|
13
|
+
* 核心的跨树依赖是既有方向(api-core.mjs 已 import '../../tools/install.mjs'),
|
|
14
|
+
* CLI 侧即可就地 import('./version.mjs');npm files 布局包含 tools/,
|
|
15
|
+
* 仓库检出 / npm 包 / profile 安装三种形态下 '../../package.json'(本文件向上
|
|
16
|
+
* 两级)都指向包根。
|
|
17
|
+
*/
|
|
18
|
+
import { readFile } from 'node:fs/promises'
|
|
19
|
+
|
|
20
|
+
// 仓库(与包名一致)的 GitHub latest release 接口与落地页。
|
|
21
|
+
const RELEASE_API = 'https://api.github.com/repos/lifangjin/dsh-paoding/releases/latest'
|
|
22
|
+
const RELEASE_PAGE = 'https://github.com/lifangjin/dsh-paoding/releases/latest'
|
|
23
|
+
// npm registry 的 latest 元数据端点(等价 dist-tags.latest,无认证无限流)。
|
|
24
|
+
const REGISTRY_LATEST = 'https://registry.npmjs.org/dsh-paoding/latest'
|
|
25
|
+
|
|
26
|
+
// 读不到 package.json 时的版本兜底。当前版本不可知(source: 'fallback')时
|
|
27
|
+
// updateAvailable 恒 false —— 拿「0.0.0 恒小于任何真实版本」去比较,反而必然
|
|
28
|
+
// 对任何 latest 误报新版,升级提示必须整体抑制(读不到版本就不提示)。
|
|
29
|
+
const FALLBACK_VERSION = '0.0.0'
|
|
30
|
+
|
|
31
|
+
// 内存缓存 TTL:1 小时内复用上次成功结果,过期才真的发外网请求。
|
|
32
|
+
const CACHE_TTL_MS = 60 * 60 * 1000
|
|
33
|
+
// 请求失败时的 stale-if-error 窗口:上次成功结果在 7 天内仍原样复用(lastGood)。
|
|
34
|
+
// 窗口刻意明显大于新鲜 TTL(7 天 >> 1 小时,旧值 10 分钟比 TTL 还短,分支永远
|
|
35
|
+
// 不可达 = 死逻辑):外网偶发抖动不该立刻撤掉本可用的升级提示;超窗才返回 error。
|
|
36
|
+
const LASTGOOD_TTL_MS = 7 * 24 * 60 * 60 * 1000
|
|
37
|
+
|
|
38
|
+
// 模块级缓存:只在成功后写入;失败结果不进缓存(下次调用仍会重试)。
|
|
39
|
+
let cache = { at: 0, info: null }
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 本地版本:读包根 package.json 的 version。读失败(文件缺失 / 解析失败 /
|
|
43
|
+
* 字段异常)回退 FALLBACK_VERSION 并标记 source: 'fallback'。
|
|
44
|
+
* @returns {Promise<{version: string, source: 'package.json'|'fallback'}>}
|
|
45
|
+
*/
|
|
46
|
+
export async function currentVersion() {
|
|
47
|
+
try {
|
|
48
|
+
const raw = await readFile(new URL('../../package.json', import.meta.url), 'utf8')
|
|
49
|
+
const pkg = JSON.parse(raw)
|
|
50
|
+
if (typeof pkg?.version === 'string' && pkg.version !== '') {
|
|
51
|
+
return { version: pkg.version, source: 'package.json' }
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
/* 静默回退:版本读取失败不该打扰任何主流程 */
|
|
55
|
+
}
|
|
56
|
+
return { version: FALLBACK_VERSION, source: 'fallback' }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 拆版本号:剥前缀 v/V,按 '.' 切数值段,`-` 后为预发布后缀(原样保留)。 */
|
|
60
|
+
function parseVersion(value) {
|
|
61
|
+
let s = String(value ?? '').trim()
|
|
62
|
+
if (s.startsWith('v') || s.startsWith('V')) s = s.slice(1)
|
|
63
|
+
const dash = s.indexOf('-')
|
|
64
|
+
const coreText = dash === -1 ? s : s.slice(0, dash)
|
|
65
|
+
const pre = dash === -1 ? '' : s.slice(dash + 1)
|
|
66
|
+
const core = coreText.split('.').map((n) => {
|
|
67
|
+
const x = parseInt(n, 10)
|
|
68
|
+
return Number.isFinite(x) ? x : 0
|
|
69
|
+
})
|
|
70
|
+
return { core, pre }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 版本比较(可测纯函数):a < b 返回 -1,相等返回 0,a > b 返回 1。
|
|
75
|
+
* 简化语义(不追求完整 semver):剥前缀 v/V 后按 '.' 逐段数值比较,缺段补 0;
|
|
76
|
+
* 核心段相同时带预发布后缀(如 0.3.0-rc.1)视为低于无后缀同版本,两个都有
|
|
77
|
+
* 预发布后缀则按字符串比较。
|
|
78
|
+
*/
|
|
79
|
+
export function compareVersions(a, b) {
|
|
80
|
+
const pa = parseVersion(a)
|
|
81
|
+
const pb = parseVersion(b)
|
|
82
|
+
const len = Math.max(pa.core.length, pb.core.length)
|
|
83
|
+
for (let i = 0; i < len; i++) {
|
|
84
|
+
const x = pa.core[i] ?? 0
|
|
85
|
+
const y = pb.core[i] ?? 0
|
|
86
|
+
if (x !== y) return x < y ? -1 : 1
|
|
87
|
+
}
|
|
88
|
+
if (pa.pre !== pb.pre) {
|
|
89
|
+
if (pa.pre === '') return 1 // 无后缀 > 有后缀
|
|
90
|
+
if (pb.pre === '') return -1
|
|
91
|
+
return pa.pre < pb.pre ? -1 : pa.pre > pb.pre ? 1 : 0
|
|
92
|
+
}
|
|
93
|
+
return 0
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 请求 GitHub latest release。任何失败(网络 / 超时 / 限流 / 解析)都不抛出,
|
|
98
|
+
* 返回 { ok: false, error };仓库尚无 release(404)不算错误,返回
|
|
99
|
+
* { ok: true, tag: null, url: null, notFound: true }。成功返回
|
|
100
|
+
* { ok: true, tag: 'vX.Y.Z', url: releasePage }。
|
|
101
|
+
*/
|
|
102
|
+
export async function fetchLatestRelease({ fetchImpl, timeoutMs = 3000 } = {}) {
|
|
103
|
+
const doFetch = typeof fetchImpl === 'function' ? fetchImpl : globalThis.fetch
|
|
104
|
+
if (typeof doFetch !== 'function') return { ok: false, error: 'fetch 不可用(当前运行时无 fetch 实现)' }
|
|
105
|
+
// GitHub API 必须带 User-Agent,顺手报上包名与本地版本便于排障。
|
|
106
|
+
const { version } = await currentVersion()
|
|
107
|
+
try {
|
|
108
|
+
const res = await doFetch(RELEASE_API, {
|
|
109
|
+
headers: {
|
|
110
|
+
'user-agent': `dsh-paoding/${version}`,
|
|
111
|
+
accept: 'application/vnd.github+json',
|
|
112
|
+
},
|
|
113
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
114
|
+
})
|
|
115
|
+
if (res.status === 404) return { ok: true, tag: null, url: null, notFound: true }
|
|
116
|
+
if (!res.ok) {
|
|
117
|
+
// 非 404 失败(403 限流、5xx 等):带上 GitHub 的 message 便于排障
|
|
118
|
+
//(典型如未认证配额用尽的 rate limit 提示),仍按失败静默收敛。
|
|
119
|
+
let detail = ''
|
|
120
|
+
try {
|
|
121
|
+
const body = await res.json()
|
|
122
|
+
if (body && typeof body.message === 'string' && body.message !== '') detail = `: ${body.message}`
|
|
123
|
+
} catch {
|
|
124
|
+
/* 正文非 JSON 时忽略,只报状态码 */
|
|
125
|
+
}
|
|
126
|
+
return { ok: false, error: `GitHub API HTTP ${res.status}${detail}` }
|
|
127
|
+
}
|
|
128
|
+
let data = null
|
|
129
|
+
try {
|
|
130
|
+
data = await res.json()
|
|
131
|
+
} catch (err) {
|
|
132
|
+
return { ok: false, error: `GitHub API 响应解析失败: ${err?.message ?? err}` }
|
|
133
|
+
}
|
|
134
|
+
const tag = typeof data?.tag_name === 'string' && data.tag_name !== '' ? data.tag_name : null
|
|
135
|
+
if (!tag) return { ok: false, error: 'GitHub API 响应缺少 tag_name' }
|
|
136
|
+
const url = typeof data?.html_url === 'string' && data.html_url !== '' ? data.html_url : RELEASE_PAGE
|
|
137
|
+
return { ok: true, tag, url }
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return { ok: false, error: String(err?.message ?? err) }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 请求 npm registry 的 latest 元数据(版本检测的首选源,runUpgrade 也复用)。
|
|
145
|
+
* 任何失败(网络 / 超时 / 解析 / 形状不对)都不抛出,返回 { ok: false, error };
|
|
146
|
+
* 成功返回 { ok: true, latest: 'vX.Y.Z', releaseUrl, source: 'npm' }(registry
|
|
147
|
+
* 的 version 不带 v 前缀,这里统一补齐,与 GitHub tag 的显示口径一致)。
|
|
148
|
+
*/
|
|
149
|
+
export async function fetchLatestFromRegistry({ fetchImpl, timeoutMs = 3000 } = {}) {
|
|
150
|
+
const doFetch = typeof fetchImpl === 'function' ? fetchImpl : globalThis.fetch
|
|
151
|
+
if (typeof doFetch !== 'function') return { ok: false, error: 'fetch 不可用(当前运行时无 fetch 实现)' }
|
|
152
|
+
try {
|
|
153
|
+
const res = await doFetch(REGISTRY_LATEST, {
|
|
154
|
+
headers: { accept: 'application/vnd.npm+json' },
|
|
155
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
156
|
+
})
|
|
157
|
+
if (!res.ok) return { ok: false, error: `npm registry HTTP ${res.status}` }
|
|
158
|
+
let data = null
|
|
159
|
+
try {
|
|
160
|
+
data = await res.json()
|
|
161
|
+
} catch (err) {
|
|
162
|
+
return { ok: false, error: `npm registry 响应解析失败: ${err?.message ?? err}` }
|
|
163
|
+
}
|
|
164
|
+
// /<name>/latest 返回该版本的完整 manifest,顶层 version 即 latest 版本号;
|
|
165
|
+
// CDN 缓存抖动可能回形状不对的正文,按失败处理走 GitHub 兜底。
|
|
166
|
+
const ver = typeof data?.version === 'string' && data.version !== '' ? data.version : null
|
|
167
|
+
if (!ver) return { ok: false, error: 'npm registry 响应缺少 version' }
|
|
168
|
+
return { ok: true, latest: `v${ver}`, releaseUrl: RELEASE_PAGE, source: 'npm' }
|
|
169
|
+
} catch (err) {
|
|
170
|
+
return { ok: false, error: String(err?.message ?? err) }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 组合结果(面板路由与 CLI 收尾共用):带 1 小时内存缓存,过期才重新请求;
|
|
176
|
+
* 请求失败时若上次成功结果在 lastGood 窗口(7 天,见 LASTGOOD_TTL_MS)内则
|
|
177
|
+
* 原样复用(stale-if-error),否则返回带 error 的降级结果(latest 置 null、
|
|
178
|
+
* updateAvailable 恒 false——调用方静默)。当前版本不可知(FALLBACK_VERSION
|
|
179
|
+
* 兜底)时 updateAvailable 恒 false:0.0.0 对任何 latest 都「更小」,比较必
|
|
180
|
+
* 误报新版,升级提示整体抑制。检测源 npm registry 优先,失败再试 GitHub
|
|
181
|
+
* release;双败才算本次检测失败,error 里带上两级的失败原因便于排障。成功
|
|
182
|
+
* 结果的 source 字段记录命中源。
|
|
183
|
+
* @param {{fetchImpl?: Function, timeoutMs?: number, now?: Function|number}} opts
|
|
184
|
+
* now 可注入函数或时间戳(测试用);缺省 Date.now()。
|
|
185
|
+
* @returns {Promise<{current: string, latest: string|null, updateAvailable: boolean,
|
|
186
|
+
* releaseUrl: string|null, checkedAt: string, error: string|null,
|
|
187
|
+
* source: 'npm'|'github'|null}>}
|
|
188
|
+
*/
|
|
189
|
+
export async function getVersionInfo({ fetchImpl, timeoutMs = 3000, now } = {}) {
|
|
190
|
+
const nowMs = typeof now === 'function' ? now() : typeof now === 'number' ? now : Date.now()
|
|
191
|
+
if (cache.info && nowMs - cache.at < CACHE_TTL_MS) return cache.info
|
|
192
|
+
const { version: current, source: currentSource } = await currentVersion()
|
|
193
|
+
// ① npm registry 优先;② 失败时 GitHub releases/latest 兜底。
|
|
194
|
+
const reg = await fetchLatestFromRegistry({ fetchImpl, timeoutMs })
|
|
195
|
+
let hit = null
|
|
196
|
+
let githubErr = null
|
|
197
|
+
if (reg.ok) {
|
|
198
|
+
hit = reg
|
|
199
|
+
} else {
|
|
200
|
+
const rel = await fetchLatestRelease({ fetchImpl, timeoutMs })
|
|
201
|
+
if (rel.ok) hit = { ok: true, latest: rel.tag, releaseUrl: rel.url, source: 'github' }
|
|
202
|
+
else githubErr = rel.error
|
|
203
|
+
}
|
|
204
|
+
if (hit && hit.ok) {
|
|
205
|
+
const info = {
|
|
206
|
+
current,
|
|
207
|
+
latest: hit.latest ?? null,
|
|
208
|
+
// 当前版本不可知(fallback 0.0.0)时不提示升级,latest 缺失同理。
|
|
209
|
+
updateAvailable:
|
|
210
|
+
currentSource === 'fallback' || !hit.latest ? false : compareVersions(current, hit.latest) < 0,
|
|
211
|
+
releaseUrl: hit.releaseUrl ?? null,
|
|
212
|
+
checkedAt: new Date(nowMs).toISOString(),
|
|
213
|
+
error: null,
|
|
214
|
+
source: hit.source ?? null,
|
|
215
|
+
}
|
|
216
|
+
cache = { at: nowMs, info }
|
|
217
|
+
return info
|
|
218
|
+
}
|
|
219
|
+
// 双失败:优先复用 lastGood 窗口(7 天)内的上次成功结果;超窗才给降级结果
|
|
220
|
+
// (不写缓存)。error 把 registry 与 GitHub 两级原因都带上(谁挂了一目了然);
|
|
221
|
+
// 走到这里 githubErr 必有值(能进兜底说明 registry 已失败),无需再兜底文案。
|
|
222
|
+
const combined = `${reg.error};GitHub 兜底也失败: ${githubErr}`
|
|
223
|
+
if (cache.info && nowMs - cache.at < LASTGOOD_TTL_MS) return cache.info
|
|
224
|
+
return {
|
|
225
|
+
current,
|
|
226
|
+
latest: null,
|
|
227
|
+
updateAvailable: false,
|
|
228
|
+
releaseUrl: null,
|
|
229
|
+
checkedAt: new Date(nowMs).toISOString(),
|
|
230
|
+
error: combined,
|
|
231
|
+
source: null,
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工作区 slug 与 preset id:工作区目录路径 → 稳定的 preset 目录名后缀。
|
|
3
|
+
* sanitizeSlugBase 取 basename 归一成合法 id 片段(DSH preset 目录名约束
|
|
4
|
+
* /^[a-z0-9][a-z0-9-]*$/),stableHash 用 sha1 前 6 位给冲突路径加后缀,
|
|
5
|
+
* assignSlugs 对整组路径一次性分配(同 basename 冲突才加 hash,非冲突路径
|
|
6
|
+
* 保持可读的裸 basename),presetIdOf 拼出最终 preset id。
|
|
7
|
+
* 仅依赖 node:crypto / node:path;被 state(工作区 preset 落盘定位)引用,
|
|
8
|
+
* 并经 tools/install.mjs re-export 供插件侧(api-core 的 workspaceMeta)消费。
|
|
9
|
+
*/
|
|
10
|
+
import { createHash } from 'node:crypto'
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* basename → slug 片段:小写;[^a-z0-9-]+ 折叠为 '-';去首尾 '-'。
|
|
15
|
+
* 空串(如 basename 为 '/' 或全非法字符)与保留名 'orchestrator'(会撞基础
|
|
16
|
+
* preset 本体)一律回落 'ws',保证结果永远是合法且不冲突的 id 片段。
|
|
17
|
+
*/
|
|
18
|
+
export function sanitizeSlugBase(base) {
|
|
19
|
+
let slug = String(base ?? '')
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
22
|
+
.replace(/^-+|-+$/g, '')
|
|
23
|
+
if (slug === '' || slug === 'orchestrator') slug = 'ws'
|
|
24
|
+
return slug
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 稳定短哈希:sha1 hex 前 6 位。只用于 slug 冲突消解,非安全用途。 */
|
|
28
|
+
export function stableHash(str) {
|
|
29
|
+
return createHash('sha1').update(String(str)).digest('hex').slice(0, 6)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 给一组工作区绝对路径分配 slug(Map<path, slug>):basename 互不冲突的路径
|
|
34
|
+
* 直接用 basename;同 basename 的多个路径(含重复路径)各自加 stableHash(path)
|
|
35
|
+
* 后缀。先数后派两趟走,保证同一批输入下每个路径的 slug 稳定且互不相同;
|
|
36
|
+
* 分配完再做一次 Set 唯一性断言——basename+hash 组合仍可能相撞(如某路径的
|
|
37
|
+
* basename 恰为另一路径的 `<base>-<hash>` 形态),冲突者追加一层 hash 后缀
|
|
38
|
+
* 重试,直到全组互不相同。
|
|
39
|
+
*/
|
|
40
|
+
export function assignSlugs(paths) {
|
|
41
|
+
const list = (Array.isArray(paths) ? paths : []).map((p) => String(p)).filter((p) => p !== '')
|
|
42
|
+
const baseCount = new Map()
|
|
43
|
+
for (const p of list) {
|
|
44
|
+
const base = sanitizeSlugBase(path.basename(p))
|
|
45
|
+
baseCount.set(base, (baseCount.get(base) ?? 0) + 1)
|
|
46
|
+
}
|
|
47
|
+
const out = new Map()
|
|
48
|
+
for (const p of list) {
|
|
49
|
+
const base = sanitizeSlugBase(path.basename(p))
|
|
50
|
+
out.set(p, baseCount.get(base) > 1 ? `${base}-${stableHash(p)}` : base)
|
|
51
|
+
}
|
|
52
|
+
// 唯一性断言 + 冲突消解:按插入序扫一遍,撞名者依次试 `<slug>-<hash(p#n)>`。
|
|
53
|
+
// hash 输入含完整路径与轮次,实际上第一轮即唯一;上限兜底防病态输入死循环。
|
|
54
|
+
const seen = new Set()
|
|
55
|
+
for (const [p, slug] of out) {
|
|
56
|
+
if (!seen.has(slug)) {
|
|
57
|
+
seen.add(slug)
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
let candidate = slug
|
|
61
|
+
for (let round = 1; seen.has(candidate) && round <= 64; round++) {
|
|
62
|
+
candidate = `${slug}-${stableHash(`${p}#${round}`)}`
|
|
63
|
+
}
|
|
64
|
+
if (seen.has(candidate)) throw new Error(`assignSlugs: cannot derive a unique slug for ${p}`)
|
|
65
|
+
out.set(p, candidate)
|
|
66
|
+
seen.add(candidate)
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 工作区 slug → preset id(即 .agent-presets/ 下的目录名)。 */
|
|
72
|
+
export function presetIdOf(slug) {
|
|
73
|
+
return `orchestrator-${slug}`
|
|
74
|
+
}
|
package/tools/lib/yaml.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { warn } from './util.mjs'
|
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Resolve the `yaml` package from $DSH_HOME/node_modules via createRequire.
|
|
17
|
-
* The directory is
|
|
17
|
+
* The directory is managed by the dsh host (profile installs included), which
|
|
18
18
|
* ships yaml v2. Returns null when unavailable — callers then use the
|
|
19
19
|
* built-in fallback parser.
|
|
20
20
|
*/
|
|
@@ -78,16 +78,56 @@ export function parsePatchFile(file, yamlMod) {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/** 块标量值标记行:`|` / `>` 及裁剪(-)/保留(+)/显式缩进指示符(数字)的
|
|
82
|
+
* 全部组合变体(如 `|-`、`|2`、`|2-`、`>-`),两个指示符先后顺序均兼容。 */
|
|
83
|
+
const BLOCK_SCALAR_MARKER_RE = /^[|>][+-]?\d*[+-]?$/
|
|
84
|
+
|
|
81
85
|
/**
|
|
82
86
|
* Minimal YAML subset parser, used only for patch files when `yaml` is
|
|
83
87
|
* unavailable. Supports: top-level list items, nested maps, scalar values
|
|
84
88
|
* (single/double-quoted strings, numbers, true/false), `#` comments and
|
|
85
|
-
* `- insert:` wrapping.
|
|
89
|
+
* `- insert:` wrapping. Map keys may be quoted(workspaces 的路径键一律带
|
|
90
|
+
* 引号落盘,见 unquoteKey)—— 嵌套 map + 带引号键按同款规则解析。
|
|
91
|
+
* 块标量(| > 变体)正文原样保留:注释与空行在块标量里是数据不是结构,
|
|
92
|
+
* 预处理不得剥除(此前 loadConfig→saveConfig 会把 persona 里的注释/空段
|
|
93
|
+
* 静默抹掉)。两趟走:先在原文上标出块标量正文行,再只对结构行做剥注释/
|
|
94
|
+
* 丢空行。
|
|
86
95
|
*/
|
|
87
96
|
export function parseYamlSubset(text) {
|
|
97
|
+
const rawLines = text.split(/\r?\n/)
|
|
98
|
+
// 第一趟:标记块标量正文行。结构行(剥注释后)以块标量标记结尾即开块,
|
|
99
|
+
// 其后「空行或缩进更深」的原文行都是正文(与 consumeBlockScalar 的相对
|
|
100
|
+
// 深度判定一致),直到缩进 <= 键缩进的结构行收块。
|
|
101
|
+
const inBody = new Array(rawLines.length).fill(false)
|
|
102
|
+
let bodyIndent = -1
|
|
103
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
104
|
+
const raw = rawLines[i]
|
|
105
|
+
if (bodyIndent !== -1) {
|
|
106
|
+
if (raw.trim() === '' || raw.match(/^[ \t]*/)[0].length > bodyIndent) {
|
|
107
|
+
inBody[i] = true
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
bodyIndent = -1 // 落到下方按结构行处理
|
|
111
|
+
}
|
|
112
|
+
const stripped = stripInlineComment(raw).replace(/[ \t]+$/, '')
|
|
113
|
+
if (stripped.trim() === '') continue
|
|
114
|
+
const indent = stripped.match(/^[ \t]*/)[0].length
|
|
115
|
+
const probe = stripped.replace(/^[ \t]*-[ \t]+/, '') // 列表项 `- key: |-` 同款判定
|
|
116
|
+
if (looksLikeKeyValue(probe)) {
|
|
117
|
+
const [, value] = splitKeyValue(probe)
|
|
118
|
+
if (value !== undefined && BLOCK_SCALAR_MARKER_RE.test(value)) bodyIndent = indent
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// 第二趟:结构行剥注释/去尾空白/丢空行;正文行整行原样入列(body 标记
|
|
122
|
+
// 供 consumeBlockScalar 跳过缩进判定,空行/浅缩进注释都算正文)。
|
|
88
123
|
const lines = []
|
|
89
|
-
for (
|
|
90
|
-
|
|
124
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
125
|
+
if (inBody[i]) {
|
|
126
|
+
const indent = rawLines[i].match(/^[ \t]*/)[0].length
|
|
127
|
+
lines.push({ indent, text: rawLines[i].slice(indent), body: true })
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
const line = stripInlineComment(rawLines[i]).replace(/[ \t]+$/, '')
|
|
91
131
|
if (line.trim() === '') continue
|
|
92
132
|
const indent = line.match(/^[ \t]*/)[0].length
|
|
93
133
|
lines.push({ indent, text: line.slice(indent) })
|
|
@@ -107,7 +147,9 @@ export function stripInlineComment(line) {
|
|
|
107
147
|
continue
|
|
108
148
|
}
|
|
109
149
|
if (inDouble) {
|
|
110
|
-
|
|
150
|
+
// 双引号内的 \" 是转义引号:翻转前回看前一字符是否为 \,否则含转义
|
|
151
|
+
// 引号的值会提前/推迟结束字符串,把真实注释误当正文(或反之截断)。
|
|
152
|
+
if (c === '"' && line[i - 1] !== '\\') inDouble = false
|
|
111
153
|
continue
|
|
112
154
|
}
|
|
113
155
|
if (c === "'") inSingle = true
|
|
@@ -118,17 +160,22 @@ export function stripInlineComment(line) {
|
|
|
118
160
|
return line
|
|
119
161
|
}
|
|
120
162
|
|
|
121
|
-
/** YAML 块标量(| >
|
|
122
|
-
*
|
|
163
|
+
/** YAML 块标量(| > 及其折叠/裁剪/显式缩进指示符变体)值标记:后续更深的行
|
|
164
|
+
* 全部属该标量正文,直到遇到缩进 <= 键缩进的行(同级键/父级结束)。正文行带
|
|
165
|
+
* body 标记(parseYamlSubset 预处理产出):空行/注释行在正文里是数据,不受
|
|
166
|
+
* keyIndent 判定约束,原样并入。返回拼接正文与下一行下标。
|
|
123
167
|
* 说明:parseYamlSubset 是 yaml 包缺失时的回退解析器;此前块标量(如角色
|
|
124
168
|
* persona: |-)之后的同级键/顶层键(roles_remove / main_agent_extra 等)会被
|
|
125
|
-
*
|
|
169
|
+
* 误判为无法解析而静默丢弃,导致回读配置丢键;正文里的注释/空行也会被预处理
|
|
170
|
+
* 剥掉(loadConfig→saveConfig 往返丢段落)。尾部空行按裁剪(clip)语义去掉,
|
|
171
|
+
* 中段空行原样保留。 */
|
|
126
172
|
export function consumeBlockScalar(lines, i, keyIndent) {
|
|
127
173
|
const parts = []
|
|
128
|
-
while (i < lines.length && lines[i].indent > keyIndent) {
|
|
174
|
+
while (i < lines.length && (lines[i].body || lines[i].indent > keyIndent)) {
|
|
129
175
|
parts.push(lines[i].text)
|
|
130
176
|
i++
|
|
131
177
|
}
|
|
178
|
+
while (parts.length > 0 && parts[parts.length - 1] === '') parts.pop()
|
|
132
179
|
return { value: parts.join('\n'), next: i }
|
|
133
180
|
}
|
|
134
181
|
|
|
@@ -164,7 +211,11 @@ export function parseList(lines, i, indent) {
|
|
|
164
211
|
}
|
|
165
212
|
} else {
|
|
166
213
|
let parsed = parseScalar(value)
|
|
167
|
-
if (
|
|
214
|
+
if (
|
|
215
|
+
BLOCK_SCALAR_MARKER_RE.test(String(value).trim()) &&
|
|
216
|
+
i < lines.length &&
|
|
217
|
+
(lines[i].indent > indent || lines[i].body)
|
|
218
|
+
) {
|
|
168
219
|
const blk = consumeBlockScalar(lines, i, indent)
|
|
169
220
|
parsed = blk.value
|
|
170
221
|
i = blk.next
|
|
@@ -202,7 +253,11 @@ export function parseMap(lines, i, indent) {
|
|
|
202
253
|
}
|
|
203
254
|
} else {
|
|
204
255
|
let parsed = parseScalar(value)
|
|
205
|
-
if (
|
|
256
|
+
if (
|
|
257
|
+
BLOCK_SCALAR_MARKER_RE.test(String(value).trim()) &&
|
|
258
|
+
i < lines.length &&
|
|
259
|
+
(lines[i].indent > indent || lines[i].body)
|
|
260
|
+
) {
|
|
206
261
|
const blk = consumeBlockScalar(lines, i, indent)
|
|
207
262
|
parsed = blk.value
|
|
208
263
|
i = blk.next
|
|
@@ -219,13 +274,35 @@ export function looksLikeKeyValue(rest) {
|
|
|
219
274
|
return trimmed.includes(': ') || trimmed.includes(':\t')
|
|
220
275
|
}
|
|
221
276
|
|
|
222
|
-
/**
|
|
277
|
+
/**
|
|
278
|
+
* 键名剥壳:workspaces 段的路径键一律单引号包裹落盘——路径可含 ':' / '#' 等
|
|
279
|
+
* 会被键值切分或行内注释截断的歧义字符,裸写有歧义。回读时必须剥掉引号还原
|
|
280
|
+
* 真实键,否则 workspaces.<path> 查不到条目;普通键名不含引号,原样放行。
|
|
281
|
+
* (局限:带引号键内含 ': ' 且同行还跟标量值时仍会切错位——本解析器只作
|
|
282
|
+
* yaml 包缺失时的回退,serializeConfig 写出的工作区键均为独立行,不受影响。)
|
|
283
|
+
*/
|
|
284
|
+
export function unquoteKey(key) {
|
|
285
|
+
if (key.length >= 2 && key.startsWith("'") && key.endsWith("'")) {
|
|
286
|
+
return key.slice(1, -1).replace(/''/g, "'")
|
|
287
|
+
}
|
|
288
|
+
if (key.length >= 2 && key.startsWith('"') && key.endsWith('"')) {
|
|
289
|
+
try {
|
|
290
|
+
return JSON.parse(key)
|
|
291
|
+
} catch {
|
|
292
|
+
return key.slice(1, -1)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return key
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Split `key: value` at the first colon; a trailing `key:` yields undefined.
|
|
299
|
+
* 键名过 unquoteKey 剥壳(见上:workspaces 带引号路径键)。 */
|
|
223
300
|
export function splitKeyValue(rest) {
|
|
224
301
|
const trimmed = rest.trim()
|
|
225
|
-
if (trimmed.endsWith(':')) return [trimmed.slice(0, -1).trim(), undefined]
|
|
302
|
+
if (trimmed.endsWith(':')) return [unquoteKey(trimmed.slice(0, -1).trim()), undefined]
|
|
226
303
|
const idx = trimmed.indexOf(': ')
|
|
227
|
-
if (idx === -1) return [trimmed, undefined]
|
|
228
|
-
return [trimmed.slice(0, idx).trim(), trimmed.slice(idx + 2).trim()]
|
|
304
|
+
if (idx === -1) return [unquoteKey(trimmed), undefined]
|
|
305
|
+
return [unquoteKey(trimmed.slice(0, idx).trim()), trimmed.slice(idx + 2).trim()]
|
|
229
306
|
}
|
|
230
307
|
|
|
231
308
|
export function parseScalar(raw) {
|
package/install.sh
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
#
|
|
3
|
-
# dsh-paoding installer — thin shell wrapper around tools/install.mjs, which
|
|
4
|
-
# installs the orchestrator preset into $DSH_HOME/.agent-presets/ (the roster
|
|
5
|
-
# root dsh-agent-presets scans for locally authored presets). The Node
|
|
6
|
-
# generator detects the host's enabled MCP servers and local tool plugins at
|
|
7
|
-
# install time, resolves their exact tool names, and rewrites the role
|
|
8
|
-
# toolFilter.allow lists in agent.cordis.yml — so tools.restrict() never
|
|
9
|
-
# rejects a child agent for an unknown host-tool name.
|
|
10
|
-
#
|
|
11
|
-
# Usage: ./install.sh [--profile <name>] [--patch <file> ...] [--dry-run] [--help]
|
|
12
|
-
# npx equivalent: npx dsh-paoding [options] (same options; defaults to --auto --config-ui)
|
|
13
|
-
# After installing: restart the dsh host (or open a new session), then pick
|
|
14
|
-
# 「编排模式 (Orchestrator)」 in the new-session preset selector. To make it
|
|
15
|
-
# the default, set it in Settings → Agent Presets.
|
|
16
|
-
#
|
|
17
|
-
# Uninstall: rm -rf "${DSH_HOME:-$HOME/.dsh}/.agent-presets/orchestrator"
|
|
18
|
-
|
|
19
|
-
set -euo pipefail
|
|
20
|
-
|
|
21
|
-
DSH_HOME="${DSH_HOME:-$HOME/.dsh}"
|
|
22
|
-
export DSH_HOME
|
|
23
|
-
|
|
24
|
-
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
25
|
-
|
|
26
|
-
if ! command -v node >/dev/null 2>&1; then
|
|
27
|
-
echo "error: node (>=18) is required to run the dsh-paoding installer" >&2
|
|
28
|
-
exit 1
|
|
29
|
-
fi
|
|
30
|
-
|
|
31
|
-
exec node "$REPO_ROOT/tools/install.mjs" "$@"
|