@noob-stupid/dsh-plugin-console 0.3.24 → 0.3.26

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/lib/index.js CHANGED
@@ -200,6 +200,171 @@ function entryPkgMeta(moduleName, baseUrl) {
200
200
  return meta
201
201
  }
202
202
 
203
+ /** 最小 semver:解析(含 prerelease/build)。 */
204
+ function parseSemverText(v) {
205
+ const m = String(v ?? '').trim().replace(/^v/u, '').match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u)
206
+ if (!m) return null
207
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), pre: m[4] ?? null }
208
+ }
209
+ function compareSemverText(a, b) {
210
+ if (a.major !== b.major) return a.major - b.major
211
+ if (a.minor !== b.minor) return a.minor - b.minor
212
+ if (a.patch !== b.patch) return a.patch - b.patch
213
+ if (a.pre === null && b.pre === null) return 0
214
+ if (a.pre === null) return 1
215
+ if (b.pre === null) return -1
216
+ const pa = a.pre.split('.')
217
+ const pb = b.pre.split('.')
218
+ const len = Math.max(pa.length, pb.length)
219
+ for (let i = 0; i < len; i += 1) {
220
+ const xa = pa[i]
221
+ const xb = pb[i]
222
+ if (xa === undefined) return -1
223
+ if (xb === undefined) return 1
224
+ const na = /^\d+$/u.test(xa)
225
+ const nb = /^\d+$/u.test(xb)
226
+ if (na && nb) { const d = Number(xa) - Number(xb); if (d !== 0) return d; continue }
227
+ if (na) return -1
228
+ if (nb) return 1
229
+ const d = xa < xb ? -1 : xa > xb ? 1 : 0
230
+ if (d !== 0) return d
231
+ }
232
+ return 0
233
+ }
234
+ /** 单段范围匹配(^ ~ >= <= > < = 精确;返回 true = 满足)。 */
235
+ function semverCompareOne(v, raw) {
236
+ const rText = String(raw).trim()
237
+ const m = rText.match(/^(\^|~|>=|<=|>|<|=)?\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?/u)
238
+ if (!m) return true
239
+ const op = m[1] ?? ''
240
+ const base = { major: Number(m[2]), minor: m[3] === undefined ? 0 : Number(m[3]), patch: m[4] === undefined ? 0 : Number(m[4]), pre: m[5] ?? null }
241
+ const hasMinor = m[3] !== undefined
242
+ const hasPatch = m[4] !== undefined
243
+ // npm 语义:prerelease 版本只与同 [major,minor,patch] 且范围带 prerelease 的声明匹配
244
+ const preAllowed = v.pre === null || (v.major === base.major && v.minor === base.minor && v.patch === base.patch && base.pre !== null)
245
+ switch (op) {
246
+ case '':
247
+ case '=':
248
+ return preAllowed && compareSemverText(v, base) === 0
249
+ case '<':
250
+ return preAllowed && compareSemverText(v, base) < 0
251
+ case '<=':
252
+ return preAllowed && compareSemverText(v, base) <= 0
253
+ case '>':
254
+ return preAllowed && compareSemverText(v, base) > 0
255
+ case '>=':
256
+ return preAllowed && compareSemverText(v, base) >= 0
257
+ case '~': {
258
+ if (!preAllowed) return false
259
+ if (!hasMinor) return v.major === base.major
260
+ if (!hasPatch) return v.major === base.major && v.minor === base.minor
261
+ return compareSemverText(v, base) >= 0 && !(v.major === base.major && v.minor > base.minor) && v.major === base.major
262
+ }
263
+ case '^': {
264
+ if (!preAllowed) return false
265
+ const upper = base.major === 0
266
+ ? (base.minor === 0 ? { major: 0, minor: 0, patch: base.patch + 1, pre: null } : { major: 0, minor: base.minor + 1, patch: 0, pre: null })
267
+ : { major: base.major + 1, minor: 0, patch: 0, pre: null }
268
+ return compareSemverText(v, base) >= 0 && compareSemverText(v, upper) < 0
269
+ }
270
+ default:
271
+ return true
272
+ }
273
+ }
274
+ /** 范围匹配:支持多个段以逗号/空白分隔(AND)与 `||`(OR)。 */
275
+ function semverRangeMatch(versionText, rangeText) {
276
+ const v = parseSemverText(versionText)
277
+ if (!v) return false
278
+ const alternatives = String(rangeText ?? '').split(/\s*\|\|\s*/u).filter(Boolean)
279
+ if (alternatives.length === 0) return false
280
+ return alternatives.some((alt) => {
281
+ const parts = alt.split(/\s*[,\s]\s*/u).filter(Boolean)
282
+ return parts.length > 0 && parts.every((part) => semverCompareOne(v, part))
283
+ })
284
+ }
285
+ /**
286
+ * 宽松声明匹配(仅用于插件「显式声明兼容范围」):prerelease 版本按同线发布版判定——
287
+ * 作者声明 `>=0.1.2` 即代表支持 0.1.2 线,框架运行在 0.1.2-rc.1 应判定兼容。
288
+ */
289
+ function semverRangeMatchLoose(versionText, rangeText) {
290
+ if (semverRangeMatch(versionText, rangeText)) return true
291
+ const v = parseSemverText(versionText)
292
+ if (v === null || v.pre === null) return false
293
+ return semverRangeMatch(`${v.major}.${v.minor}.${v.patch}`, rangeText)
294
+ }
295
+
296
+ /**
297
+ * 扫描已安装包源码,检测对已删除的 dsh-settings API 的引用
298
+ * (2026-09-04 教训:0.1.2-rc.1 起 settingsNamespace / installSettingsSection 已删除,
299
+ * 静态声明检查判 pass 是假通过;真实兼容性只有模块 import 时见分晓,而 loader 单行失败
300
+ * = 整个服务启动崩溃)。返回命中的符号列表,空数组 = 干净。
301
+ */
302
+ function scanSettingsApiUsage(pkgDir) {
303
+ const found = []
304
+ const seen = new Set()
305
+ const walk = (dir, depth) => {
306
+ if (depth > 3) return
307
+ let entries = []
308
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
309
+ for (const entry of entries) {
310
+ const full = join(dir, entry.name)
311
+ if (entry.isDirectory()) {
312
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
313
+ walk(full, depth + 1)
314
+ continue
315
+ }
316
+ if (!entry.isFile() || !/\.(?:js|mjs|cjs)$/u.test(entry.name)) continue
317
+ if (seen.has(full)) continue
318
+ seen.add(full)
319
+ if (seen.size > 120) return
320
+ try {
321
+ const text = readFileSync(full, 'utf8').slice(0, 400000)
322
+ for (const sym of ['settingsNamespace', 'installSettingsSection']) {
323
+ if (text.includes(sym) && !found.includes(sym)) found.push(sym)
324
+ }
325
+ } catch {}
326
+ }
327
+ }
328
+ walk(pkgDir, 0)
329
+ return found
330
+ }
331
+
332
+ /**
333
+ * 插件-框架兼容校验(框架升级适配门):
334
+ * 1) 显式兼容声明(dsh.engines.framework / dsh.compat.framework / engines.dsh):以声明为准;
335
+ * 2) 扫描依赖/peerDeps 的 @deepseek-ai/*:任一范围明确不含框架版本 → fail;
336
+ * 3) 【硬判据】提供 pkgDir 时扫描包源码:命中已删除的 dsh-settings API
337
+ * (settingsNamespace / installSettingsSection)→ 直接 fail(2026-09-04 教训:
338
+ * 0.3.6 全家仍引用旧 API,静态声明/依赖检查判 pass 是假通过,loader 单行失败 = 服务崩溃);
339
+ * 4) 无声明且依赖全部满足 → unknown(新版变动不大;由调用方在「版本已变化」前提下放行)。
340
+ * 返回 { decision: 'pass'|'fail'|'unknown', reason }。
341
+ */
342
+ function checkPluginFrameworkCompat(pkg, frameworkVersion, pkgDir = null) {
343
+ const declared = pkg?.dsh?.engines?.framework ?? pkg?.dsh?.compat?.framework ?? pkg?.engines?.dsh ?? null
344
+ if (typeof declared === 'string' && declared !== '') {
345
+ const ok = semverRangeMatchLoose(frameworkVersion, declared)
346
+ if (!ok) return { decision: 'fail', reason: `声明兼容范围 ${declared} 不满足当前框架 ${frameworkVersion}` }
347
+ }
348
+ // 硬判据:实际源码扫描(比声明/依赖更接近真实兼容性)
349
+ if (pkgDir !== null && typeof pkgDir === 'string' && semverRangeMatchLoose(frameworkVersion, '>=0.1.2')) {
350
+ const broken = scanSettingsApiUsage(pkgDir)
351
+ if (broken.length > 0) {
352
+ return { decision: 'fail', reason: `源码仍引用 0.1.2 起已删除的 dsh-settings API(${broken.join('、')})——实际不兼容,启用会让整个服务启动崩溃` }
353
+ }
354
+ if (typeof declared === 'string' && declared !== '') return { decision: 'pass', reason: `声明兼容范围 ${declared} 满足框架 ${frameworkVersion},且源码无已删除 API 引用` }
355
+ } else if (typeof declared === 'string' && declared !== '') {
356
+ return { decision: 'pass', reason: `声明兼容范围 ${declared} 满足框架 ${frameworkVersion}` }
357
+ }
358
+ const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.peerDependencies ?? {}), ...(pkg?.optionalDependencies ?? {}) }
359
+ const hits = Object.entries(deps).filter(([name]) => /^@deepseek-ai\//u.test(name))
360
+ const failing = hits.filter(([, range]) => typeof range === 'string' && range !== '' && !semverRangeMatch(frameworkVersion, range))
361
+ if (failing.length > 0) {
362
+ return { decision: 'fail', reason: `依赖声明不满足:${failing.map(([n, r]) => `${n}@${r}`).join('、')}(当前框架 ${frameworkVersion})` }
363
+ }
364
+ if (hits.length === 0) return { decision: 'unknown', reason: '未声明对 @deepseek-ai/* 的依赖,无法从声明判定(版本已更新时放行)' }
365
+ return { decision: 'unknown', reason: '依赖范围包含当前框架版本但未显式声明兼容(版本已更新时放行)' }
366
+ }
367
+
203
368
  /** 读取用户额外 bundle(非官方模板)的补丁插入行 id 与包名,用于"额外插件"判定。 */
