@noob-stupid/dsh-plugin-console 0.3.33 → 0.3.47

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
@@ -13,10 +13,10 @@
13
13
  * 移除该块即恢复;HMR 监视器会自动重组合,无需重启。
14
14
  */
15
15
  import { readFile, writeFile } from 'node:fs/promises'
16
- import { readFileSync, writeFileSync, existsSync, statSync, rmSync, readdirSync, mkdirSync, copyFileSync } from 'node:fs'
16
+ import { readFileSync, writeFileSync, existsSync, statSync, rmSync, readdirSync, mkdirSync, copyFileSync, realpathSync } from 'node:fs'
17
17
  import { execFile, spawn } from 'node:child_process'
18
18
  import { promisify } from 'node:util'
19
- import { dirname, join, basename } from 'node:path'
19
+ import { dirname, join, basename, sep } from 'node:path'
20
20
  import { tmpdir, homedir } from 'node:os'
21
21
  import { fileURLToPath } from 'node:url'
22
22
  import { createRequire } from 'node:module'
@@ -341,6 +341,26 @@ function semverRangeMatchLoose(versionText, rangeText) {
341
341
  * 静态声明检查判 pass 是假通过;真实兼容性只有模块 import 时见分晓,而 loader 单行失败
342
342
  * = 整个服务启动崩溃)。返回命中的符号列表,空数组 = 干净。
343
343
  */