204
369
  async function readExtraBundleRows(profileDir) {
205
370
  const rows = new Set()
@@ -683,6 +848,18 @@ async function detectCompat(baseUrl) {
683
848
 
684
849
  const FRAMEWORK_STATE_FILE = () => join(dshHome(), 'plugin-console', 'framework-state.json')
685
850
  const FRAMEWORK_BACKUP_ROOT = () => join(dshHome(), 'plugin-console', 'framework-backups')
851
+ const COMPAT_PENDING_FILE = () => join(dshHome(), 'plugin-console', 'compat-pending.json')
852
+
853
+ /** 读取框架升级适配门清单(compat-pending.json);损坏/缺失返回 null。 */
854
+ function readCompatPending() {
855
+ try { return JSON.parse(readFileSync(COMPAT_PENDING_FILE(), 'utf8')) } catch { return null }
856
+ }
857
+ function writeCompatPending(payload) {
858
+ try {
859
+ mkdirSync(dirname(COMPAT_PENDING_FILE()), { recursive: true })
860
+ writeFileSync(COMPAT_PENDING_FILE(), JSON.stringify(payload, null, 2), 'utf8')
861
+ } catch {}
862
+ }
686
863
 
687
864
  /** 定位 @deepseek-ai/dsh-app-boot(与 @deepseek-ai/dsh 同级)。 */
688
865
  function locateAppBootFile(baseUrl) {
@@ -1356,56 +1533,114 @@ async function fetchSkillMeta(repo, branch, skillDir) {
1356
1533
  * 全部条目并发 + raw 主站与镜像双通道竞速,单条 4 秒封顶。
1357
1534
  * 聚合仓库(根包 private+workspaces)额外检查子包是否有 dsh.bundle 清单:
1358
1535
  * 任一子包可 `dsh plugin add` 直装 → aggregateInstallable = true(★ 筛选会包含它)。 */
1359
- async function enrichItems(items) {
1360
- return Promise.all(items.map(async (item) => {
1361
- let official = null
1362
- let aggregate = false
1363
- let aggregateInstallable = false
1364
- let hasSkill = false
1365
- let hasSuite = false
1366
- try {
1367
- const branch = item.defaultBranch ?? 'main'
1368
- const base = `https://raw.githubusercontent.com/${item.fullName}/${encodeURIComponent(branch)}/package.json`
1369
- const [pkgResult, skillResult, suiteResult] = await Promise.allSettled([
1370
- Promise.any([
1371
- curlText(base, 4000),
1372
- curlText(`https://ghproxy.net/${base}`, 4000),
1373
- ]),
1374
- detectSkillRepo(item.fullName, branch),
1375
- rawTextWithFallback(item.fullName, branch, '.gitmodules'),
1376
- ])
1377
- if (suiteResult.status === 'fulfilled' && suiteResult.value !== null) {
1378
- hasSuite = true
1379
- }
1380
- if (pkgResult.status === 'fulfilled') {
1381
- const pkg = JSON.parse(pkgResult.value)
1382
- if (typeof pkg.dsh?.bundle?.patch === 'string') {
1383
- official = true
1384
- } else if (pkg.private === true && (Array.isArray(pkg.workspaces) || /(^|-)dsh[-/]/u.test(String(pkg.name ?? '')))) {
1385
- aggregate = true
1386
- try {
1387
- const tree = await curlJson(`https://api.github.com/repos/${item.fullName}/git/trees/${encodeURIComponent(branch)}?recursive=1`, 6000)
1388
- const pkgPaths = (tree.tree ?? [])
1389
- .filter((n) => n.type === 'blob' && /^packages\/[^/]+\/package\.json$/u.test(n.path))
1390
- .map((n) => n.path)
1391
- .slice(0, 12)
1392
- if (pkgPaths.length > 0) {
1393
- const subs = await Promise.all(pkgPaths.map((p) => curlText(`https://raw.githubusercontent.com/${item.fullName}/${encodeURIComponent(branch)}/${p}`, 4000)
1394
- .then((t) => { try { return JSON.parse(t) } catch { return null } })
1395
- .catch(() => null)))
1396
- if (subs.some((sp) => sp && typeof sp.dsh?.bundle?.patch === 'string')) {
1397
- aggregateInstallable = true
1398
- }
1536
+ const ENRICH_CACHE_FILE = () => join(dshHome(), 'plugin-console', 'enrich-cache.json')
1537
+ const ENRICH_CACHE_TTL = 24 * 60 * 60 * 1000
1538
+
1539
+ function readEnrichCache() {
1540
+ try {
1541
+ const j = JSON.parse(readFileSync(ENRICH_CACHE_FILE(), 'utf8'))
1542
+ return j !== null && typeof j === 'object' ? j : {}
1543
+ } catch { return {} }
1544
+ }
1545
+ function writeEnrichCache(cache) {
1546
+ try {
1547
+ mkdirSync(dirname(ENRICH_CACHE_FILE()), { recursive: true })
1548
+ const entries = Object.entries(cache).sort((a, b) => (b[1]?.at ?? 0) - (a[1]?.at ?? 0)).slice(0, 2000)
1549
+ writeFileSync(ENRICH_CACHE_FILE(), JSON.stringify(Object.fromEntries(entries), null, 2), 'utf8')
1550
+ } catch {}
1551
+ }
1552
+
1553
+ /** 单项识别:官方通道 / 聚合仓库 / 技能 / 套装(失败返回 official=null)。 */
1554
+ async function enrichItemOne(item) {
1555
+ let official = /^deepseek-ai\//u.test(item.fullName ?? '') ? true : null
1556
+ let aggregate = false
1557
+ let aggregateInstallable = false
1558
+ let hasSkill = false
1559
+ let hasSuite = false
1560
+ try {
1561
+ const branch = item.defaultBranch ?? 'main'
1562
+ const base = `https://raw.githubusercontent.com/${item.fullName}/${encodeURIComponent(branch)}/package.json`
1563
+ const [pkgResult, skillResult, suiteResult] = await Promise.allSettled([
1564
+ Promise.any([
1565
+ curlText(base, 4000),
1566
+ curlText(`https://ghproxy.net/${base}`, 4000),
1567
+ ]),
1568
+ detectSkillRepo(item.fullName, branch),
1569
+ rawTextWithFallback(item.fullName, branch, '.gitmodules'),
1570
+ ])
1571
+ if (suiteResult.status === 'fulfilled' && suiteResult.value !== null) {
1572
+ hasSuite = true
1573
+ }
1574
+ if (pkgResult.status === 'fulfilled') {
1575
+ const pkg = JSON.parse(pkgResult.value)
1576
+ if (typeof pkg.dsh?.bundle?.patch === 'string') {
1577
+ official = true
1578
+ } else if (pkg.private === true && (Array.isArray(pkg.workspaces) || /(^|-)dsh[-/]/u.test(String(pkg.name ?? '')))) {
1579
+ aggregate = true
1580
+ try {
1581
+ const tree = await curlJson(`https://api.github.com/repos/${item.fullName}/git/trees/${encodeURIComponent(branch)}?recursive=1`, 6000)
1582
+ const pkgPaths = (tree.tree ?? [])
1583
+ .filter((n) => n.type === 'blob' && /^packages\/[^/]+\/package\.json$/u.test(n.path))
1584
+ .map((n) => n.path)
1585
+ .slice(0, 12)
1586
+ if (pkgPaths.length > 0) {
1587
+ const subs = await Promise.all(pkgPaths.map((p) => curlText(`https://raw.githubusercontent.com/${item.fullName}/${encodeURIComponent(branch)}/${p}`, 4000)
1588
+ .then((t) => { try { return JSON.parse(t) } catch { return null } })
1589
+ .catch(() => null)))
1590
+ if (subs.some((sp) => sp && typeof sp.dsh?.bundle?.patch === 'string')) {
1591
+ aggregateInstallable = true
1399
1592
  }
1400
- } catch {}
1401
- }
1593
+ }
1594
+ } catch {}
1402
1595
  }
1403
- if (skillResult.status === 'fulfilled' && skillResult.value?.hasSkill === true) {
1404
- hasSkill = true
1596
+ }
1597
+ if (skillResult.status === 'fulfilled' && skillResult.value?.hasSkill === true) {
1598
+ hasSkill = true
1599
+ }
1600
+ } catch {}
1601
+ return { ...item, official, aggregate, aggregateInstallable, hasSkill, hasSuite }
1602
+ }
1603
+
1604
+ /**
1605
+ * 批量识别(★ 筛选数据源):并发限流 + 24h 结果缓存。
1606
+ * 网络黑洞期(raw.githubusercontent 大部分拉取失败)自动回退缓存中的上次判定,
1607
+ * 保证「只看官方」不因瞬时网络而坍缩成 0/1 条。
1608
+ */
1609
+ async function enrichItems(items) {
1610
+ const cache = readEnrichCache()
1611
+ const out = new Array(items.length)
1612
+ let next = 0
1613
+ let cacheDirty = false
1614
+ async function worker() {
1615
+ while (true) {
1616
+ const i = next
1617
+ next += 1
1618
+ if (i >= items.length) return
1619
+ const item = items[i]
1620
+ const key = `${item.fullName}@${item.defaultBranch ?? 'main'}`
1621
+ const cached = cache[key]
1622
+ try {
1623
+ if (cached !== undefined && typeof cached?.at === 'number' && Date.now() - cached.at < ENRICH_CACHE_TTL && cached.data && (cached.data.official !== null || item.fullName.startsWith('deepseek-ai/'))) {
1624
+ out[i] = { ...item, ...cached.data }
1625
+ continue
1626
+ }
1627
+ let result = await enrichItemOne(item)
1628
+ // 本次失败 → 回退缓存(哪怕已过期),避免「看天吃饭」
1629
+ if ((result.official === null && !/^deepseek-ai\//u.test(item.fullName ?? '')) && cached?.data) {
1630
+ result = { ...item, ...cached.data }
1631
+ } else {
1632
+ cache[key] = { at: Date.now(), data: { official: result.official, aggregate: result.aggregate, aggregateInstallable: result.aggregateInstallable, hasSkill: result.hasSkill, hasSuite: result.hasSuite } }
1633
+ cacheDirty = true
1634
+ }
1635
+ out[i] = result
1636
+ } catch {
1637
+ out[i] = cached?.data ? { ...item, ...cached.data } : { ...item, official: null, aggregate: false, aggregateInstallable: false, hasSkill: false, hasSuite: false }
1405
1638
  }
1406
- } catch {}
1407
- return { ...item, official, aggregate, aggregateInstallable, hasSkill, hasSuite }
1408
- }))
1639
+ }
1640
+ }
1641
+ await Promise.all(Array.from({ length: Math.min(12, items.length) }, worker))
1642
+ if (cacheDirty) writeEnrichCache(cache)
1643
+ return out
1409
1644
  }
1410
1645
 
1411
1646
  function githubRepoInfo(repo) {
@@ -1838,6 +2073,72 @@ const aiJobs = new Map()
1838
2073
  let aiJobSeq = 0
1839
2074
  /** 组件注册表(服务型组件:启动/停止/状态控制按钮的数据源)。 */
1840
2075
  const COMPONENTS_FILE = join(homedir(), '.dsh', 'plugin-console', 'components.json')
2076
+ /** AI 赋能任务持久化文件(DSH 重启不丢计划/结果;running 任务重启后标记中断)。 */
2077
+ const AI_JOBS_FILE = join(homedir(), '.dsh', 'plugin-console', 'ai-jobs.json')
2078
+ /** 仓库落地根目录(可配置,默认 ~/.dsh/repos)。 */
2079
+ let reposDirCache = null
2080
+ const REPO_LAND_CONF = join(homedir(), '.dsh', 'plugin-console', 'repo-land.json')
2081
+ function getReposDir() {
2082
+ if (reposDirCache !== null) return reposDirCache
2083
+ try {
2084
+ if (existsSync(REPO_LAND_CONF)) {
2085
+ const conf = JSON.parse(readFileSync(REPO_LAND_CONF, 'utf8'))
2086
+ if (typeof conf.dir === 'string' && conf.dir.trim() !== '') {
2087
+ reposDirCache = conf.dir.trim()
2088
+ return reposDirCache
2089
+ }
2090
+ }
2091
+ } catch {}
2092
+ reposDirCache = join(homedir(), '.dsh', 'repos')
2093
+ return reposDirCache
2094
+ }
2095
+ function setReposDir(dir) {
2096
+ reposDirCache = String(dir ?? '').trim()
2097
+ mkdirSync(dirname(REPO_LAND_CONF), { recursive: true })
2098
+ writeFileSync(REPO_LAND_CONF, JSON.stringify({ dir: reposDirCache }, null, 2), 'utf8')
2099
+ return reposDirCache
2100
+ }
2101
+ /** 已落地仓库列表:扫描 dir 下两级目录(owner/name 下含 .git)。 */
2102
+ function listLandedRepos() {
2103
+ const dir = getReposDir()
2104
+ const out = []
2105
+ try {
2106
+ if (!existsSync(dir)) return out
2107
+ for (const owner of readdirSync(dir, { withFileTypes: true })) {
2108
+ if (!owner.isDirectory() || owner.name.startsWith('.')) continue
2109
+ const ownerDir = join(dir, owner.name)
2110
+ for (const entry of readdirSync(ownerDir, { withFileTypes: true })) {
2111
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue
2112
+ const repoDir = join(ownerDir, entry.name)
2113
+ if (existsSync(join(repoDir, '.git'))) {
2114
+ out.push({ owner: owner.name, name: entry.name, repo: `${owner.name}/${entry.name}`, path: repoDir })
2115
+ }
2116
+ }
2117
+ }
2118
+ } catch {}
2119
+ return out
2120
+ }
2121
+ function saveAiJobs() {
2122
+ try {
2123
+ mkdirSync(dirname(AI_JOBS_FILE), { recursive: true })
2124
+ const arr = [...aiJobs.values()].map((j) => ({ id: j.id, source: j.source, status: j.status, stage: j.stage, plan: j.plan ?? null, logText: j.logText ?? '', stepStates: j.stepStates ?? [], progress: j.progress ?? null, error: j.error ?? null, createdAt: j.createdAt, finishedAt: j.finishedAt ?? null }))
2125
+ writeFileSync(AI_JOBS_FILE, JSON.stringify(arr, null, 2), 'utf8')
2126
+ } catch {}
2127
+ }
2128
+ function loadAiJobs() {
2129
+ try {
2130
+ if (!existsSync(AI_JOBS_FILE)) return
2131
+ const arr = JSON.parse(readFileSync(AI_JOBS_FILE, 'utf8'))
2132
+ for (const j of arr) {
2133
+ if (!j || typeof j.id !== 'string') continue
2134
+ let job = j
2135
+ if (job.status === 'running') {
2136
+ job = { ...job, status: 'failed', error: 'DSH 重启,任务中断(可重新发起)', finishedAt: Date.now() }
2137
+ }
2138
+ aiJobs.set(job.id, job)
2139
+ }
2140
+ } catch {}
2141
+ }
1841
2142
  /** 静态插件索引内存缓存(/market-index 用)。 */
1842
2143
  let marketIndexCache = null
1843
2144
 
@@ -1859,6 +2160,7 @@ function installJobView(job) {
1859
2160
  source: job.source ?? 'github',
1860
2161
  curlNote: job.curlNote ?? null,
1861
2162
  bundleNote: job.bundleNote ?? null,
2163
+ compatNote: job.compatNote ?? null,
1862
2164
  kind: job.kind ?? 'plugin',
1863
2165
  skillName: job.skillName ?? null,
1864
2166
  skillDir: job.skillDir ?? null,
@@ -2453,6 +2755,8 @@ function aiJobView(job) {
2453
2755
  logText: (job.logText ?? '').slice(-6000),
2454
2756
  createdAt: job.createdAt,
2455
2757
  finishedAt: job.finishedAt ?? null,
2758
+ workspace: job.parentCwd ?? null,
2759
+ frameworkCheck: job.frameworkCheck ?? null,
2456
2760
  }
2457
2761
  }
2458
2762
 
@@ -2588,6 +2892,7 @@ function isAllowedWritePath(p, profileDir) {
2588
2892
  norm(homedir()) + '/.openviking',
2589
2893
  norm(homedir()) + '/.cache/openviking',
2590
2894
  norm(homedir()) + '/.local/share/openviking',
2895
+ norm(homedir()) + '/.dsh/repos',
2591
2896
  norm(profileDir),
2592
2897
  'D:/OpenVikingData',
2593
2898
  ]
@@ -2611,7 +2916,16 @@ function jobLog(job, line) {
2611
2916
 
2612
2917
  /** 已知组件内置模板:跳过 AI 调研,直接产出经过验证的部署计划(当前仅 OpenViking)。 */
2613
2918
  function builtinPlanFor(source) {
2614
- if (!/openviking/i.test(source)) return null
2919
+ // 精确匹配 OpenViking 服务器本体(npm/pip 包名 openviking 或 volcengine/OpenViking 仓库),
2920
+ // 防止宽泛子串匹配误命中 openclaw_openviking_skill 之类的第三方包(曾实际发生误判)。
2921
+ const norm = String(source).trim().toLowerCase()
2922
+ .replace(/^https?:\/\//u, '')
2923
+ .replace(/^www\./u, '')
2924
+ .replace(/^github\.com\//u, '')
2925
+ .replace(/^raw\.githubusercontent\.com\//u, '')
2926
+ .replace(/\.git$/u, '')
2927
+ .replace(/\/$/u, '')
2928
+ if (norm !== 'openviking' && norm !== 'volcengine/openviking') return null
2615
2929
  const home = homedir()
2616
2930
  const storage = /^[\x00-\x7F]+$/u.test(home) ? `${home}/.openviking/data` : 'D:/OpenVikingData'
2617
2931
  const vlm = resolveVlmForOpenViking()
@@ -2691,6 +3005,9 @@ async function aiEmpowerPlan(job, ctx, profileDir) {
2691
3005
  '你是 DSH 插件控制台挂载的「AI 赋能」部署规划器。用户想把下面的组件完整部署到本机。',
2692
3006
  `目标来源:${job.source}`,
2693
3007
  '',
3008
+ '## 框架适配检测(控制台权威结果,规划时必须引用并向用户解释)',
3009
+ frameworkCheckPromptText(job.frameworkCheck),
3010
+ '',
2694
3011
  '【任务】只做只读调研与规划,禁止执行任何安装/写文件/启动服务/杀进程命令(curl 的 GET 抓取、gh api 查询、读取文件是允许的)。',
2695
3012
  '调研材料:npm README / GitHub README / docs/ 目录 / package.json / install 说明。',
2696
3013
  '全部调研完成后,输出且只输出一个 ```json ... ``` 代码块,内容为部署计划;代码块之外不要输出任何文字。',
@@ -2732,7 +3049,14 @@ async function aiEmpowerPlan(job, ctx, profileDir) {
2732
3049
  try {
2733
3050
  const agents = ctx.get('agents')
2734
3051
  const list = typeof agents?.roots === 'function' ? agents.roots() : (typeof agents?.list === 'function' ? agents.list() : [])
2735
- parent = list[0] ?? null
3052
+ // 父代理选择:优先 cwd 与 DSH 服务工作区一致的会话(子代理 cwd 继承父会话),
3053
+ // 避免多工作区/多会话时借到别的 workspace 的会话导致调研错位
3054
+ const svcCwd = String(process.cwd() ?? '').replace(/[\\/]+$/u, '')
3055
+ const cwdOf = (a) => {
3056
+ try { return String(a?.session?.header?.cwd ?? a?.cwd ?? '').replace(/[\\/]+$/u, '') } catch { return '' }
3057
+ }
3058
+ parent = list.find((a) => cwdOf(a) !== '' && cwdOf(a) === svcCwd) ?? list[0] ?? null
3059
+ job.parentCwd = parent ? cwdOf(parent) : null
2736
3060
  } catch {}
2737
3061
  if (parent === null) {
2738
3062
  job.status = 'failed'
@@ -2787,6 +3111,7 @@ async function aiEmpowerPlan(job, ctx, profileDir) {
2787
3111
  job.error = `AI 赋能规划异常:${error instanceof Error ? error.message : String(error)}`
2788
3112
  }
2789
3113
  job.finishedAt = Date.now()
3114
+ saveAiJobs()
2790
3115
  }
2791
3116
 
2792
3117
  /** 从子代理输出中提取 ```json ... ``` 计划。 */
@@ -2846,6 +3171,7 @@ async function aiEmpowerExecute(job, ctx, profileDir, selected) {
2846
3171
  job.progress = { done, total: chosenList.length }
2847
3172
  job.finishedAt = Date.now()
2848
3173
  jobLog(job, job.status === 'done' ? '🎉 AI 赋能部署完成' : '⛔ AI 赋能部署中止')
3174
+ saveAiJobs()
2849
3175
  }
2850
3176
 
2851
3177
  /** 单步执行器:动作白名单 + 路径白名单 + 破坏性命令拦截。 */
@@ -3032,7 +3358,23 @@ async function compStatus(id) {
3032
3358
  }
3033
3359
  }
3034
3360
  const running = Boolean(record.pid && processAlive(record.pid)) || healthy === true
3035
- return { id, name: record.name, running, healthy, pid: record.pid ?? null, port: record.port ?? null }
3361
+ return { id, name: record.name, running, healthy, pid: record.pid ?? null, port: record.port ?? null, autoStart: record.autoStart === true }
3362
+ }
3363
+
3364
+ /** DSH 启动时自动拉起标记 autoStart 的组件(幂等:已健康/已运行则跳过)。 */
3365
+ async function autostartComponents() {
3366
+ for (const c of findComponents()) {
3367
+ if (c.autoStart !== true) continue
3368
+ if (c.healthUrl) {
3369
+ try {
3370
+ const res = await fetch(c.healthUrl, { signal: AbortSignal.timeout(2500) })
3371
+ if (res.ok) continue
3372
+ } catch {}
3373
+ } else if (c.pid && processAlive(c.pid)) {
3374
+ continue
3375
+ }
3376
+ try { await compStart(c.id) } catch {}
3377
+ }
3036
3378
  }
3037
3379
 
3038
3380
  function processAlive(pid) {
@@ -3072,6 +3414,190 @@ async function syncAggregateSubpackageVersions(profileDir, packageName, registri
3072
3414
  return updated
3073
3415
  }
3074
3416
 
3417
+ /** 当前运行框架版本(@deepseek-ai/dsh package.json)。 */
3418
+ function currentFrameworkVersion(ctx) {
3419
+ try {
3420
+ const require = createRequire(ctx?.baseUrl ?? 'file:///')
3421
+ const pkgPath = require.resolve('@deepseek-ai/dsh/package.json')
3422
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
3423
+ return typeof pkg.version === 'string' ? pkg.version : null
3424
+ } catch {
3425
+ return null
3426
+ }
3427
+ }
3428
+
3429
+ /** 定位框架安装根(顶层 node_modules):优先运行进程入口,其次从包目录上溯找含 .pnpm 的 node_modules。 */
3430
+ function resolveFrameworkRootNodeModules(fromDir) {
3431
+ try {
3432
+ const entry = process.argv[1]
3433
+ if (typeof entry === 'string' && /bin\.js$/u.test(entry)) {
3434
+ const nm = dirname(dirname(entry))
3435
+ if (existsSync(join(nm, '.pnpm')) && existsSync(join(nm, '@deepseek-ai'))) return nm
3436
+ }
3437
+ } catch {}
3438
+ let dir = typeof fromDir === 'string' && fromDir !== '' ? fromDir : null
3439
+ for (let i = 0; i < 24 && dir !== null; i += 1) {
3440
+ if (basename(dir) === 'node_modules' && existsSync(join(dir, '.pnpm'))) return dir
3441
+ const parent = dirname(dir)
3442
+ if (parent === dir) break
3443
+ dir = parent
3444
+ }
3445
+ return null
3446
+ }
3447
+
3448
+ /**
3449
+ * 框架全树 checkpoint(可靠回滚点):镜像 .pnpm 中所有 @deepseek-ai 条目「自包」内容 +
3450
+ * 顶层 @deepseek-ai scope + lock.yaml。只镜像自包、不跟随依赖 junction,避免重复拷贝;
3451
+ * 恢复时按同路径写回 .pnpm 条目即可让整个依赖世界回到升级前。
3452
+ */
3453
+ function checkpointFrameworkTree(fwRoot, destRoot) {
3454
+ const pnpmRoot = join(fwRoot, '.pnpm')
3455
+ const dest = join(destRoot, 'fw-tree', String(Date.now()))
3456
+ mkdirSync(dest, { recursive: true })
3457
+ let mirrored = 0
3458
+ const selfNameOf = (entryName) => entryName.slice('@deepseek-ai+'.length).split('@')[0]
3459
+ for (const entry of readdirSync(pnpmRoot, { withFileTypes: true })) {
3460
+ if (!entry.isDirectory() || !entry.name.startsWith('@deepseek-ai+')) continue
3461
+ const name = selfNameOf(entry.name)
3462
+ const selfDir = join(pnpmRoot, entry.name, 'node_modules', '@deepseek-ai', name)
3463
+ if (!existsSync(join(selfDir, 'package.json'))) continue
3464
+ copyTree(selfDir, join(dest, '.pnpm', entry.name, 'node_modules', '@deepseek-ai', name))
3465
+ mirrored += 1
3466
+ }
3467
+ const topScope = join(fwRoot, '@deepseek-ai')
3468
+ if (existsSync(topScope)) copyTree(topScope, join(dest, 'top-@deepseek-ai'))
3469
+ try { copyFileSync(join(pnpmRoot, 'lock.yaml'), join(dest, 'lock.yaml')) } catch {}
3470
+ try { copyFileSync(join(fwRoot, 'package.json'), join(dest, 'fw-package.json')) } catch {}
3471
+ return { dest, mirrored }
3472
+ }
3473
+
3474
+ /** 移除补丁中的单行 disabled/forced 覆盖块(兼容门解锁用)。 */
3475
+ function removeDisableBlock(patchPath, rowId) {
3476
+ return queuedWrite(async () => {
3477
+ const { text } = await readPatchState(patchPath)
3478
+ const overrideRe = new RegExp(`^- id: ${escapeRegExp(rowId)}\\s*\\r?\\n {2}disabled: (true|false)\\s*\\r?\\n`, 'mu')
3479
+ const next = text.replace(overrideRe, '')
3480
+ if (next !== text) await writeFile(patchPath, sanitizePatchText(next), 'utf8')
3481
+ })
3482
+ }
3483
+
3484
+ /** 从「子包版本对齐」记录解析纯包名(如 '@linxin666/dsh-pet@0.2.1(新装)')。 */
3485
+ function syncNameFromNote(note) {
3486
+ const m = String(note ?? '').match(/^((?:@[^/]+\/)?[^@]+)/u)
3487
+ return m ? m[1] : null
3488
+ }
3489
+
3490
+ /**
3491
+ * 框架升级适配门:安装/更新完成后自动校验兼容性,
3492
+ * 通过(版本已变化 + 未在声明/依赖层面明确不兼容)则移除补丁禁用块并解锁启用。
3493
+ */
3494
+ async function maybeAutoAdaptCompat({ profileDir, packageName, syncedNames, patchPath, ctx }) {
3495
+ const pending = readCompatPending()
3496
+ if (!pending || !Array.isArray(pending.pending)) return { ran: false }
3497
+ const interested = new Set([packageName, ...(syncedNames ?? [])].filter((n) => n !== null))
3498
+ const targets = pending.pending.filter((p) => (p.status ?? 'pending') === 'pending' && interested.has(p.moduleName))
3499
+ if (targets.length === 0) return { ran: false }
3500
+ const fwVer = typeof pending.frameworkVersion === 'string' ? pending.frameworkVersion : '?'
3501
+ const adopted = []
3502
+ const kept = []
3503
+ for (const p of targets) {
3504
+ let pkg = null
3505
+ let pkgDir = null
3506
+ try {
3507
+ const require = createRequire(join(profileDir, 'package.json'))
3508
+ const pkgPath = require.resolve(`${p.moduleName}/package.json`)
3509
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
3510
+ pkgDir = dirname(pkgPath)
3511
+ } catch {}
3512
+ const version = typeof pkg?.version === 'string' ? pkg.version : null
3513
+ const changed = version !== null && version !== p.version
3514
+ const check = pkg !== null ? checkPluginFrameworkCompat(pkg, fwVer, pkgDir) : { decision: 'unknown', reason: '无法读取包信息' }
3515
+ if (changed && check.decision !== 'fail') {
3516
+ try { await removeDisableBlock(patchPath, p.rowId) } catch {}
3517
+ p.status = 'adopted'
3518
+ p.adoptedAt = Date.now()
3519
+ p.adoptedVersion = version
3520
+ p.adoptedFramework = fwVer
3521
+ p.check = check.decision
3522
+ p.checkNote = check.reason ?? null
3523
+ adopted.push({ rowId: p.rowId, moduleName: p.moduleName, version, check: check.decision })
3524
+ } else {
3525
+ kept.push({ rowId: p.rowId, moduleName: p.moduleName, reason: !changed ? '版本未变化(无更新可适配)' : (check.reason ?? '兼容校验未通过') })
3526
+ }
3527
+ }
3528
+ if (adopted.length > 0 || kept.length > 0) writeCompatPending(pending)
3529
+ const note = adopted.length > 0
3530
+ ? `兼容适配:已解锁 ${adopted.length} 行(${adopted.map((a) => a.rowId).join('、')})`
3531
+ + (kept.length > 0 ? `;仍待适配 ${kept.length} 行(${kept.map((k) => `${k.rowId} ${k.reason}`).join('、')})` : '')
3532
+ : `兼容适配:暂未解锁(${kept.map((k) => `${k.rowId} ${k.reason}`).join('、')})`
3533
+ return { ran: true, adopted, kept, note }
3534
+ }
3535
+
3536
+ /** AI 赋能框架适配预检:查 registry 最新版声明 + 兼容门清单,给出权威说明。 */
3537
+ async function frameworkCompatReportFor(source, ctx, profileDir) {
3538
+ const fwVer = currentFrameworkVersion(ctx)
3539
+ const pending = readCompatPending()
3540
+ const norm = String(source ?? '').trim()
3541
+ const isNpm = norm.startsWith('@') || !norm.includes('/')
3542
+ const installed = (() => {
3543
+ try {
3544
+ const require = createRequire(join(profileDir, 'package.json'))
3545
+ const pkgPath = require.resolve(`${norm}/package.json`)
3546
+ return JSON.parse(readFileSync(pkgPath, 'utf8')).version ?? null
3547
+ } catch { return null }
3548
+ })()
3549
+ const pendHits = (pending?.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending' && p.moduleName === norm)
3550
+ if (!isNpm) {
3551
+ return {
3552
+ kind: 'repo', frameworkVersion: fwVer, packageName: null, latest: null, check: null,
3553
+ pending: pendHits, installedVersion: null,
3554
+ summary: pendHits.length > 0
3555
+ ? `该来源命中兼容门清单(${pending.frameworkVersion}),GitHub 仓库无法在线预检——更新/部署完成后会由控制台自动校验解锁`
3556
+ : `GitHub 仓库来源无法预检 npm 声明,部署后以实际运行为准(框架当前 ${fwVer ?? '未知'})`,
3557
+ }
3558
+ }
3559
+ const name = norm.startsWith('@')
3560
+ ? '@' + norm.slice(1).split('@')[0]
3561
+ : norm.split('@')[0]
3562
+ let latest = null
3563
+ let versionMeta = null
3564
+ try {
3565
+ const data = await fetchJsonUrl(`https://registry.npmmirror.com/${name.replace('/', '%2f')}`)
3566
+ latest = data?.['dist-tags']?.latest ?? data?.['dist-tags']?.next ?? null
3567
+ if (latest !== null) versionMeta = data?.versions?.[latest] ?? null
3568
+ } catch (error) {
3569
+ return {
3570
+ kind: 'npm', frameworkVersion: fwVer, packageName: name, latest: null, check: null,
3571
+ pending: pendHits, installedVersion: installed,
3572
+ summary: `registry 预检失败(${error instanceof Error ? error.message : String(error)});本机已装 ${installed ?? '?'}${pendHits.length > 0 ? ',处于兼容门禁用中' : ''}`,
3573
+ }
3574
+ }
3575
+ const check = versionMeta !== null ? checkPluginFrameworkCompat(versionMeta, fwVer) : null
3576
+ const parts = []
3577
+ if (pendHits.length > 0) parts.push(`已在兼容门清单(${pending.upgradeFrom ?? '?'} → ${pending.frameworkVersion})中强制禁用,当前已装 ${installed ?? pendHits[0]?.version ?? '?'}`)
3578
+ if (latest !== null) {
3579
+ if (check?.decision === 'fail') parts.push(`最新版 ${latest} 不满足框架 ${fwVer}(${check.reason})——请勿启用,等待适配版本`)
3580
+ else if (check?.decision === 'pass') parts.push(`最新版 ${latest} 声明兼容框架 ${fwVer},可放心启用`)
3581
+ else parts.push(`最新版 ${latest} 未声明框架兼容(${check?.reason ?? '无声明'}),建议更新后启用并观察`)
3582
+ }
3583
+ return {
3584
+ kind: 'npm', frameworkVersion: fwVer, packageName: name, latest, check, pending: pendHits, installedVersion: installed,
3585
+ summary: parts.length > 0 ? parts.join(';') : `框架 ${fwVer},无待适配记录`,
3586
+ }
3587
+ }
3588
+
3589
+ /** 把框架适配检测结果格式化为给子代理的提示段。 */
3590
+ function frameworkCheckPromptText(fc) {
3591
+ if (fc === null || fc === undefined) return '(不可用)'
3592
+ return [
3593
+ `- 当前框架版本:${fc.frameworkVersion ?? '未知'}`,
3594
+ `- ${fc.kind === 'npm' ? `npm 包 ${fc.packageName ?? '?'}` : '来源类型'}:registry 最新 ${fc.latest ?? '未知'}${fc.installedVersion ? `;本机已装 ${fc.installedVersion}` : ''}`,
3595
+ fc.pending && fc.pending.length > 0 ? `- ⚠ 已命中兼容门:${fc.pending.map((p) => p.rowId).join('、')} 处于强制禁用(版本 ${fc.pending[0]?.version ?? '?'}),升级后仅更新并通过校验才解锁` : null,
3596
+ fc.check ? `- 声明/依赖校验:${fc.check.decision === 'fail' ? '不兼容' : fc.check.decision === 'pass' ? '兼容' : '未知'}(${fc.check.reason})` : null,
3597
+ '- 计划总结必须向用户说明上述适配结论;若校验为 fail,不要建议启用,应建议「更新到最新版后再启用」。',
3598
+ ].filter((s) => s !== null).join('\n')
3599
+ }
3600
+
3075
3601
  async function runInstallJob(job, ctx) {
3076
3602
  try {
3077
3603
  job.stage = 'preparing'
@@ -3302,9 +3828,11 @@ async function runInstallJob(job, ctx) {
3302
3828
  }
3303
3829
  job.packageName = installedName
3304
3830
  // 聚合包子包版本对齐(确保更新后所有子包也到新版声明版本)
3831
+ let syncedNames = []
3305
3832
  try {
3306
3833
  const synced = await syncAggregateSubpackageVersions(profileDir, installedName, registries)
3307
3834
  if (synced.length > 0) {
3835
+ syncedNames = synced.map(syncNameFromNote).filter((n) => n !== null)
3308
3836
  job.bundleNote = (job.bundleNote ? job.bundleNote + ';' : '') + `子包版本对齐:更新 ${synced.length} 个(${synced.join('、')})`
3309
3837
  }
3310
3838
  } catch {}
@@ -3318,6 +3846,11 @@ async function runInstallJob(job, ctx) {
3318
3846
  + (integrity.missing.length > 0 ? `,仍缺失 ${integrity.missing.join('、')}(网络恢复后建议重新更新)` : '')
3319
3847
  }
3320
3848
  } catch {}
3849
+ // 框架升级适配门:更新完成后自动校验兼容性,通过则移除禁用块解锁启用
3850
+ try {
3851
+ const adapt = await maybeAutoAdaptCompat({ profileDir, packageName: installedName, syncedNames, patchPath, ctx })
3852
+ if (adapt !== null && adapt.ran === true) job.compatNote = adapt.note
3853
+ } catch {}
3321
3854
  job.stage = 'configuring'
3322
3855
  if (await detectBundleOnly(profileDir, installedName)) {
3323
3856
  // 官方 dsh plugin add 行为:声明 dsh.bundle 的包追加为 profile bundle 层,
@@ -3364,6 +3897,10 @@ function listEntries(ctx) { const entries = []
3364
3897
 
3365
3898
  /** 应用插件:注册 /plugin-console 路由。 */
3366
3899
  export function apply(ctx) {
3900
+ // 恢复 AI 赋能任务(restart 后 running 置失败,plan-ready/结果保留)
3901
+ loadAiJobs()
3902
+ // DSH 启动时自动拉起标记了「自启动」的服务器组件(幂等,不阻塞启动)
3903
+ autostartComponents().catch(() => {})
3367
3904
  ctx.effect(() => {
3368
3905
  const route = {
3369
3906
  kind: 'prefix',
@@ -3405,6 +3942,15 @@ async function handle(ctx, req, res) {
3405
3942
  const patchPath = findPatchPath(ctx)
3406
3943
  const patch = await readPatchState(patchPath)
3407
3944
  const extraRows = await readExtraBundleRows(dirname(patchPath))
3945
+ const compatPending = readCompatPending()
3946
+ const compatPendingRows = new Set((compatPending?.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => p.rowId))
3947
+ let rollbackRec = null
3948
+ try {
3949
+ const rr = JSON.parse(readFileSync(join(dshHome(), 'plugin-console', 'framework-rollback.json'), 'utf8'))
3950
+ if (rr !== null && typeof rr.checkpointDir === 'string' && existsSync(join(rr.checkpointDir, '.pnpm'))) {
3951
+ rollbackRec = { from: rr.from ?? null, to: rr.to ?? null, at: rr.at ?? null }
3952
+ }
3953
+ } catch {}
3408
3954
  const entries = listEntries(ctx).map((entry) => {
3409
3955
  const meta = entryPkgMeta(entry.moduleName, ctx.baseUrl ?? 'file:///')
3410
3956
  return {
@@ -3415,6 +3961,7 @@ async function handle(ctx, req, res) {
3415
3961
  installDate: meta?.installDate ?? null,
3416
3962
  version: meta?.version ?? null,
3417
3963
  repository: meta?.repository ?? null,
3964
+ pendingCompat: compatPendingRows.has(entry.rowId),
3418
3965
  }
3419
3966
  })
3420
3967
  const compat = await detectCompat(ctx.baseUrl ?? 'file:///')
@@ -3429,7 +3976,7 @@ async function handle(ctx, req, res) {
3429
3976
  const selfPkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'))
3430
3977
  selfVersion = typeof selfPkg.version === 'string' ? selfPkg.version : null
3431
3978
  } catch {}
3432
- sendJson(res, 200, { ok: true, entries, patchPath, compat, installJobs: jobs, recentFailures, github: { loggedIn: auth.loggedIn, login: auth.login }, patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts }, framework, selfVersion, components: findComponents().map((c) => ({ id: c.id, name: c.name, kind: c.kind ?? 'server', pid: c.pid ?? null, port: c.port ?? null, healthUrl: c.healthUrl ?? null, uiUrl: compUiUrl(c) })) })
3979
+ sendJson(res, 200, { ok: true, entries, patchPath, compat, installJobs: jobs, recentFailures, github: { loggedIn: auth.loggedIn, login: auth.login }, patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts }, framework, compatPending: compatPending === null ? null : { frameworkVersion: compatPending.frameworkVersion ?? null, upgradeFrom: compatPending.upgradeFrom ?? null, pending: (compatPending.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => ({ rowId: p.rowId, moduleName: p.moduleName, version: p.version ?? null })) }, rollback: rollbackRec, selfVersion, components: findComponents().map((c) => ({ id: c.id, name: c.name, kind: c.kind ?? 'server', pid: c.pid ?? null, port: c.port ?? null, healthUrl: c.healthUrl ?? null, uiUrl: compUiUrl(c), autoStart: c.autoStart === true })) })
3433
3980
  return
3434
3981
  }
3435
3982
 
@@ -3626,6 +4173,15 @@ async function handle(ctx, req, res) {
3626
4173
  sendError(res, 400, '不能停用插件控制台自身')
3627
4174
  return
3628
4175
  }
4176
+ if (enabled) {
4177
+ // 框架升级适配门(事故教训):兼容门清单内的行保持强制禁用,直到更新并通过校验
4178
+ const compatPending = readCompatPending()
4179
+ const pend = (compatPending?.pending ?? []).find((p) => p.rowId === rowId && (p.status ?? 'pending') === 'pending')
4180
+ if (pend) {
4181
+ sendError(res, 409, `「${rowId}」仍在框架升级适配门中(${compatPending.frameworkVersion ?? '?'})被强制禁用:请先点击「更新并适配」,更新完成并通过兼容校验后系统自动解锁`)
4182
+ return
4183
+ }
4184
+ }
3629
4185
  const patchPath = findPatchPath(ctx)
3630
4186
  const result = enabled
3631
4187
  ? await enableEntry(patchPath, rowId)
@@ -3692,6 +4248,7 @@ async function handle(ctx, req, res) {
3692
4248
  const query = raw === '' ? DEFAULT_SEARCH : raw
3693
4249
  const page = Math.max(Math.min(Number.parseInt(String(body.page), 10) || 1, 5), 1)
3694
4250
  const source = typeof body.source === 'string' && body.source !== '' ? body.source : 'github'
4251
+ const all = body.all === true
3695
4252
  const auth = readGithubAuth()
3696
4253
  let items = []
3697
4254
  if (body.multi === true) {
@@ -3702,7 +4259,7 @@ async function handle(ctx, req, res) {
3702
4259
  (async () => {
3703
4260
  try {
3704
4261
  const data = await githubJson(
3705
- `${GITHUB_API}/search/repositories?q=${encodeURIComponent(query)}&sort=stars&order=desc&per_page=20&page=${page}`,
4262
+ `${GITHUB_API}/search/repositories?q=${encodeURIComponent(all ? query : `${query} topic:dsh-plugin`)}&sort=stars&order=desc&per_page=20&page=${page}`,
3706
4263
  req.signal,
3707
4264
  auth.token,
3708
4265
  )
@@ -3801,7 +4358,7 @@ async function handle(ctx, req, res) {
3801
4358
  items = normalizePlatformItems(data, 'main').map((item) => ({ ...item, source, sourceName: custom.name }))
3802
4359
  } else {
3803
4360
  const data = await githubJson(
3804
- `${GITHUB_API}/search/repositories?q=${encodeURIComponent(query)}&sort=stars&order=desc&per_page=20&page=${page}`,
4361
+ `${GITHUB_API}/search/repositories?q=${encodeURIComponent(all ? query : `${query} topic:dsh-plugin`)}&sort=stars&order=desc&per_page=20&page=${page}`,
3805
4362
  req.signal,
3806
4363
  auth.token,
3807
4364
  )
@@ -4308,6 +4865,27 @@ async function handle(ctx, req, res) {
4308
4865
  sendError(res, 500, `框架备份失败(回滚点${rollbackDir !== null ? `:${rollbackDir}` : '(无)'}缺失或无效),已取消升级——请检查磁盘空间/权限后重试`)
4309
4866
  return
4310
4867
  }
4868
+ // 框架安装根识别(事故教训 2026-09-04):require.resolve 返回 .pnpm 内部 realpath,
4869
+ // 旧逻辑 dirname(dirname(dshDir)) 指向 .pnpm/<entry>/node_modules——「重链跳过:.pnpm 目录不存在」、
4870
+ // 依赖修复 0 个,pnpm 还在错误 cwd 里把新 CLI 原位覆盖进旧 .pnpm 目录,旧树就此被毁。
4871
+ const fwRoot = resolveFrameworkRootNodeModules(dshDir)
4872
+ if (fwRoot === null || !existsSync(join(fwRoot, '.pnpm')) || !existsSync(join(fwRoot, '@deepseek-ai', 'dsh', 'lib', 'bin.js'))) {
4873
+ sendError(res, 500, `无法定位框架安装根(期望含 .pnpm 与 @deepseek-ai/dsh 的顶层 node_modules,实际:${fwRoot ?? '未找到'})——已取消升级,请修复框架安装后重试`)
4874
+ return
4875
+ }
4876
+ // 框架全树 checkpoint(可靠回滚点):升级前镜像全部 @deepseek-ai 版本包(自包内容)+ 顶层 scope + lock
4877
+ let fwCheckpoint = null
4878
+ try {
4879
+ const cpRoot = backupDir !== null ? backupDir : join(FRAMEWORK_BACKUP_ROOT(), current ?? 'unknown')
4880
+ fwCheckpoint = checkpointFrameworkTree(fwRoot, cpRoot)
4881
+ steps.push(`框架全树 checkpoint 完成:${fwCheckpoint.dest}(镜像 ${fwCheckpoint.mirrored} 个 @deepseek-ai 版本包;升级失败/拉起失败会自动全树回滚,也可用框架卡片的「回滚到上一版」一键回滚)`)
4882
+ } catch (error) {
4883
+ steps.push(`框架全树 checkpoint 失败:${error instanceof Error ? error.message : String(error)}(回滚点降级为 CLI 备份)`)
4884
+ }
4885
+ // 一键回滚记录:框架卡片「回滚到上一版」按钮读取
4886
+ try {
4887
+ writeFileSync(join(dshHome(), 'plugin-console', 'framework-rollback.json'), JSON.stringify({ from: current, to: target, checkpointDir: fwCheckpoint?.dest ?? null, cliBackupDir: rollbackDir, fwRoot, at: Date.now() }, null, 2), 'utf8')
4888
+ } catch {}
4311
4889
  const ps1 = join(tmpdir(), `fw-upgrade-${process.pid}.ps1`)
4312
4890
  const logFile = join(dshHome(), 'plugin-console', 'fw-upgrade.log')
4313
4891
  const stateFile = join(dshHome(), 'plugin-console', 'fw-upgrade-state.txt')
@@ -4337,7 +4915,7 @@ async function handle(ctx, req, res) {
4337
4915
  `Log '升级框架本体 ${current} -> ${target}…'`,
4338
4916
  // 关键:npm 的工作目录必须是缓存 node_modules(schtasks 默认 cwd 是 system32/用户目录,
4339
4917
  // 不指定会装错位置——缓存永远不更新的根因)
4340
- `Set-Location -Path ${ps(dirname(dshDir))}`,
4918
+ `Set-Location -Path ${ps(fwRoot)}`,
4341
4919
  // npm 安装函数:Start-Process 跑 npm-cli.js(唯一正确入口——lib/cli.js 是函数模块,
4342
4920
  // 直跑只加载不执行,假成功 exit 0)+ --force(显式指定版本但已在 package.json 的
4343
4921
  // semver 范围内时 npm 会跳过安装,假成功)+ 15 分钟总时长硬超时。注意:**不能加
@@ -4400,17 +4978,36 @@ async function handle(ctx, req, res) {
4400
4978
  ` }`,
4401
4979
  ` return $code`,
4402
4980
  `}`,
4403
- // 回滚函数:robocopy 支持长路径(Copy-Item 对 >260 字符路径静默失败,曾导致回滚后
4404
- // 框架目录缺失、服务无法重启);回滚前确保服务已停(进程 cwd 会锁住框架目录)
4981
+ // 回滚函数(2026-09-04 事故后升级为「全树优先」):robocopy 支持长路径(Copy-Item 对 >260
4982
+ // 字符路径静默失败,曾导致回滚后框架目录缺失、服务无法重启);回滚前确保服务已停
4983
+ // (进程 cwd 会锁住框架目录)。checkpoint 存在 → 恢复全部 @deepseek-ai 版本包 + 顶层 scope;
4984
+ // checkpoint 缺失 → CLI 兜底。
4405
4985
  `function Invoke-Rollback {`,
4406
- ` SetState 'rollback' '升级失败,回滚框架本体…'`,
4407
- ` Log '升级失败,回滚框架本体…'`,
4408
- ` # 备份缺失时绝不删除现有框架目录(曾因先删后拷失败导致框架目录为空、服务无法重启)`,
4409
- ` if (-not (Test-Path ${ps(join(rollbackDir, 'lib', 'bin.js'))})) { Log ('回滚失败:备份缺失(' + ${ps(rollbackDir)} + '),跳过删除框架目录'); return }`,
4410
- ` try { $svc = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($svc) { $svc | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Start-Sleep -Seconds 2 } } catch {}`,
4986
+ ` SetState 'rollback' '升级失败,回滚框架(全树/CLI 备份)…'`,
4987
+ ` Log '升级失败,回滚框架…'`,
4988
+ ` try { $svc = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($svc) { $svc | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Start-Sleep -Seconds 3 } } catch {}`,
4989
+ ` $cp = '${ps(fwCheckpoint?.dest ?? '')}'`,
4990
+ ` if ($cp -ne '' -and (Test-Path (Join-Path $cp '.pnpm'))) {`,
4991
+ ` $restored = 0`,
4992
+ ` $entries = Get-ChildItem -Path (Join-Path $cp '.pnpm') -Directory -ErrorAction SilentlyContinue`,
4993
+ ` foreach ($e in $entries) {`,
4994
+ ` $name = @($e.Name -split '\\+')[1].Split('@')[0]`,
4995
+ ` $src = Join-Path $e.FullName ('node_modules\\@deepseek-ai\\' + $name)`,
4996
+ ` $dst = Join-Path (Join-Path '${ps(fwRoot)}' ('.pnpm\\' + $e.Name)) ('node_modules\\@deepseek-ai\\' + $name)`,
4997
+ ` if (Test-Path (Join-Path $src 'package.json')) { New-Item -ItemType Directory -Path (Split-Path $dst -Parent) -Force | Out-Null; robocopy $src $dst /E /NFL /NDL /NJH /NJS /R:1 /W:1 | Out-Null; $restored++ }`,
4998
+ ` }`,
4999
+ ` $topSrc = Join-Path $cp 'top-@deepseek-ai'`,
5000
+ ` if (Test-Path $topSrc) { Remove-Item '${ps(join(fwRoot, '@deepseek-ai'))}' -Recurse -Force -ErrorAction SilentlyContinue; robocopy $topSrc '${ps(join(fwRoot, '@deepseek-ai'))}' /E /NFL /NDL /NJH /NJS /R:1 /W:1 | Out-Null }`,
5001
+ ` try { Copy-Item (Join-Path $cp 'lock.yaml') '${ps(join(fwRoot, '.pnpm', 'lock.yaml'))}' -Force -ErrorAction SilentlyContinue } catch {}`,
5002
+ ` Log ('全树回滚完成:恢复 ' + $restored + ' 个版本包 + 顶层 scope')`,
5003
+ ` $script:rolledBack = $true`,
5004
+ ` return`,
5005
+ ` }`,
5006
+ ` # CLI 兜底(checkpoint 缺失或损坏)`,
5007
+ ` if (-not (Test-Path ${ps(join(rollbackDir, 'lib', 'bin.js'))})) { Log ('回滚失败:CLI 备份也缺失(' + ${ps(rollbackDir)} + ')'); return }`,
4411
5008
  ` Remove-Item ${ps(dshDir)} -Recurse -Force -ErrorAction SilentlyContinue`,
4412
5009
  ` robocopy ${ps(rollbackDir)} ${ps(dshDir)} /E /NFL /NDL /NJH /NJS /R:1 /W:1 | Out-Null`,
4413
- ` if (Test-Path ${ps(join(dshDir, 'lib', 'bin.js'))}) { Log '回滚完成(框架目录已恢复)'; $script:rolledBack = $true } else { Log '回滚失败:复制失败,请手动修复框架安装' }`,
5010
+ ` if (Test-Path ${ps(join(dshDir, 'lib', 'bin.js'))}) { Log '回滚完成(CLI 恢复)'; $script:rolledBack = $true } else { Log '回滚失败:复制失败,请手动修复框架安装' }`,
4414
5011
  `}`,
4415
5012
  `$script:rolledBack = $false`,
4416
5013
  `$code = Install-Framework 'https://registry.npmmirror.com'`,
@@ -4424,6 +5021,8 @@ async function handle(ctx, req, res) {
4424
5021
  ` if ($code -ne 0) { Log ('(停服后)npmmirror 安装失败(exit=' + $code + '),切换 registry.npmjs.org 重试一次'); $code = Install-Framework 'https://registry.npmjs.org' }`,
4425
5022
  `}`,
4426
5023
  `if ($code -ne 0) { Invoke-Rollback } else { Log '框架本体升级完成' }`,
5024
+ // 回滚后跳过重链/依赖修复/版本对齐(它们按新树预期运行,会对已恢复的旧树造成二次破坏)
5025
+ `if (-not $script:rolledBack) {`,
4427
5026
  // 重新链接框架配套包(事故教训):pnpm 升级到新版本时 .pnpm 目录重建,
4428
5027
  // 但顶层 node_modules/@deepseek-ai/* 不会自动切换,仍指向旧版(如 0.1.0-rc.7)——
4429
5028
  // 导致框架混版本(如 dsh-llm-deepseek 旧版无 vision 模型)、部分功能异常。
@@ -4432,7 +5031,7 @@ async function handle(ctx, req, res) {
4432
5031
  `$relinked = 0`,
4433
5032
  `function Relink-FrameworkPackages($version) {`,
4434
5033
  ` # dshDir = .../node_modules/@deepseek-ai/dsh → nodeModules = .../node_modules`,
4435
- ` $nodeModules = ${ps(dirname(dirname(dshDir)))}`,
5034
+ ` $nodeModules = ${ps(fwRoot)}`,
4436
5035
  ` $pnpm = Join-Path $nodeModules '.pnpm'`,
4437
5036
  ` if (-not (Test-Path $pnpm)) { Log ('重链跳过:.pnpm 目录不存在'); return }`,
4438
5037
  ` $topPkgs = Get-ChildItem -Path (Join-Path $nodeModules '@deepseek-ai') -Directory | Where-Object { $_.Name -like 'dsh-*' }`,
@@ -4470,7 +5069,7 @@ async function handle(ctx, req, res) {
4470
5069
  ` $bak = $top.FullName + '.bak-' + $topVer`,
4471
5070
  ` if (Test-Path $bak) { Remove-Item $bak -Recurse -Force -ErrorAction SilentlyContinue }`,
4472
5071
  ` Rename-Item -LiteralPath $top.FullName -NewName ($name + '.bak-' + $topVer) -Force`,
4473
- ` cmd /c mklink /J "${top.FullName}" "${best}" 2>$null | Out-Null`,
5072
+ ` cmd /c mklink /J "$($top.FullName)" "$($best)" 2>$null | Out-Null`,
4474
5073
  ` if (Test-Path (Join-Path $top.FullName 'package.json')) { $script:relinked++; Log ('重链 ' + $name + ': ' + $topVer + ' -> ' + $bestVer) } else { Log ('重链失败:' + $name) }`,
4475
5074
  ` } catch { Log ('重链异常:' + $name + ' ' + $_.Exception.Message) }`,
4476
5075
  ` }`,
@@ -4484,7 +5083,7 @@ async function handle(ctx, req, res) {
4484
5083
  `$treeFixed = 0`,
4485
5084
  `function Verify-DependencyTree($refVersion) {`,
4486
5085
  ` # dshDir = .../node_modules/@deepseek-ai/dsh → nodeModules = .../node_modules`,
4487
- ` $nodeModules = ${ps(dirname(dirname(dshDir)))}`,
5086
+ ` $nodeModules = ${ps(fwRoot)}`,
4488
5087
  ` $webApp = Join-Path $nodeModules '@deepseek-ai\\dsh-web-app\\package.json'`,
4489
5088
  ` if (-not (Test-Path $webApp)) { Log ('依赖树验证跳过:dsh-web-app 未找到'); return }`,
4490
5089
  ` try { $deps = (Get-Content $webApp -Raw | ConvertFrom-Json).dependencies } catch { Log ('依赖树验证跳过:读取失败'); return }`,
@@ -4552,6 +5151,7 @@ async function handle(ctx, req, res) {
4552
5151
  `}`,
4553
5152
  `Verify-SelfNameConsistency '${selfName ?? '@noob-stupid/dsh-plugin-console'}'`,
4554
5153
  pkgArgs.length > 0 ? `SetState 'pkg' '更新官方配套包…'\n Log '更新官方配套包…'\n Set-Location -Path ${ps(profileDir2)}\n if (Test-Path ${ps(corepackJs)}) {\n $pp = Start-Process -FilePath ${ps(nodePath)} -ArgumentList @(${ps(corepackJs)}, 'pnpm', 'update', ${pkgArgs.join(', ')}, '--no-optional', '--registry', 'https://registry.npmmirror.com') -PassThru -WindowStyle Hidden\n if (-not $pp.WaitForExit(300000)) { Stop-Process -Id $pp.Id -Force -ErrorAction SilentlyContinue; Log '官方配套包更新超时(5 分钟),已跳过' } else { try { Log ('官方配套包更新结束(exit=' + $pp.ExitCode + ')') } catch { Log '官方配套包更新结束(退出码读取失败)' } }\n }` : '',
5154
+ "}",
4555
5155
  "SetState 'relaunching' '升级完成,重启 DSH 服务生效…'",
4556
5156
  "Log '重启 DSH 服务生效…'",
4557
5157
  // 升级全程服务保持在线(先下载后重启):这里停掉旧服务(内存仍是旧代码),拉起新版本生效
@@ -4563,7 +5163,19 @@ async function handle(ctx, req, res) {
4563
5163
  ` if (-not $ok -and -not $started) { $binNow = ''; try { $env:DSH_RESOLVE_ROOT = '${join(dirname(fileURLToPath(import.meta.url)), '..')}'; $binNow = (& ${ps(nodePath)} -e "const path=require('path');const p=require.resolve('@deepseek-ai/dsh/package.json',{paths:[process.env.DSH_RESOLVE_ROOT]});console.log(path.join(path.dirname(p),'lib','bin.js'))" 2>$null | Select-Object -Last 1) } catch {}; if ($binNow -ne '' -and (Test-Path $binNow)) { Start-Process -FilePath ${ps(nodePath)} -ArgumentList $binNow,'web' -WindowStyle Hidden; $started = $true; Log '已发起拉起服务' } else { Log 'bin.js 缺失,跳过拉起' } }`,
4564
5164
  ' Start-Sleep -Seconds 5',
4565
5165
  '}',
4566
- `if ($ok) { if ($script:rolledBack) { SetState 'failed' '升级失败,已回滚到原版本(服务已恢复)' ; Log '升级失败已回滚,服务已恢复' } else { SetState 'done' '升级完成,服务已监听 ${port}' ; Log '服务已监听 ${port},升级完成' } } else { SetState 'failed' '拉起失败:请手动运行 node ${binPath} web' ; Log '拉起失败:请手动运行 node ${binPath} web(参考本日志排查)' }`,
5166
+ // 拉起失败不再只留提示(2026-09-04 教训):自动全树回滚并再拉起一次
5167
+ `if (-not $ok -and -not $script:rolledBack) {`,
5168
+ ` Log '服务拉起失败,尝试自动回滚到升级前版本…'`,
5169
+ ` Invoke-Rollback`,
5170
+ ` $ok = $false`,
5171
+ ` $started = $false`,
5172
+ ` for ($i = 0; $i -lt 20; $i++) {`,
5173
+ ` try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c.Count -gt 0) { $ok = $true; break } } catch {}`,
5174
+ ` if (-not $ok -and -not $started) { $binNow = ''; try { $env:DSH_RESOLVE_ROOT = '${join(dirname(fileURLToPath(import.meta.url)), '..')}'; $binNow = (& ${ps(nodePath)} -e "const path=require('path');const p=require.resolve('@deepseek-ai/dsh/package.json',{paths:[process.env.DSH_RESOLVE_ROOT]});console.log(path.join(path.dirname(p),'lib','bin.js'))" 2>$null | Select-Object -Last 1) } catch {}; if ($binNow -ne '' -and (Test-Path $binNow)) { Start-Process -FilePath ${ps(nodePath)} -ArgumentList $binNow,'web' -WindowStyle Hidden; $started = $true; Log '已发起拉起服务(回滚后)' } else { Log 'bin.js 缺失,跳过拉起' } }`,
5175
+ ` Start-Sleep -Seconds 5`,
5176
+ ` }`,
5177
+ `}`,
5178
+ `if ($ok) { if ($script:rolledBack) { SetState 'failed' '升级失败,已自动回滚到升级前版本(服务已恢复)' ; Log '升级失败已自动回滚,服务已恢复' } else { SetState 'done' '升级完成,服务已监听 ${port}' ; Log '服务已监听 ${port},升级完成' } } else { SetState 'failed' '拉起失败且自动回滚未生效:请手动运行 node ${binPath} web' ; Log '拉起失败且自动回滚未生效:请手动运行 node ${binPath} web(参考本日志排查)' }`,
4567
5179
  `schtasks /delete /f /tn ${taskName} 2>$null`,
4568
5180
  ].filter((l) => l !== '').join('\r\n')
4569
5181
  // 初始状态(备份完成、脚本即将执行)
@@ -4626,6 +5238,87 @@ async function handle(ctx, req, res) {
4626
5238
  return
4627
5239
  }
4628
5240
 
5241
+ if (pathname === `${ROUTE_PREFIX}/framework-rollback`) {
5242
+ // 一键回滚(2026-09-04 事故后的新能力):读 framework-rollback.json(升级时写入),
5243
+ // 生成分离脚本:停服 → 全树恢复(.pnpm 自包镜像 + 顶层 scope + lock)→ 拉起 → 状态。
5244
+ let rec = null
5245
+ try { rec = JSON.parse(readFileSync(join(dshHome(), 'plugin-console', 'framework-rollback.json'), 'utf8')) } catch {}
5246
+ if (rec === null || typeof rec.checkpointDir !== 'string' || typeof rec.fwRoot !== 'string'
5247
+ || !existsSync(join(rec.checkpointDir, '.pnpm')) || !existsSync(join(rec.fwRoot, '.pnpm'))) {
5248
+ sendError(res, 409, '没有可用的框架全树回滚点(framework-rollback.json 缺失或 checkpoint 已清理)')
5249
+ return
5250
+ }
5251
+ const port = webPort(ctx)
5252
+ const nodePath = process.execPath
5253
+ const taskName = `DSH-FW-Rollback-${process.pid}`
5254
+ const ps1 = join(tmpdir(), `fw-rollback-${process.pid}.ps1`)
5255
+ const logFile = join(dshHome(), 'plugin-console', 'fw-upgrade.log')
5256
+ const stateFile = join(dshHome(), 'plugin-console', 'fw-upgrade-state.txt')
5257
+ const ps = (s) => JSON.stringify(s).replace(/\\\\/gu, '\\')
5258
+ const lines = [
5259
+ `$state = ${ps(stateFile)}`,
5260
+ `$log = ${ps(logFile)}`,
5261
+ "function Log($m) { try { Add-Content -Path $log -Value ((Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' ' + $m) -Encoding UTF8 } catch {} }",
5262
+ "function SetState($s, $m) { try { Set-Content -Path $state -Value ($s + '|' + $m) -Encoding UTF8 } catch {} }",
5263
+ "trap {",
5264
+ " try { SetState 'failed' ('回滚脚本异常终止:' + $_.Exception.Message) } catch {}",
5265
+ ` schtasks /delete /f /tn ${taskName} 2>$null`,
5266
+ " exit 1",
5267
+ "}",
5268
+ "SetState 'rollback' '回滚到升级前版本…'",
5269
+ "Log '一键回滚脚本启动'",
5270
+ `try { $svc = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($svc) { $svc | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }; Start-Sleep -Seconds 3 } } catch {}`,
5271
+ "Log '服务已停止(回滚生效)'",
5272
+ `$cp = ${ps(rec.checkpointDir)}`,
5273
+ `$restored = 0`,
5274
+ `$entries = Get-ChildItem -Path (Join-Path $cp '.pnpm') -Directory -ErrorAction SilentlyContinue`,
5275
+ `foreach ($e in $entries) {`,
5276
+ ` $name = @($e.Name -split '\\+')[1].Split('@')[0]`,
5277
+ ` $src = Join-Path $e.FullName ('node_modules\\@deepseek-ai\\' + $name)`,
5278
+ ` $dst = Join-Path (Join-Path ${ps(rec.fwRoot)} ('.pnpm\\' + $e.Name)) ('node_modules\\@deepseek-ai\\' + $name)`,
5279
+ ` if (Test-Path (Join-Path $src 'package.json')) { New-Item -ItemType Directory -Path (Split-Path $dst -Parent) -Force | Out-Null; robocopy $src $dst /E /NFL /NDL /NJH /NJS /R:1 /W:1 | Out-Null; $restored++ }`,
5280
+ `}`,
5281
+ `$topSrc = Join-Path $cp 'top-@deepseek-ai'`,
5282
+ `if (Test-Path $topSrc) { Remove-Item ${ps(join(rec.fwRoot, '@deepseek-ai'))} -Recurse -Force -ErrorAction SilentlyContinue; robocopy $topSrc ${ps(join(rec.fwRoot, '@deepseek-ai'))} /E /NFL /NDL /NJH /NJS /R:1 /W:1 | Out-Null }`,
5283
+ `try { Copy-Item (Join-Path $cp 'lock.yaml') ${ps(join(rec.fwRoot, '.pnpm', 'lock.yaml'))} -Force -ErrorAction SilentlyContinue } catch {}`,
5284
+ `Log ('全树回滚完成:恢复 ' + $restored + ' 个版本包 + 顶层 scope,拉起验证中…')`,
5285
+ `$ok = $false`,
5286
+ `$started = $false`,
5287
+ `for ($i = 0; $i -lt 20; $i++) {`,
5288
+ ` try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c.Count -gt 0) { $ok = $true; break } } catch {}`,
5289
+ ` if (-not $ok -and -not $started) { $binNow = ''; try { $env:DSH_RESOLVE_ROOT = '${join(dirname(fileURLToPath(import.meta.url)), '..')}'; $binNow = (& ${ps(nodePath)} -e "const path=require('path');const p=require.resolve('@deepseek-ai/dsh/package.json',{paths:[process.env.DSH_RESOLVE_ROOT]});console.log(path.join(path.dirname(p),'lib','bin.js'))" 2>$null | Select-Object -Last 1) } catch {}; if ($binNow -ne '' -and (Test-Path $binNow)) { Start-Process -FilePath ${ps(nodePath)} -ArgumentList $binNow,'web' -WindowStyle Hidden; $started = $true; Log '已发起拉起服务(回滚后)' } else { Log 'bin.js 缺失,跳过拉起' } }`,
5290
+ ` Start-Sleep -Seconds 5`,
5291
+ `}`,
5292
+ `if ($ok) { SetState 'done' ('已回滚到升级前版本 ${rec.from ?? '?'},服务正常') ; Log '回滚完成,服务已恢复' } else { SetState 'failed' '回滚后服务拉起失败:请手动运行 node ${resolveDshBin() ?? '<bin>'} web' ; Log '回滚后拉起失败' }`,
5293
+ `schtasks /delete /f /tn ${taskName} 2>$null`,
5294
+ ].filter((l) => l !== '').join('\r\n')
5295
+ try { writeFileSync(stateFile, 'rollback|回滚脚本已启动…', 'utf8') } catch {}
5296
+ writeFile(ps1, `\uFEFF${lines}`, 'utf8').then(
5297
+ () => {
5298
+ const ps1Posix = ps1.replace(/\\/gu, '/')
5299
+ const tr = / /.test(ps1Posix)
5300
+ ? `"powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \\"${ps1Posix}\\""`
5301
+ : `C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ${ps1Posix}`
5302
+ execFile('schtasks.exe', ['/create', '/f', '/tn', taskName, '/tr', tr, '/sc', 'once', '/st', '00:00'], { windowsHide: true }, (error) => {
5303
+ if (error) {
5304
+ execFile('powershell.exe', ['-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', ps1], { windowsHide: true, detached: true, stdio: 'ignore' }, () => {})
5305
+ return
5306
+ }
5307
+ setTimeout(() => {
5308
+ execFile('schtasks.exe', ['/run', '/tn', taskName], { windowsHide: true }, (runError) => {
5309
+ if (runError) {
5310
+ execFile('powershell.exe', ['-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', ps1], { windowsHide: true, detached: true, stdio: 'ignore' }, () => {})
5311
+ }
5312
+ })
5313
+ }, 800)
5314
+ })
5315
+ },
5316
+ () => {},
5317
+ )
5318
+ sendJson(res, 200, { ok: true, from: rec.from ?? null, checkpointDir: rec.checkpointDir })
5319
+ return
5320
+ }
5321
+
4629
5322
  if (pathname === `${ROUTE_PREFIX}/clean-residuals`) {
4630
5323
  // 清理残余备份/旧子包:删除 .old-* 备份目录,以及聚合包未声明的 @linxin666 旧子包
4631
5324
  const profileDir = dirname(findPatchPath(ctx))
@@ -4858,6 +5551,8 @@ async function handle(ctx, req, res) {
4858
5551
  const profileDir = dirname(patchPath)
4859
5552
  const jobId = `ai-${Date.now()}-${++aiJobSeq}`
4860
5553
  const job = { id: jobId, source, status: 'running', stage: 'planning', createdAt: Date.now(), logText: '', stepStates: [] }
5554
+ // 框架适配预检(兼容门 + registry 声明):规划期即给出权威说明,供子代理引用与用户查看
5555
+ try { job.frameworkCheck = await frameworkCompatReportFor(source, ctx, profileDir) } catch { job.frameworkCheck = null }
4861
5556
  aiJobs.set(jobId, job)
4862
5557
  const builtin = builtinPlanFor(source)
4863
5558
  if (builtin !== null) {
@@ -4865,7 +5560,8 @@ async function handle(ctx, req, res) {
4865
5560
  job.status = 'plan-ready'
4866
5561
  job.stage = 'builtin'
4867
5562
  job.finishedAt = Date.now()
4868
- sendJson(res, 200, { ok: true, jobId })
5563
+ saveAiJobs()
5564
+ sendJson(res, 200, { ok: true, jobId, frameworkCheck: job.frameworkCheck })
4869
5565
  return
4870
5566
  }
4871
5567
  aiEmpowerPlan(job, ctx, profileDir).catch((error) => {
@@ -4873,7 +5569,7 @@ async function handle(ctx, req, res) {
4873
5569
  job.error = `规划任务异常:${error instanceof Error ? error.message : String(error)}`
4874
5570
  job.finishedAt = Date.now()
4875
5571
  })
4876
- sendJson(res, 200, { ok: true, jobId })
5572
+ sendJson(res, 200, { ok: true, jobId, frameworkCheck: job.frameworkCheck })
4877
5573
  return
4878
5574
  }
4879
5575
 
@@ -4887,9 +5583,30 @@ async function handle(ctx, req, res) {
4887
5583
  sendJson(res, 200, { ok: true, ...aiJobView(job) })
4888
5584
  return
4889
5585
  }
5586
+ if (pathname === `${ROUTE_PREFIX}/ai-empower/list`) {
5587
+ // 并发任务列表(轻量视图,不含日志全文):面板展示/切换多个并发 AI 赋能任务
5588
+ const list = [...aiJobs.values()].slice(-10).reverse().map((job) => ({
5589
+ jobId: job.id,
5590
+ source: job.source,
5591
+ status: job.status,
5592
+ stage: job.stage,
5593
+ type: job.plan?.type ?? null,
5594
+ displayName: job.plan?.displayName ?? null,
5595
+ progress: job.progress ?? { done: 0, total: (job.plan?.steps ?? []).length },
5596
+ error: job.error ?? null,
5597
+ createdAt: job.createdAt,
5598
+ }))
5599
+ sendJson(res, 200, { ok: true, tasks: list })
5600
+ return
5601
+ }
4890
5602
  if (pathname === `${ROUTE_PREFIX}/ai-empower/run`) {
4891
5603
  const jobId = typeof body.jobId === 'string' ? body.jobId : ''
4892
5604
  const selected = Array.isArray(body.steps) ? body.steps : null
5605
+ // 纵深防御:必须显式确认(前端「同意并部署」按钮携带 confirmed:true),防止任何绕过同意直接执行
5606
+ if (body.confirmed !== true) {
5607
+ sendError(res, 400, '未确认部署:请先在面板勾选步骤并点击「同意并部署」')
5608
+ return
5609
+ }
4893
5610
  const job = aiJobs.get(jobId)
4894
5611
  if (!job) {
4895
5612
  sendError(res, 404, '没有这个 AI 赋能任务')
@@ -4905,6 +5622,7 @@ async function handle(ctx, req, res) {
4905
5622
  job.status = 'failed'
4906
5623
  job.error = `执行任务异常:${error instanceof Error ? error.message : String(error)}`
4907
5624
  job.finishedAt = Date.now()
5625
+ saveAiJobs()
4908
5626
  })
4909
5627
  sendJson(res, 200, { ok: true, jobId })
4910
5628
  return
@@ -4922,6 +5640,7 @@ async function handle(ctx, req, res) {
4922
5640
  job.status = 'failed'
4923
5641
  job.error = '已取消执行(用户中断)'
4924
5642
  job.finishedAt = Date.now()
5643
+ saveAiJobs()
4925
5644
  }
4926
5645
  sendJson(res, 200, { ok: true, jobId })
4927
5646
  return
@@ -4934,13 +5653,124 @@ async function handle(ctx, req, res) {
4934
5653
  try {
4935
5654
  enriched.push(await compStatus(c.id))
4936
5655
  } catch {
4937
- enriched.push({ id: c.id, name: c.name, running: false, healthy: null, pid: null, port: c.port ?? null })
5656
+ enriched.push({ id: c.id, name: c.name, running: false, healthy: null, pid: null, port: c.port ?? null, autoStart: c.autoStart === true })
4938
5657
  }
4939
5658
  }
4940
5659
  sendJson(res, 200, { ok: true, components: enriched.map((x) => ({ ...x, uiUrl: compUiUrl(compFind(x.id) ?? {}) })) })
4941
5660
  return
4942
5661
  }
4943
5662
 
5663
+ if (pathname === `${ROUTE_PREFIX}/repo-clone`) {
5664
+ // 仓库落地:从 GitHub 克隆任意项目到 <配置路径>/<owner>/<name>(不限于 DSH 插件)
5665
+ const raw = typeof body.repo === 'string' ? body.repo.trim() : ''
5666
+ let repo = raw
5667
+ .replace(/^https?:\/\/github\.com\//u, '')
5668
+ .replace(/^git@github\.com:/u, '')
5669
+ .replace(/\.git$/u, '')
5670
+ .replace(/\/+$/u, '')
5671
+ const m = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)$/u.exec(repo)
5672
+ if (!m) {
5673
+ sendError(res, 400, '仓库格式不正确:请提供 owner/repo 或 GitHub 仓库链接')
5674
+ return
5675
+ }
5676
+ const [, owner, name] = m
5677
+ const root = getReposDir()
5678
+ const target = join(root, owner, name)
5679
+ if (existsSync(target)) {
5680
+ sendError(res, 400, `已存在:${target}(如需更新请先手动处理该目录)`)
5681
+ return
5682
+ }
5683
+ try {
5684
+ mkdirSync(root, { recursive: true })
5685
+ await execFileAsync('git.exe', ['clone', '--depth', '1', `https://github.com/${owner}/${name}.git`, target], { cwd: root, timeout: 600000, windowsHide: true, maxBuffer: 4 * 1024 * 1024 })
5686
+ sendJson(res, 200, { ok: true, owner, name, path: target })
5687
+ } catch (error) {
5688
+ sendError(res, 500, `克隆失败:${error instanceof Error ? error.message : String(error)}`)
5689
+ }
5690
+ return
5691
+ }
5692
+
5693
+ if (pathname === `${ROUTE_PREFIX}/repo-list`) {
5694
+ try {
5695
+ sendJson(res, 200, { ok: true, dir: getReposDir(), repos: listLandedRepos() })
5696
+ } catch (error) {
5697
+ sendError(res, 500, error instanceof Error ? error.message : String(error))
5698
+ }
5699
+ return
5700
+ }
5701
+
5702
+ if (pathname === `${ROUTE_PREFIX}/repo-land-config`) {
5703
+ const dir = typeof body.dir === 'string' ? body.dir.trim() : ''
5704
+ if (dir === '' || !/^[A-Za-z]:[\\/]/.test(dir) && !/^\\\\/.test(dir)) {
5705
+ sendError(res, 400, '保存路径不合法:请提供绝对路径(如 D:\\dsh\\repos)')
5706
+ return
5707
+ }
5708
+ try {
5709
+ setReposDir(dir)
5710
+ sendJson(res, 200, { ok: true, dir: getReposDir() })
5711
+ } catch (error) {
5712
+ sendError(res, 500, error instanceof Error ? error.message : String(error))
5713
+ }
5714
+ return
5715
+ }
5716
+
5717
+ if (pathname === `${ROUTE_PREFIX}/repo-remove`) {
5718
+ const target = typeof body.path === 'string' ? body.path : ''
5719
+ const rootNorm = getReposDir().replace(/[\\/]+$/u, '')
5720
+ const tNorm = target.replace(/[\\/]+$/u, '')
5721
+ if (!tNorm.startsWith(rootNorm) || tNorm === rootNorm || !existsSync(target)) {
5722
+ sendError(res, 400, '路径不在仓库落地目录内或不存在')
5723
+ return
5724
+ }
5725
+ try {
5726
+ rmSync(target, { recursive: true, force: true })
5727
+ sendJson(res, 200, { ok: true })
5728
+ } catch (error) {
5729
+ sendError(res, 500, `删除失败:${error instanceof Error ? error.message : String(error)}`)
5730
+ }
5731
+ return
5732
+ }
5733
+
5734
+ if (pathname === `${ROUTE_PREFIX}/repo-open`) {
5735
+ const target = typeof body.path === 'string' ? body.path : ''
5736
+ const rootNorm = getReposDir().replace(/[\\/]+$/u, '')
5737
+ const tNorm = target.replace(/[\\/]+$/u, '')
5738
+ if (!tNorm.startsWith(rootNorm) || tNorm === rootNorm) {
5739
+ sendError(res, 400, '路径不在仓库落地目录内')
5740
+ return
5741
+ }
5742
+ if (!existsSync(target)) {
5743
+ sendError(res, 404, `目录不存在:${target}`)
5744
+ return
5745
+ }
5746
+ try {
5747
+ execFile('explorer.exe', [target], { windowsHide: true, detached: true }).unref()
5748
+ sendJson(res, 200, { ok: true })
5749
+ } catch (error) {
5750
+ sendError(res, 500, `打开失败:${error instanceof Error ? error.message : String(error)}`)
5751
+ }
5752
+ return
5753
+ }
5754
+
5755
+ if (pathname === `${ROUTE_PREFIX}/component/autostart`) {
5756
+ const id = typeof body.id === 'string' ? body.id : ''
5757
+ const enabled = body.enabled === true
5758
+ if (id === '') {
5759
+ sendError(res, 400, '缺少组件 id')
5760
+ return
5761
+ }
5762
+ if (!compFind(id)) {
5763
+ sendError(res, 404, '组件不存在')
5764
+ return
5765
+ }
5766
+ compUpsert({ id, autoStart: enabled })
5767
+ if (enabled) {
5768
+ compStart(id).catch(() => {})
5769
+ }
5770
+ sendJson(res, 200, { ok: true, id, autoStart: enabled })
5771
+ return
5772
+ }
5773
+
4944
5774
  if (pathname === `${ROUTE_PREFIX}/component/start`) {
4945
5775
  const id = typeof body.id === 'string' ? body.id : ''
4946
5776
  if (id === '') {