344
+ const REMOVED_SETTINGS_SYMBOLS = ['settingsNamespace', 'installSettingsSection']
345
+
346
+ /** 源码是否**引用**了已删除符号——而不是「恰好包含同名前缀的其它标识符」或「自己定义的同名局部函数」。
347
+ * v0.3.35 修复(真机演练抓到的误伤):原先用 `text.includes('settingsNamespace')` 子串匹配,
348
+ * 而框架自带的 @deepseek-ai/dsh-api-settings-controller 里有个 `settingsNamespaceRequestSchema`,
349
+ * 于是框架自己的 settings 控制器被判「引用已删除 API」→ 升级预扫会把它自动禁用(砍掉框架功能)。
350
+ * 现在要求标识符边界,且排除「本地 const/let/var/function/class 定义且同行没有 dsh-settings 引用」。 */
351
+ function referencesRemovedSymbol(text, sym) {
352
+ const re = new RegExp(`(?<![A-Za-z0-9_$])${sym}(?![A-Za-z0-9_$])`, 'u')
353
+ for (const line of text.split(/\r?\n/u)) {
354
+ const m = re.exec(line)
355
+ if (m === null) continue
356
+ const before = line.slice(0, m.index)
357
+ const localDefinition = /(?:^|[^\w$])(?:const|let|var|function|class|async\s+function)\s*$/u.test(before)
358
+ if (localDefinition && !/dsh-settings/u.test(line)) continue
359
+ return true
360
+ }
361
+ return false
362
+ }
363
+
344
364
  function scanSettingsApiUsage(pkgDir) {
345
365
  const found = []
346
366
  const seen = new Set()
@@ -361,8 +381,9 @@ function scanSettingsApiUsage(pkgDir) {
361
381
  if (seen.size > 120) return
362
382
  try {
363
383
  const text = readFileSync(full, 'utf8').slice(0, 400000)
364
- for (const sym of ['settingsNamespace', 'installSettingsSection']) {
365
- if (text.includes(sym) && !found.includes(sym)) found.push(sym)
384
+ for (const sym of REMOVED_SETTINGS_SYMBOLS) {
385
+ if (found.includes(sym)) continue
386
+ if (referencesRemovedSymbol(text, sym)) found.push(sym)
366
387
  }
367
388
  } catch {}
368
389
  }
@@ -371,6 +392,18 @@ function scanSettingsApiUsage(pkgDir) {
371
392
  return found
372
393
  }
373
394
 
395
+ /** 包是否由框架自带(解析到 profile 目录之外,如 npx/pnpm 缓存里的 @deepseek-ai/*)。
396
+ * v0.3.35:这类行随框架一起发布,禁用**不是**正确处置(正确处置是回滚框架),
397
+ * 而且一旦判定有误就会把框架功能砍掉——所以升级预扫永不自动禁用它们。 */
398
+ function isFrameworkOwnedPackage(pkgDir, profileDir) {
399
+ if (typeof pkgDir !== 'string' || pkgDir === '' || typeof profileDir !== 'string' || profileDir === '') return false
400
+ const real = (p) => { try { return realpathSync(p) } catch { return p } }
401
+ const dir = real(pkgDir).toLowerCase()
402
+ let base = real(profileDir).toLowerCase()
403
+ if (!base.endsWith(sep)) base += sep
404
+ return !dir.startsWith(base)
405
+ }
406
+
374
407
  /**
375
408
  * 插件-框架兼容校验(框架升级适配门):
376
409
  * 1) 显式兼容声明(dsh.engines.framework / dsh.compat.framework / engines.dsh):以声明为准;
@@ -1073,6 +1106,208 @@ function writeCompatPending(payload) {
1073
1106
  } catch {}
1074
1107
  }
1075
1108
 
1109
+ /** 兼容门总开关(用户定案 2026-09-11):用户可关掉自动行为,回到纯手动。
1110
+ * autoDisable —— 升级前是否自动禁用判定不适配的行(关掉 = 只提示不动开关)
1111
+ * autoDetect —— 打开控制台时是否自动检测「已适配」(关掉 = 不显示可解锁提示) */
1112
+ const COMPAT_GATE_FILE = () => join(dshHome(), 'plugin-console', 'compat-gate.json')
1113
+ const COMPAT_GATE_DEFAULTS = { autoDisable: true, autoDetect: true }
1114
+ function readCompatGate() {
1115
+ try {
1116
+ const raw = JSON.parse(readFileSync(COMPAT_GATE_FILE(), 'utf8'))
1117
+ return {
1118
+ autoDisable: raw?.autoDisable !== false,
1119
+ autoDetect: raw?.autoDetect !== false,
1120
+ }
1121
+ } catch { return { ...COMPAT_GATE_DEFAULTS } }
1122
+ }
1123
+ function writeCompatGate(patch) {
1124
+ const next = { ...readCompatGate(), ...patch }
1125
+ try {
1126
+ mkdirSync(dirname(COMPAT_GATE_FILE()), { recursive: true })
1127
+ writeFileSync(COMPAT_GATE_FILE(), JSON.stringify(next, null, 2), 'utf8')
1128
+ } catch {}
1129
+ return next
1130
+ }
1131
+
1132
+ /** 待适配行的「现在是否已适配」检测(只提示,不自动解锁——用户定案:我点才开)。
1133
+ * 返回 Map<rowId, {version, check, note}>:仅当版本已变化且源码扫描不再 fail 时才算可解锁。 */
1134
+ function detectAdoptablePending(ctx) {
1135
+ const out = new Map()
1136
+ const pending = readCompatPending()
1137
+ if (pending === null) return out
1138
+ const fwVer = typeof pending.frameworkVersion === 'string' ? pending.frameworkVersion : null
1139
+ if (fwVer === null) return out
1140
+ let profileDir = null
1141
+ try { profileDir = dirname(findPatchPath(ctx)) } catch { return out }
1142
+ for (const p of (pending.pending ?? []).filter((x) => (x.status ?? 'pending') === 'pending')) {
1143
+ try {
1144
+ const pkgPath = resolvePackageJson(p.moduleName, profileDir)
1145
+ if (pkgPath === null) continue
1146
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
1147
+ const version = typeof pkg.version === 'string' ? pkg.version : null
1148
+ const changed = version !== null && version !== p.version
1149
+ const check = checkPluginFrameworkCompat(pkg, fwVer, dirname(pkgPath))
1150
+ if (changed && check.decision !== 'fail') {
1151
+ out.set(p.rowId, { version, check: check.decision, note: check.reason ?? null })
1152
+ }
1153
+ } catch {}
1154
+ }
1155
+ return out
1156
+ }
1157
+
1158
+ /** 把升级脚本留下的「启动失败隔离」记录并入适配门清单(只处理一次:处理后改名 .applied)。
1159
+ * 作用:服务被隔离救回来之后,用户能在面板上看到「谁被自动关了、为什么」,并可逐个解锁。 */
1160
+ /** 读取升级脚本写下的隔离记录(含 BOM 兼容)。
1161
+ * ★ 2026-09-11 事故根因:升级脚本用 PowerShell `Set-Content -Encoding UTF8` 写这个文件,
1162
+ * PS5.1 会**带 UTF-8 BOM**;而合并逻辑先"复制 + 删除"再判断 JSON.parse 结果 → BOM 让 parse
1163
+ * 必失败 → 记录被销毁、却从未并进适配门清单 → 界面上那 20 行只剩一个**没有解释的【停用】**。
1164
+ * 这里统一剥 BOM 再解析;解析失败返回 null(调用方会保留文件、留错误日志,绝不销毁证据)。 */
1165
+ function readQuarantineRecord() {
1166
+ const file = join(dshHome(), 'plugin-console', 'fw-quarantine.json')
1167
+ if (!existsSync(file)) return null
1168
+ let raw = ''
1169
+ try { raw = readFileSync(file, 'utf8') } catch { return null }
1170
+ if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1)
1171
+ try {
1172
+ const rec = JSON.parse(raw)
1173
+ return rec !== null && typeof rec === 'object' ? rec : null
1174
+ } catch { return null }
1175
+ }
1176
+
1177
+ /** 合并隔离记录失败时留痕(v0.3.44):静默 catch 让 2026-09-11 那次 20 行隔离记录凭空消失,
1178
+ * 界面只剩一个没有解释的【停用】,而且事后完全查不到原因。错误落到 fw-merge-error.log。 */
1179
+ function logQuarantineMergeError(error) {
1180
+ try {
1181
+ const file = join(dshHome(), 'plugin-console', 'fw-merge-error.log')
1182
+ mkdirSync(dirname(file), { recursive: true })
1183
+ writeFileSync(file, `${new Date().toISOString()} ${error?.stack ?? String(error)}\n`, { flag: 'a' })
1184
+ } catch {}
1185
+ }
1186
+
1187
+ /** 把升级脚本留下的「启动失败隔离」记录并入适配门清单。
1188
+ * 顺序很关键(v0.3.44):**先写清单并校验成功,再归档移除记录**。反过来的话,
1189
+ * 任何解析/写入意外都会"记录没了、清单也没进",用户只看到一个没有理由的【停用】。
1190
+ * 导出仅供测试直接驱动(避免测试为了跑它而 apply 整个插件)。 */
1191
+ export function mergeQuarantineRecord(ctx) {
1192
+ const file = join(dshHome(), 'plugin-console', 'fw-quarantine.json')
1193
+ if (!existsSync(file)) return null
1194
+ const rec = readQuarantineRecord()
1195
+ if (rec === null) {
1196
+ logQuarantineMergeError(new Error('隔离记录无法解析(已按 BOM 兼容处理仍失败),保留文件待下次启动重试'))
1197
+ return null
1198
+ }
1199
+ const rows = (Array.isArray(rec.rows) ? rec.rows : []).filter((rowId) => typeof rowId === 'string' && rowId !== '')
1200
+ const pending = readCompatPending() ?? { frameworkVersion: null, upgradeFrom: null, pending: [] }
1201
+ if (!Array.isArray(pending.pending)) pending.pending = []
1202
+ const lines = Array.isArray(rec.lines) ? rec.lines : []
1203
+ // 隔离记录里只有 rowId(脚本写的),但清单里其它地方都按 moduleName 认身份:
1204
+ // 「全家桶一键启用已适配」按 moduleName 前缀匹配、`检测到已适配 vX` 也要 moduleName 才能算。
1205
+ // 所以这里按当前 loader 反查补上(v0.3.45:补不上就会永远匹配不到 —— 用户实测「点了说没有待适配行」)。
1206
+ const info = ctx === undefined || ctx === null ? new Map() : rowIdModuleMap(ctx)
1207
+ for (const rowId of rows) {
1208
+ const meta = info.get(rowId) ?? null
1209
+ const record = {
1210
+ rowId,
1211
+ moduleName: meta?.moduleName ?? null,
1212
+ version: meta?.version ?? null,
1213
+ status: 'pending',
1214
+ check: 'unknown',
1215
+ checkNote: `启动失败隔离(${rec.mode ?? 'targeted'}):${lines[0] ?? '启动日志命中,服务曾被它拖垮'}`,
1216
+ forcedAt: Date.now(),
1217
+ source: 'boot-quarantine',
1218
+ }
1219
+ const at = pending.pending.findIndex((p) => p.rowId === rowId)
1220
+ if (at >= 0) pending.pending[at] = { ...pending.pending[at], ...record }
1221
+ else pending.pending.push(record)
1222
+ // 该行现在就是启用的(比如用户在隔离之后已经手动启用过)→ 直接记成已适配,别留一个假 pending
1223
+ if (meta?.enabled === true) markPendingAdopted(pending, rowId, 'row-enabled', meta)
1224
+ }
1225
+ // 预设隔离单独记录(它不是插件行,没有「启用」语义;解锁 = 把 .broken 文件改回来)
1226
+ if (Array.isArray(rec.presets) && rec.presets.length > 0) {
1227
+ pending.presetsQuarantined = rec.presets.map((name) => ({ name, at: rec.at ?? null, note: 'agent.cordis.yml 已改名 .broken(启动失败隔离),确认修好后改回文件名即可恢复' }))
1228
+ }
1229
+ pending.quarantineAt = rec.at ?? null
1230
+ pending.quarantineLines = lines.slice(0, 5)
1231
+ writeCompatPending(pending)
1232
+ // 写后校验:清单里必须真的能看到这些行,才允许销毁原始记录
1233
+ const back = readCompatPending()
1234
+ const missing = rows.filter((rowId) => !(back?.pending ?? []).some((p) => p.rowId === rowId))
1235
+ if (back === null || missing.length > 0) {
1236
+ logQuarantineMergeError(new Error(`隔离记录写入清单后校验失败(缺失 ${missing.length}/${rows.length} 行),保留记录待下次启动重试`))
1237
+ return rec
1238
+ }
1239
+ try {
1240
+ copyFileSync(file, `${file}.applied-${Date.now()}`)
1241
+ rmSync(file, { force: true })
1242
+ } catch {}
1243
+ return rec
1244
+ }
1245
+
1246
+ /** 当前 loader 树里的 rowId → { moduleName, version, enabled }(补 moduleName、对账用)。 */
1247
+ export function rowIdModuleMap(ctx) {
1248
+ const out = new Map()
1249
+ let entries = []
1250
+ try { entries = listEntries(ctx) } catch { return out }
1251
+ const profileDir = profileDirOf(ctx)
1252
+ for (const entry of entries) {
1253
+ if (typeof entry.rowId !== 'string' || entry.rowId === '') continue
1254
+ let version = null
1255
+ try {
1256
+ const pkgPath = profileDir === null ? null : resolvePackageJson(entry.moduleName, profileDir)
1257
+ if (pkgPath !== null) version = JSON.parse(readFileSync(pkgPath, 'utf8')).version ?? null
1258
+ } catch {}
1259
+ out.set(entry.rowId, { moduleName: entry.moduleName ?? null, version, enabled: entry.enabled === true })
1260
+ }
1261
+ return out
1262
+ }
1263
+
1264
+ /** 把某行的待适配记录标记为「已适配」(保留历史判定痕迹:check / checkNote / riskyApprovedAt)。
1265
+ * 语义(用户定案 2026-09-11):**启用即视为已适配**,但要留下"曾被判定/隔离"的痕迹供事后查。 */
1266
+ function markPendingAdopted(pending, rowId, by, meta) {
1267
+ const rec = (pending?.pending ?? []).find((p) => p.rowId === rowId)
1268
+ if (rec === undefined) return false
1269
+ rec.status = 'adopted'
1270
+ rec.adoptedAt = Date.now()
1271
+ rec.adoptedBy = by
1272
+ if (meta !== null && meta !== undefined) {
1273
+ if (typeof meta.moduleName === 'string' && meta.moduleName !== '') rec.moduleName = meta.moduleName
1274
+ if (typeof meta.version === 'string' && meta.version !== '') rec.version = meta.version
1275
+ }
1276
+ return true
1277
+ }
1278
+
1279
+ /** 启动时让清单与现实对账(v0.3.45):
1280
+ * ① 老记录缺 moduleName → 按当前 loader 补上(否则「全家桶一键启用已适配」永远匹配不到);
1281
+ * ② 记录还是 pending、但这一行**当前已经启用**(用户手动启用过 / 补丁被清过)→ 转 adopted。
1282
+ * 不这么做就会出现用户实测的那种矛盾:**已启用的行,重启后仍挂着【待适配】**。
1283
+ * 返回 { backfilled, adopted } 供日志/测试核对;无变化则不写盘。 */
1284
+ export function reconcileCompatPending(ctx) {
1285
+ const pending = readCompatPending()
1286
+ if (pending === null || !Array.isArray(pending.pending)) return { backfilled: 0, adopted: 0 }
1287
+ const info = rowIdModuleMap(ctx)
1288
+ let backfilled = 0
1289
+ let adopted = 0
1290
+ for (const rec of pending.pending) {
1291
+ if ((rec.status ?? 'pending') !== 'pending') continue
1292
+ const meta = info.get(rec.rowId) ?? null
1293
+ if (meta !== null) {
1294
+ if ((rec.moduleName === null || rec.moduleName === undefined || rec.moduleName === '') && typeof meta.moduleName === 'string' && meta.moduleName !== '') {
1295
+ rec.moduleName = meta.moduleName
1296
+ if (meta.version !== null && meta.version !== undefined) rec.version = meta.version
1297
+ backfilled += 1
1298
+ }
1299
+ if (meta.enabled === true) {
1300
+ if (markPendingAdopted(pending, rec.rowId, 'row-enabled', meta)) adopted += 1
1301
+ }
1302
+ }
1303
+ }
1304
+ if (backfilled > 0 || adopted > 0) {
1305
+ pending.updatedAt = new Date().toISOString()
1306
+ writeCompatPending(pending)
1307
+ }
1308
+ return { backfilled, adopted }
1309
+ }
1310
+
1076
1311
  /** 定位 @deepseek-ai/dsh-app-boot(与 @deepseek-ai/dsh 同级)。 */
1077
1312
  function locateAppBootFile(baseUrl) {
1078
1313
  try {
@@ -1181,6 +1416,27 @@ function profileDirOf(ctx) {
1181
1416
  }
1182
1417
  }
1183
1418
 
1419
+ /** 框架版本检查缓存(功能包 → 框架 面板用;5 分钟 TTL,避免每次打开面板都打 registry)。 */
1420
+ let fwCheckCache = null
1421
+
1422
+ /** 清理残留的框架升级/回滚/重启计划任务(脚本被强杀时它来不及自删)。
1423
+ * v0.3.43:把「重启」与「重启守护」任务也纳入——2026-09-11 现场残留了 5 个 Ready 僵尸任务
1424
+ * (DSH-Restart-13804 / -31688 / -3744 / RestartV2 / RestartV3),正是"重启后没人拉起"的证据。
1425
+ * 只在服务已经起来了的时候清理是安全的:服务在跑 ⇒ 守护任务无事可做(它自己也会立刻收工)。 */
1426
+ function cleanupStaleFwTasks() {
1427
+ try {
1428
+ execFile('schtasks', ['/query', '/fo', 'CSV', '/nh'], { windowsHide: true, timeout: 20000 }, (error, stdout) => {
1429
+ if (error) return
1430
+ const names = String(stdout).split(/\r?\n/u)
1431
+ .map((line) => (line.match(/^"([^"]*)"/u)?.[1] ?? '').trim())
1432
+ .filter((name) => /^\\?DSH-(?:FW-(?:Upgrade|Rollback)|Restart(?:V\d+)?|RestartGuard)-\d+$/u.test(name))
1433
+ for (const name of names) {
1434
+ execFile('schtasks', ['/delete', '/f', '/tn', name], { windowsHide: true, timeout: 20000 }, () => {})
1435
+ }
1436
+ })
1437
+ } catch {}
1438
+ }
1439
+
1184
1440
  /** 读取补丁文件并扫描:停用块与 insert 行的 id。 */
1185
1441
  async function readPatchState(patchPath) {
1186
1442
  let text = ''
@@ -2346,17 +2602,17 @@ let installJobSeq = 0
2346
2602
  const aiJobs = new Map()
2347
2603
  let aiJobSeq = 0
2348
2604
  /** 组件注册表(服务型组件:启动/停止/状态控制按钮的数据源)。 */
2349
- const COMPONENTS_FILE = join(homedir(), '.dsh', 'plugin-console', 'components.json')
2605
+ const componentsFile = () => join(dshHome(), 'plugin-console', 'components.json')
2350
2606
  /** AI 赋能任务持久化文件(DSH 重启不丢计划/结果;running 任务重启后标记中断)。 */
2351
- const AI_JOBS_FILE = join(homedir(), '.dsh', 'plugin-console', 'ai-jobs.json')
2607
+ const aiJobsFile = () => join(dshHome(), 'plugin-console', 'ai-jobs.json')
2352
2608
  /** 仓库落地根目录(可配置,默认 ~/.dsh/repos)。 */
2353
2609
  let reposDirCache = null
2354
- const REPO_LAND_CONF = join(homedir(), '.dsh', 'plugin-console', 'repo-land.json')
2610
+ const repoLandConfFile = () => join(dshHome(), 'plugin-console', 'repo-land.json')
2355
2611
  function getReposDir() {
2356
2612
  if (reposDirCache !== null) return reposDirCache
2357
2613
  try {
2358
- if (existsSync(REPO_LAND_CONF)) {
2359
- const conf = JSON.parse(readFileSync(REPO_LAND_CONF, 'utf8'))
2614
+ if (existsSync(repoLandConfFile())) {
2615
+ const conf = JSON.parse(readFileSync(repoLandConfFile(), 'utf8'))
2360
2616
  if (typeof conf.dir === 'string' && conf.dir.trim() !== '') {
2361
2617
  reposDirCache = conf.dir.trim()
2362
2618
  return reposDirCache
@@ -2368,8 +2624,8 @@ function getReposDir() {
2368
2624
  }
2369
2625
  function setReposDir(dir) {
2370
2626
  reposDirCache = String(dir ?? '').trim()
2371
- mkdirSync(dirname(REPO_LAND_CONF), { recursive: true })
2372
- writeFileSync(REPO_LAND_CONF, JSON.stringify({ dir: reposDirCache }, null, 2), 'utf8')
2627
+ mkdirSync(dirname(repoLandConfFile()), { recursive: true })
2628
+ writeFileSync(repoLandConfFile(), JSON.stringify({ dir: reposDirCache }, null, 2), 'utf8')
2373
2629
  return reposDirCache
2374
2630
  }
2375
2631
  /** 已落地仓库列表:扫描 dir 下两级目录(owner/name 下含 .git)。 */
@@ -2394,15 +2650,15 @@ function listLandedRepos() {
2394
2650
  }
2395
2651
  function saveAiJobs() {
2396
2652
  try {
2397
- mkdirSync(dirname(AI_JOBS_FILE), { recursive: true })
2653
+ mkdirSync(dirname(aiJobsFile()), { recursive: true })
2398
2654
  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 }))
2399
- writeFileSync(AI_JOBS_FILE, JSON.stringify(arr, null, 2), 'utf8')
2655
+ writeFileSync(aiJobsFile(), JSON.stringify(arr, null, 2), 'utf8')
2400
2656
  } catch {}
2401
2657
  }
2402
2658
  function loadAiJobs() {
2403
2659
  try {
2404
- if (!existsSync(AI_JOBS_FILE)) return
2405
- const arr = JSON.parse(readFileSync(AI_JOBS_FILE, 'utf8'))
2660
+ if (!existsSync(aiJobsFile())) return
2661
+ const arr = JSON.parse(readFileSync(aiJobsFile(), 'utf8'))
2406
2662
  for (const j of arr) {
2407
2663
  if (!j || typeof j.id !== 'string') continue
2408
2664
  let job = j
@@ -2722,7 +2978,7 @@ async function runSuiteInstallJob(job, ctx) {
2722
2978
 
2723
2979
  /** 技能安装:git clone 仓库 → 定位 SKILL.md(根或第一层子目录)→ 复制到 ~/.dsh/skills/<name>/。
2724
2980
  * 技能由 dsh-skill-filesystem 插件扫描(发现根:<dshHome>/skills),与桌面版共享。 */
2725
- async function runSkillInstallJob(job, ctx) {
2981
+ async function runSkillInstallJob(job) {
2726
2982
  const tmpDir = join(tmpdir(), `dsh-skill-${job.id}-${Date.now()}`)
2727
2983
  try {
2728
2984
  job.stage = 'preparing'
@@ -3146,7 +3402,7 @@ function resolvePythonPath() {
3146
3402
 
3147
3403
  function findComponents() {
3148
3404
  try {
3149
- const parsed = JSON.parse(readFileSync(COMPONENTS_FILE, 'utf8'))
3405
+ const parsed = JSON.parse(readFileSync(componentsFile(), 'utf8'))
3150
3406
  return Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.components) ? parsed.components : [])
3151
3407
  } catch {
3152
3408
  return []
@@ -3154,8 +3410,8 @@ function findComponents() {
3154
3410
  }
3155
3411
 
3156
3412
  function saveComponents(list) {
3157
- mkdirSync(dirname(COMPONENTS_FILE), { recursive: true })
3158
- writeFileSync(COMPONENTS_FILE, JSON.stringify(list, null, 2), 'utf8')
3413
+ mkdirSync(dirname(componentsFile()), { recursive: true })
3414
+ writeFileSync(componentsFile(), JSON.stringify(list, null, 2), 'utf8')
3159
3415
  }
3160
3416
 
3161
3417
  function compFind(id) {
@@ -3808,6 +4064,60 @@ const PRESET_CONFIG_MIGRATIONS = [
3808
4064
  { pkg: '@deepseek-ai/dsh-persona', from: 'text', to: 'prefix', since: '0.1.5' },
3809
4065
  ]
3810
4066
 
4067
+ /**
4068
+ * 生成「拉起 DSH 服务」的 PowerShell 前导块(升级脚本 / 回滚脚本共用,v0.3.37 事故修复)。
4069
+ *
4070
+ * 2026-09-11 事故:脚本在「重启服务」这一步崩溃,服务没人拉起(框架其实已经升级成功,
4071
+ * 界面却显示全红)。根因是一条**静默的 null**:
4072
+ * $binNow = ''; try { $binNow = (& node -e "…require.resolve…" | Select-Object -Last 1) } catch {}
4073
+ * if ($binNow -ne '' -and (Test-Path $binNow)) { … }
4074
+ * 当 node 解析那一瞬间失败(新版链接尚未就绪等)时输出为空 → `Select-Object -Last 1` 让
4075
+ * `$binNow` 变成 **$null**,而 PowerShell 里 `$null -ne ''` 是 **true**(守卫失效)→
4076
+ * `Test-Path $null` 抛「无法将参数绑定到参数"Path",因为该参数是空值」→ 脚本当场终止。
4077
+ * 同一段代码原先被复制了 5 份,所以这个坑反复出现。
4078
+ *
4079
+ * 现在:只保留这一份实现,并且
4080
+ * 1) 返回值**永不为 $null**(非字符串一律归一成空串,再做 IsNullOrWhiteSpace 判断);
4081
+ * 2) 解析走**多级回退**(不再假设某一处路径一定可用):node resolve → 目标版本的 .pnpm 实体
4082
+ * 目录 → 顶层可见链接 → .pnpm 里最新的一个;
4083
+ * 3) 全路径参数一律 `Test-Path -LiteralPath`,失败只记录、不抛错。
4084
+ */
4085
+ function relaunchPrelude({ nodePath, pluginDir, fwRoot, target, ps }) {
4086
+ const probe = "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'))"
4087
+ const pnpmBin = (dirExpr) => `Join-Path ${dirExpr} 'node_modules\\@deepseek-ai\\dsh\\lib\\bin.js'`
4088
+ return [
4089
+ `$launchLog = ''`,
4090
+ // 心跳(v0.3.39):脚本每推进一小步就更新一次心跳文件的时间戳。服务端据此区分
4091
+ // 「脚本还在干活」与「脚本进程被系统/启动器杀掉」。2026-09-11 真机事故:回滚脚本
4092
+ // 干完活之后被 Ctrl+C 类事件结束(计划任务 Last Result = 0xC000013A),终态没写成,
4093
+ // 界面就永远卡在「回滚中…」——有心跳就能判定「脚本已死 + 现实是什么」。
4094
+ `$hb = $state + '.hb'`,
4095
+ `function Beat { try { Set-Content -Path $hb -Value ([string](Get-Date).Ticks) -Encoding UTF8 } catch {} }`,
4096
+ `try { Beat } catch {}`,
4097
+ `try { if ($log) { $launchLog = Join-Path (Split-Path -LiteralPath $log) 'fw-relaunch.log' } } catch {}`,
4098
+ `if ([string]::IsNullOrWhiteSpace($launchLog)) { $launchLog = Join-Path $env:TEMP 'fw-relaunch.log' }`,
4099
+ `function Resolve-DshBin {`,
4100
+ ` $cand = ''`,
4101
+ ` try { $env:DSH_RESOLVE_ROOT = ${ps(pluginDir)}; $cand = (& ${ps(nodePath)} -e "${probe}" 2>$null | Select-Object -Last 1) } catch { $cand = '' }`,
4102
+ ` if ($cand -isnot [string]) { $cand = '' }`,
4103
+ ` $cand = ([string]$cand).Trim()`,
4104
+ ` if ($cand -ne '' -and (Test-Path -LiteralPath $cand)) { return $cand }`,
4105
+ ` try { foreach ($d in @(Get-ChildItem -Path (Join-Path ${ps(fwRoot)} '.pnpm') -Directory -Filter '@deepseek-ai+dsh@${target}*' -ErrorAction SilentlyContinue)) { $c = ${pnpmBin('$d.FullName')}; if (Test-Path -LiteralPath $c) { return $c } } } catch {}`,
4106
+ ` try { $c = ${ps(join(fwRoot, '@deepseek-ai', 'dsh', 'lib', 'bin.js'))}; if (Test-Path -LiteralPath $c) { return $c } } catch {}`,
4107
+ ` try { foreach ($d in @(Get-ChildItem -Path (Join-Path ${ps(fwRoot)} '.pnpm') -Directory -Filter '@deepseek-ai+dsh@*' -ErrorAction SilentlyContinue | Sort-Object Name -Descending)) { $c = ${pnpmBin('$d.FullName')}; if (Test-Path -LiteralPath $c) { return $c } } } catch {}`,
4108
+ ` return ''`,
4109
+ `}`,
4110
+ `function Invoke-DshRelaunch($tag) {`,
4111
+ ` $bin = Resolve-DshBin`,
4112
+ ` if ([string]::IsNullOrWhiteSpace($bin)) { Log ('拉起失败(' + $tag + '):node resolve / .pnpm / 顶层链接 三种方式都找不到 dsh 的 bin.js,请手动启动 DSH'); return $false }`,
4113
+ ` try { Add-Content -Path $launchLog -Value ((Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' 拉起(' + $tag + '): ' + $bin) -Encoding UTF8 } catch {}`,
4114
+ ` try { Start-Process -FilePath 'cmd.exe' -ArgumentList @('/c', ('"' + ${ps(nodePath)} + '" "' + $bin + '" web >> "' + $launchLog + '" 2>&1')) -WindowStyle Hidden } catch { Log ('拉起进程启动失败(' + $tag + '):' + $_.Exception.Message); return $false }`,
4115
+ ` Log ('已发起拉起服务(' + $tag + ',输出见 fw-relaunch.log)')`,
4116
+ ` return $true`,
4117
+ `}`,
4118
+ ].join('\r\n')
4119
+ }
4120
+
3811
4121
  /** 待扫描的 agent 配置文件:所有预设 + profile 的 host 组合。 */
3812
4122
  function collectAgentConfigFiles(profileDir) {
3813
4123
  const files = []
@@ -3890,6 +4200,162 @@ export function migrateAgentConfigsForUpgrade(profileDir, targetVersion) {
3890
4200
  return out
3891
4201
  }
3892
4202
 
4203
+ /**
4204
+ * 启动失败日志分析器(纯函数,导出供测试):
4205
+ * 从服务/预设的启动失败日志里提取「谁把服务搞挂了」——用于**启动失败隔离**(升级后服务拉不起来时,
4206
+ * 先禁用/隔离肇事者并重试,而不是整包回滚)。三类信号:
4207
+ * ① 预设挂载失败:`preset "router-spec" failed to mount` 或路径 `.agent-presets/<name>/agent.cordis.yml`
4208
+ * ② loader 条目应用失败:`failed to apply loader entry <rowId> (<moduleName>)`
4209
+ * ③ 模块解析失败:`Cannot find module '<moduleName>'`
4210
+ * 返回 { presets:[name], modules:[{rowId,moduleName}], lines:[命中行] }
4211
+ */
4212
+ export function analyzeBootFailure(logText) {
4213
+ const text = String(logText ?? '')
4214
+ const presets = new Set()
4215
+ const modules = new Map() // moduleName -> rowId|null
4216
+ const hits = []
4217
+ const pushHit = (line) => { if (hits.length < 40 && !hits.includes(line.trim())) hits.push(line.trim().slice(0, 300)) }
4218
+
4219
+ for (const line of text.split(/\r?\n/u)) {
4220
+ if (line.trim() === '') continue
4221
+ let matched = false
4222
+ for (const m of line.matchAll(/preset\s+"([^"]+)"\s+failed to mount/gu)) { presets.add(m[1]); matched = true }
4223
+ for (const m of line.matchAll(/\.agent-presets[\\/]([^\\/\s"']+)[\\/]agent\.cordis\.ya?ml/gu)) { presets.add(m[1]); matched = true }
4224
+ for (const m of line.matchAll(/failed to apply loader entry\s+(\S+)\s+\(([^)]+)\)/gu)) { modules.set(m[2], m[1]); matched = true }
4225
+ for (const m of line.matchAll(/Cannot find module '([^']+)'/gu)) {
4226
+ if (!modules.has(m[1])) modules.set(m[1], null)
4227
+ matched = true
4228
+ }
4229
+ if (matched) pushHit(line)
4230
+ }
4231
+ return {
4232
+ presets: [...presets],
4233
+ modules: [...modules].map(([moduleName, rowId]) => ({ moduleName, rowId })),
4234
+ lines: hits,
4235
+ }
4236
+ }
4237
+
4238
+ /** 启动失败隔离决策器(纯函数,导出供测试)。
4239
+ * 输入:启动失败日志 + 当前可开关行清单([{rowId,moduleName,toggleable}])。
4240
+ * 输出一份**可直接执行的隔离方案**(PowerShell 侧只负责照做,不掺判断逻辑):
4241
+ * presets 要隔离的预设文件(改名 .broken-<ts>,避免预设挂载失败拖垮整个服务)
4242
+ * rows 要写入 disabled:true 的行(已剔除核心行/受保护行/控制台自身)
4243
+ * safeMode 无明确肇事者时是否建议安全模式(禁用全部第三方行,先让服务起来)
4244
+ * coreHits 命中的行属于核心/受保护(禁它没用,正确动作是回滚框架)
4245
+ * 设计依据:loader 单行 import 失败 = 整个服务启动崩溃;静态扫描抓不到全部不兼容,
4246
+ * 所以必须有一条「起不来 → 定位肇事者 → 隔离 → 重试」的运行时兜底。 */
4247
+ export function planQuarantine({ logText, candidates, presetRoot = null, exists = existsSync }) {
4248
+ const analysis = analyzeBootFailure(logText)
4249
+ const byModule = new Map()
4250
+ const byRow = new Map()
4251
+ for (const c of candidates ?? []) {
4252
+ if (typeof c?.rowId !== 'string' || c.rowId === '') continue
4253
+ if (typeof c.moduleName === 'string' && c.moduleName !== '') byModule.set(c.moduleName, c)
4254
+ byRow.set(c.rowId, c)
4255
+ }
4256
+ const rows = []
4257
+ const coreHits = []
4258
+ const unknown = []
4259
+ for (const m of analysis.modules) {
4260
+ const hit = (m.rowId !== null && byRow.get(m.rowId)) || byModule.get(m.moduleName) || null
4261
+ if (hit === null) { unknown.push(m.moduleName); continue }
4262
+ const isCore = CORE_PATCH_ROW_IDS.has(hit.rowId) || hit.rowId === 'plugin-console'
4263
+ if (isCore || hit.toggleable === false) { coreHits.push({ rowId: hit.rowId, moduleName: hit.moduleName }); continue }
4264
+ if (!rows.includes(hit.rowId)) rows.push(hit.rowId)
4265
+ }
4266
+ const root = presetRoot ?? join(dshHome(), '.agent-presets')
4267
+ const presets = []
4268
+ for (const name of analysis.presets) {
4269
+ const file = join(root, name, 'agent.cordis.yml')
4270
+ presets.push({ name, file, exists: exists(file) })
4271
+ }
4272
+ return {
4273
+ presets,
4274
+ rows,
4275
+ unknown,
4276
+ coreHits,
4277
+ safeMode: presets.length === 0 && rows.length === 0,
4278
+ lines: analysis.lines,
4279
+ }
4280
+ }
4281
+
4282
+ /** 框架升级前置门禁(用户硬要求:「升级后所有不适配的必须先禁用」)。
4283
+ * 在升级脚本执行**之前**扫描全部可开关行,对目标框架版本判定为 fail 的行就地写 disabled:true,
4284
+ * 并记入 compat-pending(UI 显示「待适配」,更新后一键解锁)。这样新框架 boot 时不会因为
4285
+ * 某行 import 失败而整树崩溃(loader 单行失败 = 服务起不来)。
4286
+ * 受保护行/核心行/自身永不禁用——禁它们本身就会让服务起不来。
4287
+ * 返回 { disabled:[{rowId,moduleName,version,reason}], skipped:[{rowId,reason}] } */
4288
+ export async function preflightDisableIncompatible({ ctx, profileDir, patchPath, targetVersion }) {
4289
+ const disabled = []
4290
+ const skipped = []
4291
+ const gate = readCompatGate()
4292
+ if (typeof targetVersion !== 'string' || targetVersion === '') return { disabled, skipped }
4293
+ let entries = []
4294
+ try { entries = listEntries(ctx) } catch { return { disabled, skipped } }
4295
+ let pending = readCompatPending()
4296
+ if (pending === null || !Array.isArray(pending.pending)) pending = { frameworkVersion: targetVersion, upgradeFrom: null, pending: [] }
4297
+ for (const entry of entries) {
4298
+ if (typeof entry.rowId !== 'string' || entry.rowId === '') continue
4299
+ if (!entry.enabled) continue
4300
+ if (entry.rowId === 'plugin-console') continue // 控制台自己永不禁用
4301
+ if (!entry.toggleable || CORE_PATCH_ROW_IDS.has(entry.rowId)) {
4302
+ skipped.push({ rowId: entry.rowId, reason: '受保护/核心行(禁用会让服务起不来,改由启动失败隔离兜底)' })
4303
+ continue
4304
+ }
4305
+ let pkg = null
4306
+ let pkgDir = null
4307
+ try {
4308
+ const pkgPath = resolvePackageJson(entry.moduleName, profileDir)
4309
+ if (pkgPath !== null) { pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); pkgDir = dirname(pkgPath) }
4310
+ } catch {}
4311
+ if (pkg === null) {
4312
+ skipped.push({ rowId: entry.rowId, reason: '无法读取包信息(保持启用,改由启动失败隔离兜底)' })
4313
+ continue
4314
+ }
4315
+ // v0.3.35:框架自带包永不自动禁用(真机演练抓到:框架自己的 settings 控制器被误判成不适配)。
4316
+ // 它们与框架同源安装,禁用不是正确处置——正确处置是回滚;误判则直接砍掉框架功能。
4317
+ if (isFrameworkOwnedPackage(pkgDir, profileDir)) {
4318
+ skipped.push({ rowId: entry.rowId, reason: '框架自带包(与框架同源安装,禁用不是正确处置,改由回滚兜底)' })
4319
+ continue
4320
+ }
4321
+ let check = { decision: 'unknown', reason: '' }
4322
+ try { check = checkPluginFrameworkCompat(pkg, targetVersion, pkgDir) } catch (error) { check = { decision: 'unknown', reason: error instanceof Error ? error.message : String(error) } }
4323
+ if (check.decision !== 'fail') continue // pass / unknown 一律不禁用(避免过度禁用把功能砍掉)
4324
+ if (gate.autoDisable !== true) {
4325
+ // 总开关关闭:只报告不动开关(用户定案:自动行为必须可关)
4326
+ skipped.push({ rowId: entry.rowId, reason: `判定不适配(${check.reason ?? '不兼容'}),但「升级时自动禁用」已关闭——保持启用,请手动处理` })
4327
+ continue
4328
+ }
4329
+ try {
4330
+ await disableEntry(patchPath, entry.rowId)
4331
+ } catch (error) {
4332
+ skipped.push({ rowId: entry.rowId, reason: `禁用写入失败:${error instanceof Error ? error.message : String(error)}` })
4333
+ continue
4334
+ }
4335
+ const version = typeof pkg.version === 'string' ? pkg.version : null
4336
+ disabled.push({ rowId: entry.rowId, moduleName: entry.moduleName, version, reason: check.reason ?? null })
4337
+ const record = {
4338
+ rowId: entry.rowId,
4339
+ moduleName: entry.moduleName,
4340
+ version,
4341
+ status: 'pending',
4342
+ check: 'fail',
4343
+ checkNote: check.reason ?? null,
4344
+ forcedAt: Date.now(),
4345
+ source: 'preflight-disabled-before-upgrade',
4346
+ }
4347
+ const at = pending.pending.findIndex((p) => p.rowId === entry.rowId)
4348
+ if (at >= 0) pending.pending[at] = { ...pending.pending[at], ...record }
4349
+ else pending.pending.push(record)
4350
+ }
4351
+ if (disabled.length > 0) {
4352
+ pending.frameworkVersion = targetVersion
4353
+ pending.updatedAt = new Date().toISOString()
4354
+ writeCompatPending(pending)
4355
+ }
4356
+ return { disabled, skipped }
4357
+ }
4358
+
3893
4359
  /** 移除补丁中的单行 disabled/forced 覆盖块(兼容门解锁用)。 */
3894
4360
  function removeDisableBlock(patchPath, rowId) {
3895
4361
  return queuedWrite(async () => {
@@ -4058,7 +4524,7 @@ async function runInstallJob(job, ctx) {
4058
4524
  const skillProbe = await detectSkillRepo(job.repo, branch)
4059
4525
  if (skillProbe.hasSkill) {
4060
4526
  job.kind = 'skill'
4061
- await runSkillInstallJob(job, ctx)
4527
+ await runSkillInstallJob(job)
4062
4528
  return
4063
4529
  }
4064
4530
  job.status = 'failed'
@@ -4365,6 +4831,20 @@ function listEntries(ctx) { const entries = []
4365
4831
  export function apply(ctx) {
4366
4832
  // 恢复 AI 赋能任务(restart 后 running 置失败,plan-ready/结果保留)
4367
4833
  loadAiJobs()
4834
+ // 升级脚本的「启动失败隔离」记录并入适配门清单(让用户看到谁被自动关了)
4835
+ // v0.3.44:这里的 catch 不再静默 —— 2026-09-11 那次隔离记录(20 行)就是这样凭空消失的:
4836
+ // 合并函数在中途抛错、catch 吞掉,界面只剩一个没有解释的【停用】。失败必须留痕。
4837
+ try { mergeQuarantineRecord(ctx) } catch (error) { logQuarantineMergeError(error) }
4838
+ // v0.3.45:清单与现实对账 —— 补 moduleName(否则「全家桶一键启用已适配」永远匹配不到),
4839
+ // 以及把「已经启用、却还挂着 pending」的记录转成已适配(用户实测:启用后重启又变【待适配】)
4840
+ try {
4841
+ const fixed = reconcileCompatPending(ctx)
4842
+ if (fixed.backfilled > 0 || fixed.adopted > 0) {
4843
+ try { writeFileSync(join(dshHome(), 'plugin-console', 'fw-merge-error.log'), `${new Date().toISOString()} 清单对账:补 moduleName ${fixed.backfilled} 行,已启用转已适配 ${fixed.adopted} 行\n`, { flag: 'a' }) } catch {}
4844
+ }
4845
+ } catch (error) { logQuarantineMergeError(error) }
4846
+ // 启动时清掉上次升级/回滚/重启留下的僵尸计划任务(脚本被强杀时来不及自删)
4847
+ try { cleanupStaleFwTasks() } catch {}
4368
4848
  // DSH 启动时自动拉起标记了「自启动」的服务器组件(幂等,不阻塞启动)
4369
4849
  autostartComponents().catch(() => {})
4370
4850
  ctx.effect(() => {
@@ -4421,11 +4901,14 @@ async function handle(ctx, req, res) {
4421
4901
  const extraRows = await readExtraBundleRows(dirname(patchPath))
4422
4902
  const compatPending = readCompatPending()
4423
4903
  const compatPendingRows = new Set((compatPending?.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending').map((p) => p.rowId))
4904
+ // 兼容门总开关 + 自动检测(只提示不自动开)
4905
+ const compatGate = readCompatGate()
4906
+ const adoptable = compatGate.autoDetect ? detectAdoptablePending(ctx) : new Map()
4424
4907
  let rollbackRec = null
4425
4908
  try {
4426
4909
  const rr = JSON.parse(readFileSync(join(dshHome(), 'plugin-console', 'framework-rollback.json'), 'utf8'))
4427
4910
  if (rr !== null && typeof rr.checkpointDir === 'string' && existsSync(join(rr.checkpointDir, '.pnpm'))) {
4428
- rollbackRec = { from: rr.from ?? null, to: rr.to ?? null, at: rr.at ?? null }
4911
+ rollbackRec = { from: rr.from ?? null, to: rr.to ?? null, at: rr.at ?? null, applicable: true }
4429
4912
  }
4430
4913
  } catch {}
4431
4914
  const entries = listEntries(ctx).map((entry) => {
@@ -4438,7 +4921,11 @@ async function handle(ctx, req, res) {
4438
4921
  installDate: meta?.installDate ?? null,
4439
4922
  version: meta?.version ?? null,
4440
4923
  repository: meta?.repository ?? null,
4441
- pendingCompat: compatPendingRows.has(entry.rowId),
4924
+ // v0.3.45:只有"补丁里此刻确实还禁着它"的行才显示【待适配】——
4925
+ // 记录与开关脱节时(用户手动启用过 / 补丁块被清掉)不能继续挂着「待适配」误导人
4926
+ pendingCompat: compatPendingRows.has(entry.rowId) && patch.disables.includes(entry.rowId),
4927
+ // 自动检测结果(只提示,不自动开):该待适配行现在是否已适配(插件已更新 + 扫描通过)
4928
+ adoptable: adoptable.get(entry.rowId) ?? null,
4442
4929
  }
4443
4930
  })
4444
4931
  const compat = await detectCompat(ctx.baseUrl ?? 'file:///')
@@ -4448,12 +4935,18 @@ async function handle(ctx, req, res) {
4448
4935
  // 框架升级检测与适配(备份快照 + 重打框架补丁),try 包裹不阻塞 state 返回
4449
4936
  let framework = null
4450
4937
  try { framework = detectFrameworkUpgrade(ctx) } catch {}
4938
+ // 回滚可用性(2026-09-11 用户困惑:版本已经回滚了,回滚按钮还能点)——
4939
+ // 当前版本已经等于记录里的 from 时,再点回滚等于"恢复到你现在这个版本",无意义。
4940
+ if (rollbackRec !== null) {
4941
+ const currentVer = typeof framework?.version === 'string' ? framework.version : null
4942
+ rollbackRec.applicable = !(currentVer !== null && rollbackRec.from !== null && currentVer === rollbackRec.from)
4943
+ }
4451
4944
  let selfVersion = null
4452
4945
  try {
4453
4946
  const selfPkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'))
4454
4947
  selfVersion = typeof selfPkg.version === 'string' ? selfPkg.version : null
4455
4948
  } catch {}
4456
- 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, patchHeal: patchHeal === null ? null : { healed: patchHeal.healed ?? [], autoDisabled: patchHeal.autoDisabled ?? [], healedAt: patchHeal.healedAt ?? 0 }, 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, checkNote: p.checkNote ?? null, check: p.check ?? 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 })) })
4949
+ 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, patchHeal: patchHeal === null ? null : { healed: patchHeal.healed ?? [], autoDisabled: patchHeal.autoDisabled ?? [], healedAt: patchHeal.healedAt ?? 0 }, 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, checkNote: p.checkNote ?? null, check: p.check ?? null, riskyApprovedAt: p.riskyApprovedAt ?? null, adoptable: adoptable.get(p.rowId) ?? null })) }, compatGate, 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 })) })
4457
4950
  return
4458
4951
  }
4459
4952
 
@@ -4537,7 +5030,50 @@ async function handle(ctx, req, res) {
4537
5030
  if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1)
4538
5031
  const [st, ...rest] = raw.split('|')
4539
5032
  const at = statSync(f).mtimeMs
4540
- status = { status: st ?? 'idle', message: rest.join('|') || null, at }
5033
+ // v0.3.37:失败记录里带 stage=<崩溃前最后阶段>,界面据此把已完成的步骤显示成 ✓(而不是整列 ✕)
5034
+ const stageRaw = rest.find((p) => p.startsWith('stage='))
5035
+ const stage = stageRaw === undefined ? null : (stageRaw.slice(6) || null)
5036
+ status = { status: st ?? 'idle', message: rest.filter((p) => !p.startsWith('stage=')).join('|') || null, stage, at }
5037
+ // ── v0.3.39 状态自愈 ──────────────────────────────────────────────────
5038
+ // 2026-09-11 真机:回滚脚本干完活之后**进程被 Ctrl+C 类事件结束**(计划任务 Last Result
5039
+ // = 0xC000013A),终态没写成 → 界面永远卡在「回滚中…」并每 3 秒轮询。心跳 + 现实核对
5040
+ // 能把它纠正回来:脚本不再心跳(>90 秒没动静)时,用「已装版本 vs 回滚记录的 from/to」
5041
+ // 判断真实结果;同时清掉残留的 DSH-FW-* 计划任务。
5042
+ const terminal = status.status === 'idle' || status.status === 'done' || status.status === 'failed'
5043
+ if (!terminal) {
5044
+ let hbAge = null
5045
+ try { hbAge = Date.now() - statSync(`${f}.hb`).mtimeMs } catch { hbAge = null }
5046
+ const scriptAlive = hbAge !== null && hbAge < 90000
5047
+ if (!scriptAlive) {
5048
+ let rec = null
5049
+ let current = null
5050
+ try { rec = JSON.parse(readFileSync(join(dshHome(), 'plugin-console', 'framework-rollback.json'), 'utf8')) } catch {}
5051
+ try {
5052
+ const localRequire = createRequire(ctx.baseUrl ?? 'file:///')
5053
+ current = JSON.parse(readFileSync(localRequire.resolve('@deepseek-ai/dsh/package.json'), 'utf8')).version ?? null
5054
+ } catch {}
5055
+ const upgradedTo = rec !== null && typeof rec.to === 'string' && current === rec.to
5056
+ const rolledBackTo = rec !== null && typeof rec.from === 'string' && current === rec.from
5057
+ // 「安装阶段」不能靠 from 判定(那时本来就还是旧版本),只认 to
5058
+ if (upgradedTo) {
5059
+ status = { ...status, status: 'done', reconciled: { from: st, note: `脚本进程已中断,但框架已是 ${current}、服务正常 —— 实际结果:升级成功` } }
5060
+ } else if (rolledBackTo && (st === 'rollback' || st === 'relaunching' || st === 'stopped')) {
5061
+ status = { ...status, status: 'done', reconciled: { from: st, note: `脚本进程已中断,但框架已回到 ${current}、服务正常 —— 实际结果:回滚成功` } }
5062
+ } else if (hbAge !== null) {
5063
+ status = { ...status, stalled: true, note: '升级/回滚脚本已超过 90 秒没有心跳,进程可能已被结束——请用「重新检查版本」核对,必要时重启服务' }
5064
+ }
5065
+ if (status.reconciled !== undefined) cleanupStaleFwTasks()
5066
+ }
5067
+ }
5068
+ // 失败但框架本体其实已经装到目标版本:明确告诉用户「升级本体成功、失败的是重启那一步」,
5069
+ // 免得整列红叉让人以为白干了(2026-09-11 真机事故就是这样)。
5070
+ if (status.status === 'failed') {
5071
+ try {
5072
+ const rr = JSON.parse(readFileSync(join(dshHome(), 'plugin-console', 'framework-rollback.json'), 'utf8'))
5073
+ const cur = JSON.parse(readFileSync(join(rr.fwRoot, '@deepseek-ai', 'dsh', 'package.json'), 'utf8')).version
5074
+ if (typeof rr.to === 'string' && rr.to !== '' && cur === rr.to) status.frameworkAtTarget = cur
5075
+ } catch {}
5076
+ }
4541
5077
  // 残留清理:非终止状态(starting/stopped/installing/rollback/pkg/relaunching)超过 15 分钟
4542
5078
  // 视为上次升级的残留——升级脚本要么成功(done)要么失败(failed),服务重启后不可能还在
4543
5079
  // 中途;任务调度失败/脚本空跑时状态会永远停在 starting,重启后不应再自动恢复进度条。
@@ -4654,12 +5190,34 @@ async function handle(ctx, req, res) {
4654
5190
  return
4655
5191
  }
4656
5192
  if (enabled) {
4657
- // 框架升级适配门(事故教训):兼容门清单内的行保持强制禁用,直到更新并通过校验
5193
+ // 框架升级适配门(用户定案 2026-09-11:**软禁**)——自动禁用的目的是「保证新框架能起来」,
5194
+ // 不是「剥夺用户控制权」:默认自动禁用,但允许手动强行启用(首次请求返回 needsConfirm,
5195
+ // 前端弹风险提示确认框;带 confirmRisky:true 才放行)。
5196
+ // 注意下面还有一条**硬**门禁:启用前的 import 冒烟检查——那条是事实性崩溃(模块根本加载不了),
5197
+ // 不允许覆盖(否则下次启动必崩,与「服务永不崩」冲突)。
4658
5198
  const compatPending = readCompatPending()
4659
5199
  const pend = (compatPending?.pending ?? []).find((p) => p.rowId === rowId && (p.status ?? 'pending') === 'pending')
4660
5200
  if (pend) {
4661
- sendError(res, 409, `「${rowId}」仍在框架升级适配门中(${compatPending.frameworkVersion ?? '?'})被强制禁用:请先点击「更新并适配 / 已适配,立即解锁」,通过兼容校验后系统自动解锁`)
4662
- return
5201
+ if (body.confirmRisky !== true) {
5202
+ sendError(
5203
+ res,
5204
+ 409,
5205
+ `「${rowId}」在框架升级适配门清单中(框架 ${compatPending.frameworkVersion ?? '?'}${pend.checkNote ? `,判定:${pend.checkNote}` : ''})——已自动禁用。强行启用可能让 DSH 下次启动失败,需要你确认。`,
5206
+ {
5207
+ code: 'compat-confirm',
5208
+ rowId,
5209
+ moduleName: pend.moduleName ?? null,
5210
+ frameworkVersion: compatPending.frameworkVersion ?? null,
5211
+ checkNote: pend.checkNote ?? null,
5212
+ },
5213
+ )
5214
+ return
5215
+ }
5216
+ try {
5217
+ const next = readCompatPending()
5218
+ const rec = (next?.pending ?? []).find((p) => p.rowId === rowId)
5219
+ if (rec) { rec.riskyApprovedAt = Date.now(); writeCompatPending(next) }
5220
+ } catch {}
4663
5221
  }
4664
5222
  }
4665
5223
  const patchPath = findPatchPath(ctx)
@@ -4680,6 +5238,19 @@ async function handle(ctx, req, res) {
4680
5238
  const result = enabled
4681
5239
  ? await enableEntry(patchPath, rowId)
4682
5240
  : await disableEntry(patchPath, rowId)
5241
+ // v0.3.45(用户定案:启用即视为已适配,但保留痕迹):手动启用一个待适配行后,
5242
+ // 清单里的 pending 记录要转成 adopted —— 否则重启后界面上会出现「已启用却还挂着【待适配】」,
5243
+ // 用户实测就是这样(5 行)。check / checkNote / riskyApprovedAt 一律保留供事后查。
5244
+ if (enabled) {
5245
+ try {
5246
+ const list = readCompatPending()
5247
+ const meta = rowIdModuleMap(ctx).get(rowId) ?? null
5248
+ if (list !== null && markPendingAdopted(list, rowId, 'manual-enable', meta)) {
5249
+ list.updatedAt = new Date().toISOString()
5250
+ writeCompatPending(list)
5251
+ }
5252
+ } catch {}
5253
+ }
4683
5254
  sendJson(res, 200, { ok: true, entryId, rowId, enabled, changed: result.changed, patchPath })
4684
5255
  return
4685
5256
  }
@@ -5407,6 +5978,49 @@ async function handle(ctx, req, res) {
5407
5978
  return
5408
5979
  }
5409
5980
 
5981
+ if (pathname === `${ROUTE_PREFIX}/framework-check`) {
5982
+ // 框架版本检查(「功能包 → 框架」常驻面板用):当前版本 / latest / next / 升级目标。
5983
+ // 与升级路由同一套判定规则,但**只读**(不备份、不写状态文件);5 分钟内存缓存 +
5984
+ // body.refresh === true 强制重查——面板是常驻入口,不能每次打开都打 registry。
5985
+ const now = Date.now()
5986
+ if (body.refresh === true || fwCheckCache === null || now - fwCheckCache.at > 300000) {
5987
+ let latest = null
5988
+ let next = null
5989
+ let registryError = null
5990
+ try {
5991
+ const data = await fetchJsonUrl('https://registry.npmmirror.com/@deepseek-ai%2fdsh')
5992
+ latest = data?.['dist-tags']?.latest ?? null
5993
+ next = data?.['dist-tags']?.next ?? null
5994
+ } catch (error) {
5995
+ registryError = error instanceof Error ? error.message : String(error)
5996
+ }
5997
+ let current = null
5998
+ try {
5999
+ const localRequire = createRequire(ctx.baseUrl ?? 'file:///')
6000
+ const pkg = JSON.parse(readFileSync(localRequire.resolve('@deepseek-ai/dsh/package.json'), 'utf8'))
6001
+ current = typeof pkg.version === 'string' ? pkg.version : null
6002
+ } catch {}
6003
+ const target = latest !== null && current !== null && isFrameworkVersionNewer(latest, current)
6004
+ ? latest
6005
+ : (next !== null && current !== null && isFrameworkVersionNewer(next, current) ? next : null)
6006
+ fwCheckCache = { at: now, data: { current, latest, next, target, registryError } }
6007
+ }
6008
+ sendJson(res, 200, { ok: true, ...fwCheckCache.data, checkedAt: fwCheckCache.at })
6009
+ return
6010
+ }
6011
+
6012
+ if (pathname === `${ROUTE_PREFIX}/compat-gate`) {
6013
+ // 兼容门总开关(用户定案 2026-09-11):自动行为必须可关,关掉即回到纯手动。
6014
+ // autoDisable —— 升级前是否自动禁用判定不适配的行
6015
+ // autoDetect —— 打开控制台时是否自动检测「已适配」(仅提示,绝不自动解锁)
6016
+ const patchBody = {}
6017
+ if (typeof body.autoDisable === 'boolean') patchBody.autoDisable = body.autoDisable
6018
+ if (typeof body.autoDetect === 'boolean') patchBody.autoDetect = body.autoDetect
6019
+ const gateNext = Object.keys(patchBody).length > 0 ? writeCompatGate(patchBody) : readCompatGate()
6020
+ sendJson(res, 200, { ok: true, compatGate: gateNext })
6021
+ return
6022
+ }
6023
+
5410
6024
  if (pathname === `${ROUTE_PREFIX}/adapt-unlock`) {
5411
6025
  // 待适配行「已适配,立即解锁」:无需等新版本——对已装模块重跑源码扫描,通过即移除禁用块。
5412
6026
  const rowId = typeof body.rowId === 'string' ? body.rowId : ''
@@ -5462,10 +6076,31 @@ async function handle(ctx, req, res) {
5462
6076
  const pending = readCompatPending()
5463
6077
  const patchPath = findPatchPath(ctx)
5464
6078
  const profileDir = dirname(patchPath)
5465
- const targets = (pending?.pending ?? []).filter((p) => (p.status ?? 'pending') === 'pending' && (p.moduleName === root || String(p.moduleName).startsWith(root + '/')))
6079
+ // v0.3.45:匹配规则改成「moduleName 前缀 **或** 属主行集合」——
6080
+ // 隔离记录合并进来的老行 moduleName 为空(脚本只写 rowId),只按 moduleName 比会匹配不到,
6081
+ // 于是用户点「一键启用已适配」得到「该全家桶没有待适配行」(实测就是这个)。
6082
+ const info = rowIdModuleMap(ctx)
6083
+ const bundleRowIds = new Set()
6084
+ for (const [rowId, meta] of info) {
6085
+ const name = meta?.moduleName
6086
+ if (typeof name === 'string' && name !== '' && (name === root || name.startsWith(root + '/'))) bundleRowIds.add(rowId)
6087
+ }
6088
+ const inFamily = (p) => {
6089
+ if (typeof p.moduleName === 'string' && p.moduleName !== '' && (p.moduleName === root || p.moduleName.startsWith(root + '/'))) return true
6090
+ return bundleRowIds.has(p.rowId) || info.get(p.rowId)?.moduleName === root
6091
+ }
6092
+ // 目标 = ① 待适配(pending)② **已记已适配、但从没通过源码扫描**(check !== 'pass')。
6093
+ // ② 这类正是"账面上已适配、补丁里却还禁着"的行 —— 老逻辑只挑 pending,于是用户点「一键启用已适配」
6094
+ // 得到"没有待适配行、无需操作",可它们其实一行都没启用(实测就是这个)。
6095
+ const targets = (pending?.pending ?? []).filter((p) => {
6096
+ if (!inFamily(p)) return false
6097
+ const status = p.status ?? 'pending'
6098
+ if (status === 'pending') return true
6099
+ return status === 'adopted' && p.check !== 'pass'
6100
+ })
5466
6101
  if (targets.length === 0) {
5467
6102
  // 无待适配行=全部已适配/已解锁,属正常状态:返回友好提示而非错误(点击「一键启用已适配」不应报"操作失败")
5468
- sendJson(res, 200, { ok: true, unlocked: [], kept: [], note: '该全家桶没有待适配行(已全部适配/解锁),无需操作' })
6103
+ sendJson(res, 200, { ok: true, unlocked: [], kept: [], note: '该全家桶内没有待扫描/待解锁的行(全部已通过源码扫描或已解锁)' })
5469
6104
  return
5470
6105
  }
5471
6106
  const fwVer = typeof pending.frameworkVersion === 'string' ? pending.frameworkVersion : '?'
@@ -5498,7 +6133,17 @@ async function handle(ctx, req, res) {
5498
6133
  unlocked.push(p.rowId)
5499
6134
  }
5500
6135
  writeCompatPending(pending)
5501
- sendJson(res, 200, { ok: true, root, unlocked, kept })
6136
+ // kept = 扫描未通过(保持禁用,并带上原因);unlocked = 本次真解锁的行
6137
+ sendJson(res, 200, {
6138
+ ok: true,
6139
+ root,
6140
+ unlocked,
6141
+ kept,
6142
+ scanned: targets.length,
6143
+ note: kept.length === 0
6144
+ ? `已重跑源码扫描并解锁 ${unlocked.length} 行`
6145
+ : `扫描 ${targets.length} 行:解锁 ${unlocked.length} 行,${kept.length} 行未通过(保持禁用,原因见下)`,
6146
+ })
5502
6147
  return
5503
6148
  }
5504
6149
 
@@ -5637,7 +6282,7 @@ async function handle(ctx, req, res) {
5637
6282
  // 面板自报名(自报名一致性校验用):从插件自身 package.json 读,与部署目录比对
5638
6283
  let selfName = null
5639
6284
  try {
5640
- const selfPkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8'))
6285
+ const selfPkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'))
5641
6286
  selfName = typeof selfPkg.name === 'string' ? selfPkg.name : null
5642
6287
  } catch {}
5643
6288
  try {
@@ -5698,6 +6343,28 @@ async function handle(ctx, req, res) {
5698
6343
  steps.push(`自动迁移了 ${configMigrations.length} 处框架新版不再接受的预设字段(不迁移会让升级后预设挂载失败、服务拉不起来)`)
5699
6344
  }
5700
6345
  }
6346
+ // 1.6) 不适配行预禁用(用户硬要求:「更新框架后所有不适配的必须先禁用」)。
6347
+ // 适配门此前只有「执行侧」(读清单→锁启用→更新后解锁),**检测侧从未实现**:
6348
+ // 那份 compat-pending.json 一直是人工/一次性脚本产出的(v0.3.25 起遗留至今)。
6349
+ // 这里补上检测侧:升级脚本执行**前**扫描全部可开关行,判定 fail 的就地禁用 + 记入清单,
6350
+ // 保证新框架 boot 时不会被某行 import 失败拖垮(loader 单行失败 = 整个服务起不来)。
6351
+ let preflight = { disabled: [], skipped: [] }
6352
+ if (hasUpdate) {
6353
+ try {
6354
+ preflight = await preflightDisableIncompatible({ ctx, profileDir, patchPath: findPatchPath(ctx), targetVersion: target })
6355
+ } catch (error) {
6356
+ steps.push(`不适配行预禁用失败(不阻塞升级,改由启动失败隔离兜底):${error instanceof Error ? error.message : String(error)}`)
6357
+ }
6358
+ for (const row of preflight.disabled) {
6359
+ steps.push(`已预先禁用不适配行:${row.rowId}(${row.moduleName}@${row.version ?? '?'})— ${row.reason ?? '判定不兼容目标框架'}`)
6360
+ }
6361
+ if (preflight.disabled.length > 0) {
6362
+ steps.push(`共预先禁用 ${preflight.disabled.length} 行:新框架启动不会再被它们拖垮;等它们发布适配版本后点「更新并适配」即可解锁`)
6363
+ }
6364
+ if (preflight.skipped.length > 0) {
6365
+ steps.push(`另有 ${preflight.skipped.length} 行无法预判(受保护行/包信息读不到),保持启用,由升级后的启动失败隔离兜底`)
6366
+ }
6367
+ }
5701
6368
  // 2) 自动升级框架:运行中的服务锁着 dsh/lib 目录(EBUSY 无法原地替换),
5702
6369
  // 因此生成独立升级脚本(kill 服务 → npm 升级 → 失败回滚 → 拉起服务),后台执行。
5703
6370
  // 与 /restart 同款自守护模式:端口无监听会自动拉起,不会留下"服务起不来"。
@@ -5714,7 +6381,7 @@ async function handle(ctx, req, res) {
5714
6381
  // 避免"kill 后升级/拉起双双失败,服务永久 down"
5715
6382
  if (!existsSync(corepackJs) || binPath === null || !existsSync(binPath)) {
5716
6383
  steps.push(`升级已取消:无法定位 ${!existsSync(corepackJs) ? 'corepack(pnpm 通道)' : 'dsh bin.js'}(框架安装可能不完整),服务保持运行;请先修复框架再升级`)
5717
- sendJson(res, 200, { ok: true, current, latest, next, target, hasUpdate, upgraded: false, backupDir, configMigrations, steps, hints: steps })
6384
+ sendJson(res, 200, { ok: true, current, latest, next, target, hasUpdate, upgraded: false, backupDir, configMigrations, preflight, steps, hints: steps })
5718
6385
  return
5719
6386
  }
5720
6387
  const profileDir2 = profileDir
@@ -5766,6 +6433,40 @@ async function handle(ctx, req, res) {
5766
6433
  const ps1 = join(tmpdir(), `fw-upgrade-${process.pid}.ps1`)
5767
6434
  const logFile = join(dshHome(), 'plugin-console', 'fw-upgrade.log')
5768
6435
  const stateFile = join(dshHome(), 'plugin-console', 'fw-upgrade-state.txt')
6436
+ // 启动失败隔离所需的两个文件(升级脚本在服务起不来时调用):
6437
+ // ① 分析器:直接 import 本插件的 planQuarantine,避免把判断逻辑再写一份到 PowerShell;
6438
+ // ② 候选行快照:升级发起时的可开关行(决策器据此把日志里的模块名映射回行 id)。
6439
+ const qHelperPath = join(dshHome(), 'plugin-console', 'fw-analyze-boot.mjs')
6440
+ const qCandidatesPath = join(dshHome(), 'plugin-console', 'fw-quarantine-candidates.json')
6441
+ const qRecordPath = join(dshHome(), 'plugin-console', 'fw-quarantine.json')
6442
+ let thirdPartyRows = []
6443
+ try {
6444
+ thirdPartyRows = listEntries(ctx)
6445
+ .filter((e) => e.enabled && e.toggleable && e.rowId !== 'plugin-console'
6446
+ && !CORE_PATCH_ROW_IDS.has(e.rowId)
6447
+ && typeof e.moduleName === 'string' && !e.moduleName.startsWith('@deepseek-ai/'))
6448
+ .map((e) => e.rowId)
6449
+ } catch {}
6450
+ try {
6451
+ const selfLibUrl = new URL('./index.js', import.meta.url).href
6452
+ const helperSource = [
6453
+ '// 自动生成:启动失败隔离分析器(升级脚本在服务起不来时调用;逻辑在插件内,本文件只做转发)',
6454
+ `import { planQuarantine } from ${JSON.stringify(selfLibUrl)}`,
6455
+ "import { readFileSync } from 'node:fs'",
6456
+ 'const [logPath, candPath] = process.argv.slice(2)',
6457
+ "let logText = ''",
6458
+ "try { logText = readFileSync(logPath, 'utf8').split(/\\r?\\n/u).slice(-400).join('\\n') } catch {}",
6459
+ 'let candidates = []',
6460
+ 'try { candidates = JSON.parse(readFileSync(candPath, "utf8")) } catch {}',
6461
+ 'try { process.stdout.write(JSON.stringify(planQuarantine({ logText, candidates }))) } catch (error) { process.stdout.write(JSON.stringify({ error: String(error && error.message ? error.message : error) })) }',
6462
+ '',
6463
+ ].join('\n')
6464
+ writeFileSync(qHelperPath, helperSource, 'utf8')
6465
+ const candidateSnapshot = listEntries(ctx)
6466
+ .filter((e) => typeof e.rowId === 'string' && e.rowId !== '')
6467
+ .map((e) => ({ rowId: e.rowId, moduleName: e.moduleName, toggleable: e.toggleable === true, enabled: e.enabled === true }))
6468
+ writeFileSync(qCandidatesPath, JSON.stringify(candidateSnapshot, null, 2), 'utf8')
6469
+ } catch {}
5769
6470
  try { mkdirSync(dirname(logFile), { recursive: true }) } catch {}
5770
6471
  // PowerShell 字符串里反斜杠是字面量(无 \\ 转义):JSON.stringify 产生的 \\ 必须还原为 \,
5771
6472
  // 否则所有路径无效(npm 调不起来、回滚无效、拉起失败——曾导致升级脚本空跑)
@@ -5776,18 +6477,28 @@ async function handle(ctx, req, res) {
5776
6477
  if (j === '""') return "''"
5777
6478
  return j.replace(/`/gu, '``').replace(/\$/gu, '`$')
5778
6479
  }
6480
+ const patchFilePath = join(profileDir, 'cordis.patch.yml')
6481
+ // 拉起服务的 PowerShell 片段(隔离重试用;与升级/回滚路径共用 relaunchPrelude 里的同一份实现)
6482
+ const launchSnippet = (tag) => `if (-not $ok -and -not $started) { if (Invoke-DshRelaunch '${tag}') { $started = $true } }; Beat`
5779
6483
  const lines = [
5780
6484
  `$state = ${ps(stateFile)}`,
5781
6485
  `$log = ${ps(logFile)}`,
5782
6486
  "function Log($m) { try { Add-Content -Path $log -Value ((Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' ' + $m) -Encoding UTF8 } catch {} }",
5783
- "function SetState($s, $m) { try { Set-Content -Path $state -Value ($s + '|' + $m) -Encoding UTF8 } catch {} }",
6487
+ "function SetState($s, $m) {",
6488
+ // v0.3.37:失败时把**崩溃前最后到达的阶段**一起写进状态文件(stage=…)——
6489
+ // 否则界面只能看到 failed,把已经成功的步骤全打成 ✕(2026-09-11 用户实测就是这个观感:
6490
+ // 框架其实升好了,卡片却整列红叉)。$script:stage 未设置时 [string] 会安全地变成空串。
6491
+ " try { if ($s -eq 'failed') { Set-Content -Path $state -Value ($s + '|' + $m + '|stage=' + [string]$script:stage) -Encoding UTF8; return } } catch {}",
6492
+ " try { if ($s -ne 'done' -and $s -ne 'idle') { $script:stage = $s }; Set-Content -Path $state -Value ($s + '|' + $m) -Encoding UTF8; Beat } catch {}",
6493
+ "}",
6494
+ relaunchPrelude({ nodePath, pluginDir: join(dirname(fileURLToPath(import.meta.url)), '..'), fwRoot, target, ps }),
5784
6495
  // 全局异常兜底(事故教训):脚本任何未捕获异常都会无声死亡、状态永远卡住、服务没人拉起。
5785
6496
  // trap 捕获后:写 failed 状态 + 日志 + 尝试拉起服务 + 自删任务 + 退出
5786
6497
  "trap {",
5787
6498
  " try { SetState 'failed' ('升级脚本异常终止:' + $_.Exception.Message) } catch {}",
5788
6499
  // 事故教训(2026-09-10):只记消息无法定位是哪一行崩的 —— 连出错位置一起写日志
5789
6500
  " try { Log ('升级脚本异常终止:' + $_.Exception.Message + ' @ ' + $_.InvocationInfo.PositionMessage) } catch {}",
5790
- ` try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if (-not $c) { $bt = ''; try { $env:DSH_RESOLVE_ROOT = '${join(dirname(fileURLToPath(import.meta.url)), '..')}'; $bt = (& ${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 ($bt -ne '') { $launchLog = Join-Path (Split-Path $log) 'fw-relaunch.log'; try { Add-Content -Path $launchLog -Value ((Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' 拉起(异常兜底): ' + $bt) -Encoding UTF8 } catch {}; Start-Process -FilePath 'cmd.exe' -ArgumentList @('/c', ('"' + ${ps(nodePath)} + '" "' + $bt + '" web >> "' + $launchLog + '" 2>&1')) -WindowStyle Hidden } } } catch {}`,
6501
+ ` try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if (-not $c) { [void](Invoke-DshRelaunch '异常兜底') } } catch {}`,
5791
6502
  ` schtasks /delete /f /tn ${taskName} 2>$null`,
5792
6503
  " exit 1",
5793
6504
  "}",
@@ -5820,7 +6531,7 @@ async function handle(ctx, req, res) {
5820
6531
  ` $cacheDir = ''`,
5821
6532
  ` try {`,
5822
6533
  ` $npmrcFile = Join-Path $env:USERPROFILE '.npmrc'`,
5823
- ` if (Test-Path $npmrcFile) { $m = Select-String -Path $npmrcFile -Pattern '^cache\s*=\s*(.+)$' | Select-Object -Last 1; if ($m) { $cacheDir = $m.Matches[0].Groups[1].Value.Trim().Trim('"') } }`,
6534
+ ` if (Test-Path $npmrcFile) { $m = Select-String -Path $npmrcFile -Pattern '^cache\\s*=\\s*(.+)$' | Select-Object -Last 1; if ($m) { $cacheDir = $m.Matches[0].Groups[1].Value.Trim().Trim('"') } }`,
5824
6535
  ` } catch {}`,
5825
6536
  ` if ($cacheDir -eq '') { $fb = Join-Path $env:APPDATA 'npm-cache'; if (Test-Path (Join-Path $fb '_logs')) { $cacheDir = $fb } }`,
5826
6537
  ` $npmOut = $log + '.npm.out.txt'`,
@@ -5956,8 +6667,8 @@ async function handle(ctx, req, res) {
5956
6667
  ` # 版本数值比较(修复:字符串比较在版本号位数变化时出错,如 0.1.10 < 0.1.9;PS5.1 兼容,不用三元运算符)`,
5957
6668
  ` function Compare-Version($a, $b) {`,
5958
6669
  ` if ($a -eq $b) { return 0 }`,
5959
- ` $pa = [regex]::Match($a, '^(\d+)\.(\d+)\.(\d+)(?:-(?:[a-z]+\.)?(\d+))?' )`,
5960
- ` $pb = [regex]::Match($b, '^(\d+)\.(\d+)\.(\d+)(?:-(?:[a-z]+\.)?(\d+))?' )`,
6670
+ ` $pa = [regex]::Match($a, '^(\\d+)\\.(\\d+)\\.(\\d+)(?:-(?:[a-z]+\\.)?(\\d+))?' )`,
6671
+ ` $pb = [regex]::Match($b, '^(\\d+)\\.(\\d+)\\.(\\d+)(?:-(?:[a-z]+\\.)?(\\d+))?' )`,
5961
6672
  ` if (-not $pa.Success -or -not $pb.Success) { return ($a.CompareTo($b)) }`,
5962
6673
  ` for ($i = 1; $i -le 4; $i++) {`,
5963
6674
  ` if ($pa.Groups[$i].Success) { $va = [int]$pa.Groups[$i].Value } else { $va = 2147483647 }`,
@@ -5973,15 +6684,15 @@ async function handle(ctx, req, res) {
5973
6684
  ` try { $topVer = (Get-Content $topPkgJson -Raw | ConvertFrom-Json).version } catch { continue }`,
5974
6685
  ` # 找 .pnpm 里该包的所有主目录(@deepseek-ai+dsh-<name>@<ver>_<hash> 或 @deepseek-ai+dsh-<name>@<ver>-rc.x_<hash>)`,
5975
6686
  ` $cands = Get-ChildItem -Path $pnpm -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -match ('^@deepseek-ai\\\\+dsh-' + [regex]::Escape($name.Substring(4)) + '@') }`,
5976
- ` $best = $nil; $bestVer = ''`,
6687
+ ` $best = $null; $bestVer = ''`,
5977
6688
  ` foreach ($c in $cands) {`,
5978
6689
  ` $inner = Join-Path $c.FullName ('node_modules\\@deepseek-ai\\' + $name)`,
5979
6690
  ` $pj = Join-Path $inner 'package.json'`,
5980
6691
  ` if (-not (Test-Path $pj)) { continue }`,
5981
6692
  ` try { $v = (Get-Content $pj -Raw | ConvertFrom-Json).version } catch { continue }`,
5982
- ` if ($best -eq $nil -or (Compare-Version $v $bestVer) -gt 0) { $bestVer = $v; $best = $inner }`,
6693
+ ` if ($null -eq $best -or (Compare-Version $v $bestVer) -gt 0) { $bestVer = $v; $best = $inner }`,
5983
6694
  ` }`,
5984
- ` if ($best -eq $nil) { continue }`,
6695
+ ` if ($null -eq $best) { continue }`,
5985
6696
  ` if ($bestVer -eq $topVer) { continue }`,
5986
6697
  ` try {`,
5987
6698
  ` $bak = $top.FullName + '.bak-' + $topVer`,
@@ -6078,10 +6789,63 @@ async function handle(ctx, req, res) {
6078
6789
  '$started = $false',
6079
6790
  `for ($i = 0; $i -lt 20; $i++) {`,
6080
6791
  ` try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c.Count -gt 0) { $ok = $true; break } } catch {}`,
6081
- ` 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)) { $launchLog = Join-Path (Split-Path $log) 'fw-relaunch.log'; try { Add-Content -Path $launchLog -Value ((Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' 拉起(升级后): ' + $binNow) -Encoding UTF8 } catch {}; Start-Process -FilePath 'cmd.exe' -ArgumentList @('/c', ('"' + ${ps(nodePath)} + '" "' + $binNow + '" web >> "' + $launchLog + '" 2>&1')) -WindowStyle Hidden; $started = $true; Log '已发起拉起服务(输出见 fw-relaunch.log)' } else { Log 'bin.js 缺失,跳过拉起' } }`,
6792
+ ` if (-not $ok -and -not $started) { if (Invoke-DshRelaunch '升级后') { $started = $true } }; Beat`,
6082
6793
  ' Start-Sleep -Seconds 5',
6083
6794
  '}',
6084
- // 拉起失败不再只留提示(2026-09-04 教训):自动全树回滚并再拉起一次
6795
+ // ── 启动失败隔离(用户硬要求):新框架起不来时先隔离肇事者再重试,不直接回滚 ──
6796
+ // 判定逻辑全在 Node 决策器(fw-analyze-boot.mjs → planQuarantine);这里只执行方案。
6797
+ `function Invoke-Quarantine {`,
6798
+ ` $helper = ${ps(qHelperPath)}`,
6799
+ ` if (-not (Test-Path $helper)) { Log '隔离分析器缺失,跳过隔离'; return $false }`,
6800
+ ` $logPath = Join-Path (Split-Path $log) 'fw-relaunch.log'`,
6801
+ ` if (-not (Test-Path $logPath)) { $logPath = $log }`,
6802
+ ` $out = ''`,
6803
+ ` try { $out = (& ${ps(nodePath)} $helper $logPath ${ps(qCandidatesPath)} 2>$null | Select-Object -Last 1) } catch { Log ('隔离分析调用失败:' + $_.Exception.Message); return $false }`,
6804
+ ` if (-not $out) { Log '隔离分析无输出'; return $false }`,
6805
+ ` $plan = $null`,
6806
+ ` try { $plan = $out | ConvertFrom-Json } catch { Log '隔离分析输出无法解析'; return $false }`,
6807
+ ` $acted = $false; $doneP = @(); $doneR = @(); $mode = 'targeted'`,
6808
+ ` foreach ($p in @($plan.presets)) {`,
6809
+ ` if (-not $p.exists) { continue }`,
6810
+ ` try { Rename-Item -LiteralPath $p.file -NewName ((Split-Path $p.file -Leaf) + '.broken-' + (Get-Date -Format 'yyyyMMdd-HHmmss')) -ErrorAction Stop; $doneP += $p.name; $acted = $true; Log ('已隔离预设:' + $p.name + '(改名 .broken,不再参与挂载)') } catch { Log ('预设隔离失败:' + $p.name + ' ' + $_.Exception.Message) }`,
6811
+ ` }`,
6812
+ ` foreach ($r in @($plan.rows)) {`,
6813
+ ` try {`,
6814
+ ` $has = Select-String -Path ${ps(patchFilePath)} -Pattern ('^- id: ' + [regex]::Escape($r) + '\\s*$') -Quiet -ErrorAction SilentlyContinue`,
6815
+ ` if (-not $has) { Add-Content -Path ${ps(patchFilePath)} -Value ('- id: ' + $r) -Encoding UTF8; Add-Content -Path ${ps(patchFilePath)} -Value ' disabled: true' -Encoding UTF8 }`,
6816
+ ` $doneR += $r; $acted = $true; Log ('已禁用不适配行:' + $r)`,
6817
+ ` } catch { Log ('禁用行失败:' + $r + ' ' + $_.Exception.Message) }`,
6818
+ ` }`,
6819
+ ` if (-not $acted -and $plan.safeMode) {`,
6820
+ ` $mode = 'safe-mode'`,
6821
+ ` foreach ($r in @(${thirdPartyRows.length > 0 ? thirdPartyRows.map((r) => ps(r)).join(', ') : "''"})) {`,
6822
+ ` try { $has = Select-String -Path ${ps(patchFilePath)} -Pattern ('^- id: ' + [regex]::Escape($r) + '\\s*$') -Quiet -ErrorAction SilentlyContinue; if (-not $has) { Add-Content -Path ${ps(patchFilePath)} -Value ('- id: ' + $r) -Encoding UTF8; Add-Content -Path ${ps(patchFilePath)} -Value ' disabled: true' -Encoding UTF8 }; $doneR += $r; $acted = $true } catch {}`,
6823
+ ` }`,
6824
+ ` if ($acted) { Log ('安全模式:已禁用 ' + $doneR.Count + ' 个第三方行,先让服务起来(适配后逐个解锁)') }`,
6825
+ ` }`,
6826
+ ` if ($acted) {`,
6827
+ ` try {`,
6828
+ ` $rec = [pscustomobject]@{ at = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'); mode = $mode; presets = $doneP; rows = $doneR; lines = $plan.lines } | ConvertTo-Json -Depth 4`,
6829
+ ` Set-Content -Path ${ps(qRecordPath)} -Value $rec -Encoding UTF8`,
6830
+ ` } catch {}`,
6831
+ ` } else { Log '隔离分析未定位到可隔离对象' }`,
6832
+ ` return $acted`,
6833
+ `}`,
6834
+ `if (-not $ok -and -not $script:rolledBack) {`,
6835
+ ` Log '服务拉起失败 → 先做启动失败隔离(隔离肇事者后重试,不直接回滚)'`,
6836
+ ` for ($q = 1; $q -le 3; $q++) {`,
6837
+ ` if (-not (Invoke-Quarantine)) { Log ('第 ' + $q + ' 轮:未定位到可隔离对象,停止隔离'); break }`,
6838
+ ` $ok = $false; $started = $false`,
6839
+ ` for ($i = 0; $i -lt 12; $i++) {`,
6840
+ ` try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c.Count -gt 0) { $ok = $true; break } } catch {}`,
6841
+ ` ${launchSnippet('隔离后重试')}`,
6842
+ ` Start-Sleep -Seconds 5`,
6843
+ ` }`,
6844
+ ` if ($ok) { Log ('第 ' + $q + ' 轮隔离后服务已起来(被隔离项可在控制台解锁)'); break }`,
6845
+ ` Log ('第 ' + $q + ' 轮隔离后仍失败')`,
6846
+ ` }`,
6847
+ `}`,
6848
+ // 隔离也救不回才回滚(2026-09-04 教训:自动全树回滚并再拉起一次)
6085
6849
  `if (-not $ok -and -not $script:rolledBack) {`,
6086
6850
  ` Log '服务拉起失败,尝试自动回滚到升级前版本…'`,
6087
6851
  ` Invoke-Rollback`,
@@ -6089,7 +6853,7 @@ async function handle(ctx, req, res) {
6089
6853
  ` $started = $false`,
6090
6854
  ` for ($i = 0; $i -lt 20; $i++) {`,
6091
6855
  ` try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c.Count -gt 0) { $ok = $true; break } } catch {}`,
6092
- ` 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)) { $launchLog = Join-Path (Split-Path $log) 'fw-relaunch.log'; try { Add-Content -Path $launchLog -Value ((Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' 拉起(回滚后): ' + $binNow) -Encoding UTF8 } catch {}; Start-Process -FilePath 'cmd.exe' -ArgumentList @('/c', ('"' + ${ps(nodePath)} + '" "' + $binNow + '" web >> "' + $launchLog + '" 2>&1')) -WindowStyle Hidden; $started = $true; Log '已发起拉起服务(回滚后,输出见 fw-relaunch.log)' } else { Log 'bin.js 缺失,跳过拉起' } }`,
6856
+ ` if (-not $ok -and -not $started) { if (Invoke-DshRelaunch '回滚后') { $started = $true } }; Beat`,
6093
6857
  ` Start-Sleep -Seconds 5`,
6094
6858
  ` }`,
6095
6859
  `}`,
@@ -6098,6 +6862,8 @@ async function handle(ctx, req, res) {
6098
6862
  ].filter((l) => l !== '').join('\r\n')
6099
6863
  // 初始状态(备份完成、脚本即将执行)
6100
6864
  try { writeFileSync(stateFile, 'starting|备份配置完成,启动升级脚本…', 'utf8') } catch {}
6865
+ // 版本检查缓存作废:升级后 [框架] 面板要立刻显示新版本(否则 5 分钟内还显示"可升级到…")
6866
+ fwCheckCache = null
6101
6867
  // UTF-8 BOM:powershell 5.1 按 ANSI 读无 BOM 文件,中文会乱码——加 BOM 保证解析正确。
6102
6868
  // 执行方式:schtasks 一次性计划任务(Task Scheduler 启动,独立于服务进程树/Job Object)——
6103
6869
  // 服务进程被杀时,其子进程(execFile/detached 的 powershell)会被 Job/进程树连带杀死,
@@ -6152,7 +6918,7 @@ async function handle(ctx, req, res) {
6152
6918
  '升级完成后请重启 DSH 服务生效——重启后 Hub 自动:备份新版本配置快照、重打框架补丁(issue #5 容错)、给出适配提示。',
6153
6919
  '官方配套组件(@deepseek-ai/*)随框架一起升级,请勿在市场单独更新。',
6154
6920
  ]
6155
- sendJson(res, 200, { ok: true, current, latest, next, target, hasUpdate, upgraded, backupDir, registryError, configMigrations, steps, hints })
6921
+ sendJson(res, 200, { ok: true, current, latest, next, target, hasUpdate, upgraded, backupDir, registryError, configMigrations, preflight, steps, hints })
6156
6922
  return
6157
6923
  }
6158
6924
 
@@ -6177,7 +6943,11 @@ async function handle(ctx, req, res) {
6177
6943
  `$state = ${ps(stateFile)}`,
6178
6944
  `$log = ${ps(logFile)}`,
6179
6945
  "function Log($m) { try { Add-Content -Path $log -Value ((Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' ' + $m) -Encoding UTF8 } catch {} }",
6180
- "function SetState($s, $m) { try { Set-Content -Path $state -Value ($s + '|' + $m) -Encoding UTF8 } catch {} }",
6946
+ "function SetState($s, $m) {",
6947
+ " try { if ($s -eq 'failed') { Set-Content -Path $state -Value ($s + '|' + $m + '|stage=' + [string]$script:stage) -Encoding UTF8; return } } catch {}",
6948
+ " try { if ($s -ne 'done' -and $s -ne 'idle') { $script:stage = $s }; Set-Content -Path $state -Value ($s + '|' + $m) -Encoding UTF8; Beat } catch {}",
6949
+ "}",
6950
+ relaunchPrelude({ nodePath, pluginDir: join(dirname(fileURLToPath(import.meta.url)), '..'), fwRoot: rec.fwRoot, target: rec.from ?? '', ps }),
6181
6951
  "trap {",
6182
6952
  " try { SetState 'failed' ('回滚脚本异常终止:' + $_.Exception.Message) } catch {}",
6183
6953
  ` schtasks /delete /f /tn ${taskName} 2>$null`,
@@ -6204,13 +6974,14 @@ async function handle(ctx, req, res) {
6204
6974
  `$started = $false`,
6205
6975
  `for ($i = 0; $i -lt 20; $i++) {`,
6206
6976
  ` try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c.Count -gt 0) { $ok = $true; break } } catch {}`,
6207
- ` 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)) { $launchLog = Join-Path (Split-Path $log) 'fw-relaunch.log'; try { Add-Content -Path $launchLog -Value ((Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' 拉起(回滚后): ' + $binNow) -Encoding UTF8 } catch {}; Start-Process -FilePath 'cmd.exe' -ArgumentList @('/c', ('"' + ${ps(nodePath)} + '" "' + $binNow + '" web >> "' + $launchLog + '" 2>&1')) -WindowStyle Hidden; $started = $true; Log '已发起拉起服务(回滚后,输出见 fw-relaunch.log)' } else { Log 'bin.js 缺失,跳过拉起' } }`,
6977
+ ` if (-not $ok -and -not $started) { if (Invoke-DshRelaunch '回滚后') { $started = $true } }; Beat`,
6208
6978
  ` Start-Sleep -Seconds 5`,
6209
6979
  `}`,
6210
6980
  `if ($ok) { SetState 'done' ('已回滚到升级前版本 ${rec.from ?? '?'},服务正常') ; Log '回滚完成,服务已恢复' } else { SetState 'failed' '回滚后服务拉起失败:请手动运行 node ${resolveDshBin() ?? '<bin>'} web' ; Log '回滚后拉起失败' }`,
6211
6981
  `schtasks /delete /f /tn ${taskName} 2>$null`,
6212
6982
  ].filter((l) => l !== '').join('\r\n')
6213
6983
  try { writeFileSync(stateFile, 'rollback|回滚脚本已启动…', 'utf8') } catch {}
6984
+ fwCheckCache = null // 回滚后 [框架] 面板要立刻显示回滚到的版本
6214
6985
  writeFile(ps1, `\uFEFF${lines}`, 'utf8').then(
6215
6986
  () => {
6216
6987
  const ps1Posix = ps1.replace(/\\/gu, '/')
@@ -6363,7 +7134,7 @@ async function handle(ctx, req, res) {
6363
7134
  }
6364
7135
  installJobs.set(job.id, job)
6365
7136
  // 后台执行:请求立即返回,安装不受客户端断开/离开面板影响
6366
- void (kind === 'skill' ? runSkillInstallJob(job, ctx) : kind === 'suite' ? runSuiteInstallJob(job, ctx) : runInstallJob(job, ctx))
7137
+ void (kind === 'skill' ? runSkillInstallJob(job) : kind === 'suite' ? runSuiteInstallJob(job, ctx) : runInstallJob(job, ctx))
6367
7138
  sendJson(res, 200, { ok: true, jobId: job.id, status: 'installing', kind })
6368
7139
  return
6369
7140
  }
@@ -6758,6 +7529,16 @@ async function handle(ctx, req, res) {
6758
7529
  // 不再依赖桌面端监督器(它并不总是会重启服务,曾导致用户需要重启电脑)。
6759
7530
  // 安全护栏(事故教训):无法定位 dsh bin 或 bin.js 不存在时**拒绝重启**——
6760
7531
  // 避免"kill 后拉不起"(框架缓存损坏时常见),提示先修复框架安装。
7532
+ //
7533
+ // v0.3.43 事故修复(2026-09-11 用户实测「重启后服务没自己拉起来,只能手动重启」):
7534
+ // 现场证据=一堆 Ready 僵尸任务(DSH-Restart-13804 / -31688 / -3744 / V2 / V3),
7535
+ // 说明脚本杀完服务后**自己也被结束了**(与升级/回滚脚本同一个毛病:0xC000013A),
7536
+ // 于是"检查端口→拉起"那几行根本没跑到;而且原来只等 3 秒、只查一次端口。
7537
+ // 现在改成三层保险:
7538
+ // ① 主脚本:等到端口真正空出来(最多 20 秒轮询)→ 拉起 → 重试 3 次 → 写 console-restart.log
7539
+ // ② **守护任务**(关键):主脚本动手**之前**就注册一个每分钟跑一次的独立计划任务,
7540
+ // 服务被杀、主脚本被杀都不影响它;端口起来了它自删,起不来就继续拉(最多 5 次)
7541
+ // ③ bin 解析用与升级/回滚同一套多级回退(node resolve → .pnpm → 顶层链接)
6761
7542
  const port = webPort(ctx)
6762
7543
  const binPath = resolveDshBin()
6763
7544
  const nodePath = process.execPath
@@ -6766,37 +7547,92 @@ async function handle(ctx, req, res) {
6766
7547
  return
6767
7548
  }
6768
7549
  if (process.platform === 'win32') {
6769
- const ps1 = join(tmpdir(), `console-restart-${process.pid}.ps1`)
7550
+ const consoleDir = join(dshHome(), 'plugin-console')
7551
+ try { mkdirSync(consoleDir, { recursive: true }) } catch {}
7552
+ const restartLog = join(consoleDir, 'console-restart.log')
7553
+ const fwRoot = resolveFrameworkRootNodeModules(dirname(dirname(binPath)))
7554
+ let installedVersion = ''
7555
+ try { installedVersion = JSON.parse(readFileSync(join(dirname(dirname(binPath)), 'package.json'), 'utf8')).version ?? '' } catch {}
7556
+ const ps = (s) => {
7557
+ const j = JSON.stringify(String(s)).replace(/\\\\/gu, '\\')
7558
+ if (j === '""') return "''"
7559
+ return j.replace(/`/gu, '``').replace(/\$/gu, '`$')
7560
+ }
7561
+ const prelude = relaunchPrelude({ nodePath, pluginDir: join(dirname(fileURLToPath(import.meta.url)), '..'), fwRoot: fwRoot ?? dirname(dirname(binPath)), target: installedVersion, ps })
6770
7562
  const taskName = `DSH-Restart-${process.pid}`
6771
- const lines = [
6772
- `Stop-Process -Id ${process.pid} -Force -ErrorAction SilentlyContinue`,
6773
- 'Start-Sleep -Seconds 3',
6774
- '$ok = $false',
6775
- `try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; $ok = $c.Count -gt 0 } catch {}`,
6776
- `if (-not $ok) { Start-Process -FilePath ${JSON.stringify(nodePath)} -ArgumentList ${JSON.stringify(binPath)},'web' -WindowStyle Hidden }`,
6777
- // 任务自删:否则 DSH-Restart-* 残留(服务重启后任务列表里一堆 Ready 僵尸任务)
7563
+ const guardName = `DSH-RestartGuard-${process.pid}`
7564
+ const guardCount = join(consoleDir, `restart-guard-${process.pid}.count`)
7565
+ const killLine = `Stop-Process -Id ${process.pid} -Force -ErrorAction SilentlyContinue`
7566
+ // ① 主脚本:等端口空 → 拉起(重试 3 次)
7567
+ const mainLines = [
7568
+ `$log = ${ps(restartLog)}`,
7569
+ `$state = ${ps(join(consoleDir, 'fw-upgrade-state.txt'))}`,
7570
+ `function Log($m) { try { Add-Content -Path $log -Value ((Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' ' + $m) -Encoding UTF8 } catch {} }`,
7571
+ prelude,
7572
+ `Log ('重启脚本启动:目标端口 ${port},bin=' + ${ps(binPath)})`,
7573
+ killLine,
7574
+ // 等到端口真正空出来(原实现只 sleep 3 秒、只查一次 —— 端口还占着就误判"已有人监听"而跳过拉起)
7575
+ `$free = $false`,
7576
+ `for ($i = 0; $i -lt 20; $i++) { Start-Sleep -Seconds 1; try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if (-not $c -or $c.Count -eq 0) { $free = $true; break } } catch { $free = $true; break } }`,
7577
+ `Log ('端口 ' + ${ps(String(port))} + ' 状态:' + $(if ($free) { '已释放' } else { '仍被占用(可能被其它实例占着)' }))`,
7578
+ `$up = $false`,
7579
+ `for ($a = 1; $a -le 3; $a++) {`,
7580
+ ` [void](Invoke-DshRelaunch ('重启第 ' + $a + ' 次'))`,
7581
+ ` for ($w = 0; $w -lt 8; $w++) { Start-Sleep -Seconds 2; try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue; if ($c -and $c.Count -gt 0) { $up = $true; break } } catch {} }; if ($up) { break }`,
7582
+ `}`,
7583
+ `if ($up) { Log '服务已重新监听,重启完成' } else { Log '三次拉起后端口仍未监听:守护任务会继续尝试(输出见 fw-relaunch.log)' }`,
6778
7584
  `schtasks /delete /f /tn ${taskName} 2>$null`,
6779
7585
  ]
6780
- // schtasks 计划任务执行(独立于服务进程树/Job Object):服务被杀时脚本不被连带杀死,
6781
- // 否则「重启」会在 kill 后无人拉起(历史重启偶发崩溃的根因之一)
6782
- writeFile(ps1, lines.join('\r\n'), 'utf8').then(
6783
- () => {
6784
- const ps1Posix = ps1.replace(/\\/gu, '/')
6785
- // 无引号 /tr(重要):Task Scheduler 对带引号命令的解析会把 Command 拆坏成
6786
- // `"powershell ... -File \"`(非有效可执行文件)——任务显示 Ready、/run 报 SUCCESS
6787
- // 但永不执行(曾导致升级/重启脚本反复"已启动"却不动作、服务不停止)。实测无引号
6788
- // 格式(exe 与脚本路径均无空格时)任务正常执行、脚本完整跑通。仅当脚本路径含空格
6789
- // 时才退回带引号格式(schtasks 引号解析在服务 execFile 上下文不可靠,此时宁可用它)。
6790
- const tr = / /.test(ps1Posix)
6791
- ? `"powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \\"${ps1Posix}\\""`
6792
- : `C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ${ps1Posix}`
6793
- execFile('schtasks.exe', ['/create', '/f', '/tn', taskName, '/tr', tr, '/sc', 'once', '/st', '00:00'], { windowsHide: true }, (error) => {
6794
- if (error) {
6795
- execFile('powershell.exe', ['-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', ps1], { windowsHide: true, detached: true, stdio: 'ignore' }, () => {})
6796
- return
6797
- }
6798
- execFile('schtasks.exe', ['/run', '/tn', taskName], { windowsHide: true }, () => {})
6799
- })
7586
+ // ② 守护任务:独立于主脚本与服务进程,端口不起来就一直拉(最多 5 次)
7587
+ const guardLines = [
7588
+ `$log = ${ps(restartLog)}`,
7589
+ `$state = ${ps(join(consoleDir, 'fw-upgrade-state.txt'))}`,
7590
+ `function Log($m) { try { Add-Content -Path $log -Value ((Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' [guard] ' + $m) -Encoding UTF8 } catch {} }`,
7591
+ prelude,
7592
+ `$c = $null`,
7593
+ `try { $c = Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue } catch {}`,
7594
+ `if ($c -and $c.Count -gt 0) { Log '服务已在监听,守护任务收工'; Remove-Item ${ps(guardCount)} -Force -ErrorAction SilentlyContinue; schtasks /delete /f /tn ${guardName} 2>$null; schtasks /delete /f /tn ${taskName} 2>$null; exit 0 }`,
7595
+ `$n = 0`,
7596
+ `try { $n = [int](Get-Content ${ps(guardCount)} -Raw -ErrorAction SilentlyContinue) } catch { $n = 0 }`,
7597
+ `$n = $n + 1`,
7598
+ `if ($n -gt 5) { Log ('已尝试 ' + ($n - 1) + ' 次仍拉不起来,放弃并自删(请手动启动,或看 fw-relaunch.log / console-restart.log)'); schtasks /delete /f /tn ${guardName} 2>$null; exit 0 }`,
7599
+ `try { Set-Content -Path ${ps(guardCount)} -Value ([string]$n) -Encoding UTF8 } catch {}`,
7600
+ `Log ('端口 ' + ${ps(String(port))} + ' 无监听,第 ' + $n + ' 次拉起')`,
7601
+ `[void](Invoke-DshRelaunch ('守护第 ' + $n + ' 次'))`,
7602
+ ]
7603
+ const ps1 = join(tmpdir(), `console-restart-${process.pid}.ps1`)
7604
+ const guardPs1 = join(tmpdir(), `console-restart-guard-${process.pid}.ps1`)
7605
+ const scheduleFor = (file, name, scheduleArgs) => {
7606
+ const posix = String(file).replace(/\\/gu, '/')
7607
+ // 无引号 /tr(重要):Task Scheduler 对带引号命令的解析会把 Command 拆坏成
7608
+ // `"powershell ... -File \"`(非有效可执行文件)——任务显示 Ready、/run 报 SUCCESS
7609
+ // 但永不执行(曾导致升级/重启脚本反复"已启动"却不动作、服务不停止)。实测无引号
7610
+ // 格式(exe 与脚本路径均无空格时)任务正常执行、脚本完整跑通。仅当脚本路径含空格
7611
+ // 时才退回带引号格式(schtasks 引号解析在服务 execFile 上下文不可靠,此时宁可用它)。
7612
+ const tr = / /.test(posix)
7613
+ ? `"powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \\"${posix}\\""`
7614
+ : `C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ${posix}`
7615
+ return new Promise((resolve) => {
7616
+ execFile('schtasks.exe', ['/create', '/f', '/tn', name, '/tr', tr, ...scheduleArgs], { windowsHide: true }, (error) => resolve(error ?? null))
7617
+ })
7618
+ }
7619
+ Promise.all([
7620
+ writeFile(ps1, `\uFEFF${mainLines.join('\r\n')}`, 'utf8'),
7621
+ writeFile(guardPs1, `\uFEFF${guardLines.join('\r\n')}`, 'utf8'),
7622
+ ]).then(
7623
+ async () => {
7624
+ // 守护任务先注册(每分钟一次,独立于服务进程树)——主脚本被杀也有它兜底
7625
+ const guardError = await scheduleFor(guardPs1, guardName, ['/sc', 'minute', '/mo', '1'])
7626
+ const mainError = await scheduleFor(ps1, taskName, ['/sc', 'once', '/st', '00:00'])
7627
+ if (mainError !== null) {
7628
+ // schtasks 不可用:退回 detached 直接执行主脚本(守护任务仍可能已建)
7629
+ execFile('powershell.exe', ['-NoProfile', '-WindowStyle', 'Hidden', '-ExecutionPolicy', 'Bypass', '-File', ps1], { windowsHide: true, detached: true, stdio: 'ignore' }, () => {})
7630
+ return
7631
+ }
7632
+ execFile('schtasks.exe', ['/run', '/tn', taskName], { windowsHide: true }, () => {})
7633
+ if (guardError !== null) {
7634
+ try { writeFileSync(restartLog, `[warn] 守护任务注册失败(${guardError.message}),仅靠主脚本重启\n`, { flag: 'a' }) } catch {}
7635
+ }
6800
7636
  },
6801
7637
  () => {},
6802
7638
  )