dsh-composer-ux 0.15.4 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,73 @@
2
2
 
3
3
  本项目遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
4
 
5
+ ## [0.16.0] — 2026-09-30(桌面重启改成「连壳一起换掉」—— 不再弹那个恢复框)
6
+
7
+ 前置事实(0.15.5 修好命令行之后发现的):桌面壳把宿主子进程的 close **一律**判成失败
8
+ (连退出码 0 也照样 `fail()`),唯一的恢复式重启是它弹框里那个按钮。所以 0.15.5 之后,
9
+ 我们越能成功重启,那个框越会弹 —— 而且它成了陷阱(点里面的「重启」会再拉一整个应用抢端口)。
10
+ 证据与推演见仓库根的 `重启机制改造计划.md`。
11
+
12
+ **改法**(照 `HHHEEEWWW/dsh-quick-restart` 的核心思路,但规避它 README 自陈的两个致命坑):
13
+
14
+ - 新增 `desktopKindOf()`:`ELECTRON_RUN_AS_NODE=1` 且 `execPath` 是 `.exe` ⇒ 桌面形态;
15
+ - 桌面形态的助手:`taskkill /F /PID <壳>`(**只用 /PID,绝不用 /T** —— /T 会把负责重启的助手自己
16
+ 一起带走)→ 轮询等旧宿主真的没了 → 用应用 exe 重建,且**必须删掉 `ELECTRON_RUN_AS_NODE`**
17
+ (留着它拉起来的会是"又一个 node 进程",不是应用)→ **不按映像名扫描**(`/IM` 会误杀自己;
18
+ 渲染进程随 Electron 主进程自然退出);
19
+ - `dsh web` 形态一行不改:等端口 → 重拉 → 校验端口;
20
+ - `RestartIo`/`planRestart` 补 `ppid`,宿主传入 `process.ppid`。
21
+
22
+ ## 验证
23
+
24
+ - `npm test`:**2368 passed / 0 failed**(23 个套件;新增 8 条:形态判定 3 条 + 助手源码约束 5 条)。
25
+ - 变异:**164/164 全部咬住**(新增 FN 忘了删环境变量 / FO 带上 /T;并修好三条**假测试** ——
26
+ FN、FO、以及老变异 D 的断言原来都在"查文本",注释里或别处的同类代码就能骗过它们,
27
+ 现在一律改成效果导向)。
28
+
29
+ ## 升级说明
30
+
31
+ - 桌面版:装完**仍需手动重启一次**(`C:\Users\fangwen\.dsh\launcher\restart-webui.bat`)让这一版生效;
32
+ 之后界面上的重启按钮才走新机制。
33
+ - 已知代价:杀掉壳会让**窗口短暂整个消失**再回来(参考插件也接受这个代价)。
34
+
35
+ ## [0.15.5] — 2026-09-30(**真凶**:重启时重放的启动命令是坏的 —— 桌面版一直没成功过)
36
+
37
+ 前一轮查壳的判定时顺手翻了自己的助手日志,发现一个比弹框严重得多的问题。
38
+
39
+ **证据**(`%TEMP%\composer-ux-restart-*.err.log`,就是用户那两次重启的产物):
40
+
41
+ ```
42
+ Error: dsh: invalid profile name "D:\\deepseekharness\\resources\\app.asar\\dsh"
43
+ at resolveProfileDir (…/@deepseek-ai/dsh-app-boot/lib/index.js:525)
44
+ [dsh-composer-ux] the replacement did not bind port 19387 within 20000ms
45
+ ```
46
+
47
+ 另一份(端口 3080 那次)是 `error: --profile <name> is required`。
48
+
49
+ **原因**:`launchCommand()` 只在 argv1 长得像 `bin.js`/`bin.ts`/`dsh` 时才忠实重放,**否则退回裸
50
+ `dsh` 并把 argv1 整个丢掉**。桌面壳起宿主用的是 `@deepseek-ai/dsh-desktop-host/lib/cli.js`
51
+ (不匹配那个正则)⇒ 重放成 `dsh "<asar>/dsh"` ⇒ 替换进程当场报 "invalid profile name" 退出。
52
+ **也就是说:桌面版的重启从来没成功过,一直是壳那个「应用无法启动」恢复框在兜底** —— 而那个框里的
53
+ 「退出」会把应用真的关掉。
54
+
55
+ **改法**:改成**逐字重放原命令** —— `node <execArgv> <argv1> <rest...>`,cwd 用原 cwd
56
+ (相对入口靠它解析)。只给 Windows 的 `.cmd`/`.bat` shim 保留「过一层 shell」那条分支。
57
+ 这也正是两个参考插件(Noah0509 / HHHEEEWWW 两版 dsh-quick-restart)的做法。
58
+
59
+ ## 验证
60
+
61
+ - `npm test`:**2359 passed / 0 failed**(23 个套件;新增 7 条,含一条**真机回归**:
62
+ 「入口不像 dsh(桌面壳那条路)也逐字重放,绝不退化成裸 dsh」)。
63
+ - 变异:**162/162 全部咬住**(新增 FM:又可以把 argv1 丢掉 ⇒ 那条回归报警)。
64
+
65
+ ## 仍未解决
66
+
67
+ 桌面壳那个恢复框**仍然会弹**:读它的 `lib/main.js` 证实,宿主的子进程一 close 它就 `fail()`,
68
+ 不管退出码是 0 还是别的;唯一的「恢复式重启」是它自己弹框里那个按钮(`app.relaunch()`)。
69
+ 所以只要还是「我们自己退出」,那一步就去不掉 —— 待用户决定:机制不动只把话说明白,还是换成
70
+ 「杀掉壳自己拉起 exe」(会先杀掉整个窗口,且有它自带的两个致命坑)。
71
+
5
72
  ## [0.15.4] — 2026-09-30(移除侧边栏那枚重启按钮)
6
73
 
7
74
  起因(用户原话):**「移除这里的重启按钮」**(配图是侧边栏底部,「移动访问」下面那枚)。
package/README.md CHANGED
@@ -506,7 +506,7 @@ Windows 上 DSH 给模型的终端工具是 **PowerShell**(工具名 `pwsh`)
506
506
  | 工具循环的消息形状 / 轮次封顶 / 异常必须降级 | 同上第 6 节(假 llm 驱动整条循环,逐条检查 `role:'tool'` 与 `toolCallId`) |
507
507
  | 工具路径:默认关、开了才派、**查完吐散文必须回落**、拿不到工作目录就不派 | `test/quick-commands.mjs` 5g 节(13 条,真路由 + 假 llm) |
508
508
  | 工具读到的内容不进台账 | 同 5g 节:断言台账里只有工具名与次数、没有读到的文件内容 |
509
- | 变异守卫真的会咬人,且**必须是指定的那条测试变红** | `npm run test:mutations`:**161 条全部咬住**(判据是 `red.some(line => line.includes(item.expect))`,不是"有红就行") |
509
+ | 变异守卫真的会咬人,且**必须是指定的那条测试变红** | `npm run test:mutations`:**164 条全部咬住**(判据是 `red.some(line => line.includes(item.expect))`,不是"有红就行") |
510
510
  | 发布门禁三条判据 | `npm run gates` 全过;`check:tag` 实测 `v0.12.0` 的 tag 名与所指提交里的版本一致 |
511
511
  | 三平台 CI | GitHub Actions:ubuntu / windows / macos × Node 20(`npm ci → typecheck → build → test → check:pack → check:docs`) |
512
512
 
@@ -640,9 +640,9 @@ node build.mjs # 产出 lib/index.js + lib/client.js
640
640
  # ⚠️ 宿主半会**内联** schemastery / cosmokit:
641
641
  # 优先用 DSH 检出里的 vendor 副本,检出不在时退到 node_modules
642
642
  # 里同版本的 npm 包(两者逐字节相同)——CI 上走的就是退路
643
- npm test # 23 个套件;当前 2357 passed, 0 failed(2026-09-29 实测;CI 三平台同样全绿)
643
+ npm test # 23 个套件;当前 2368 passed, 0 failed(2026-09-29 实测;CI 三平台同样全绿)
644
644
  # 走 scripts/run-tests.mjs:顺带把"多少套件/多少条"记进 test/.last-run.json
645
- npm run test:mutations # 手动跑:变异测试,证明那套护栏真的在咬人(161 条,须单独跑)
645
+ npm run test:mutations # 手动跑:变异测试,证明那套护栏真的在咬人(164 条,须单独跑)
646
646
  # 同样记录结果,供下面的文档门禁核对
647
647
  npm run gates # 发版门禁三条一起跑:tag 指向 / 包内容 / 文档数字
648
648
  npm run check:tag # ① tag 名里的版本 == 该 tag 所指提交里的 package.json 版本
package/lib/index.js CHANGED
@@ -3879,10 +3879,12 @@ function isAbsolutePath(value) {
3879
3879
  return value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value) || value.startsWith("\\\\");
3880
3880
  }
3881
3881
  function launchCommand(facts) {
3882
- const { argv1, execArgv, rest, cwd, platform, resolve: resolve4, dirname: dirname8 } = facts;
3883
- if (argv1 !== void 0 && /[\\/](?:bin\.(?:js|ts)|dsh)$/.test(argv1)) {
3884
- const absolute = resolve4(argv1);
3885
- return { file: facts.node, args: [...execArgv, absolute, ...rest], cwd: dirname8(absolute), viaShell: false };
3882
+ const { node, argv1, execArgv, rest, cwd, platform } = facts;
3883
+ if (argv1 !== void 0 && argv1 !== "") {
3884
+ if (/\.(?:cmd|bat)$/iu.test(argv1)) {
3885
+ return { file: argv1, args: [...rest], cwd, viaShell: true };
3886
+ }
3887
+ return { file: node, args: [...execArgv, argv1, ...rest], cwd, viaShell: false };
3886
3888
  }
3887
3889
  return { file: "dsh", args: [...rest], cwd, viaShell: platform === "win32" };
3888
3890
  }
@@ -3980,8 +3982,12 @@ function detectedSupervisor(input) {
3980
3982
  if (input.ppid === 1 || input.parentComm(input.ppid) === "systemd") return "systemd";
3981
3983
  return null;
3982
3984
  }
3985
+ function desktopKindOf(input) {
3986
+ return input.env.ELECTRON_RUN_AS_NODE === "1" && /\.exe$/iu.test(input.execPath);
3987
+ }
3983
3988
  function restartHelperSource(input) {
3984
- const { spawned, cwd, logs, port } = input;
3989
+ const { spawned, cwd, logs, port, desktop } = input;
3990
+ const desktopFacts = desktop === null || desktop === void 0 ? "null" : `{ shellPid: ${String(desktop.shellPid)}, hostPid: ${String(desktop.hostPid)}, appExe: ${JSON.stringify(desktop.appExe)} }`;
3985
3991
  return [
3986
3992
  "const { spawn } = require('node:child_process')",
3987
3993
  "const fs = require('node:fs')",
@@ -3994,6 +4000,7 @@ function restartHelperSource(input) {
3994
4000
  `const logOut = ${JSON.stringify(logs.out)}`,
3995
4001
  `const logErr = ${JSON.stringify(logs.err)}`,
3996
4002
  `const port = ${JSON.stringify(port)}`,
4003
+ `const desktop = ${desktopFacts}`,
3997
4004
  `const pollMs = ${String(RESTART_POLL_MS)}`,
3998
4005
  `const portWaitMs = ${String(RESTART_PORT_WAIT_MS)}`,
3999
4006
  `const settleMs = ${String(RESTART_PORT_SETTLE_MS)}`,
@@ -4044,7 +4051,39 @@ function restartHelperSource(input) {
4044
4051
  " while (Date.now() < upBy && !(await listening())) await sleep(500)",
4045
4052
  ' if (!(await listening())) note("the replacement did not bind port " + port + " within " + replacementWaitMs + "ms \u2014 see the output log beside this one")',
4046
4053
  "}",
4047
- "main()"
4054
+ "const alive = (pid) => new Promise((resolve) => {",
4055
+ ' let out = ""',
4056
+ ' const probe = spawn("tasklist", ["/FI", "PID eq " + pid, "/FO", "CSV", "/NH"], { windowsHide: true })',
4057
+ ' probe.stdout.on("data", (chunk) => { out += String(chunk) })',
4058
+ ' probe.on("close", () => resolve(out.includes("\\"" + pid + "\\"")))',
4059
+ ' probe.on("error", () => resolve(false))',
4060
+ "})",
4061
+ "// \u684C\u9762\u5F62\u6001\uFF1A\u8FDE\u58F3\u4E00\u8D77\u6362\u6389 \u2014\u2014 \u58F3\u770B\u4E0D\u5230\u81EA\u5DF1\u5B50\u8FDB\u7A0B\u7684\u9000\u51FA\uFF0C\u5C31\u4E0D\u4F1A\u5F39\u6062\u590D\u6846\u3002",
4062
+ "const desktopMain = async () => {",
4063
+ ' const out = fs.openSync(logOut, "a")',
4064
+ ' const err = fs.openSync(logErr, "a")',
4065
+ ' // \u53EA /PID\uFF1A/T \u4F1A\u628A"\u8D1F\u8D23\u91CD\u542F\u7684\u52A9\u624B\u81EA\u5DF1"\u4E00\u8D77\u5E26\u8D70\uFF08\u53C2\u8003\u63D2\u4EF6\u8E29\u8FC7\u7684\u5751\uFF09\u3002',
4066
+ " try {",
4067
+ ' const killer = spawn("taskkill", ["/F", "/PID", String(desktop.shellPid)], { windowsHide: true, stdio: ["ignore", out, err] })',
4068
+ ' killer.on("error", (error) => note("could not kill the shell: " + (error && error.message ? error.message : String(error))))',
4069
+ " killer.unref()",
4070
+ ' } catch (error) { note("could not kill the shell: " + String(error)) }',
4071
+ " const until = Date.now() + portWaitMs",
4072
+ " while (Date.now() < until && await alive(desktop.hostPid)) await sleep(pollMs)",
4073
+ ' if (await alive(desktop.hostPid)) note("the old host was still alive after " + portWaitMs + "ms; relaunching anyway")',
4074
+ " await sleep(settleMs)",
4075
+ ' // \u5FC5\u987B\u5220\u6389 ELECTRON_RUN_AS_NODE\uFF1A\u7559\u7740\u5B83\u62C9\u8D77\u6765\u7684\u4F1A\u662F"\u53C8\u4E00\u4E2A node \u8FDB\u7A0B"\uFF0C\u4E0D\u662F\u5E94\u7528\u3002',
4076
+ " const env = { ...process.env }",
4077
+ " delete env.ELECTRON_RUN_AS_NODE",
4078
+ " try {",
4079
+ ' const child = spawn(desktop.appExe, [], { detached: true, stdio: ["ignore", out, err], env, windowsHide: true })',
4080
+ ' child.on("error", (error) => note("could not relaunch the app: " + (error && error.message ? error.message : String(error))))',
4081
+ " child.unref()",
4082
+ ' } catch (error) { note("could not relaunch the app: " + String(error)) }',
4083
+ " await sleep(lingerMs)",
4084
+ "}",
4085
+ "if (desktop === null) main()",
4086
+ "else desktopMain()"
4048
4087
  ].join("\n");
4049
4088
  }
4050
4089
  function planRestart(io, port) {
@@ -4059,6 +4098,7 @@ function planRestart(io, port) {
4059
4098
  resolve: io.resolve,
4060
4099
  dirname: io.dirname
4061
4100
  });
4101
+ const desktop = desktopKindOf({ env: io.env, execPath: io.execPath }) ? { shellPid: io.ppid, hostPid: io.pid, appExe: io.execPath } : null;
4062
4102
  const respawn = respawnCommand(launch, io.platform);
4063
4103
  const logOut = io.join(io.tmpdir, `${RESTART_LOG_PREFIX}${io.stamp}.out.log`);
4064
4104
  const logErr = io.join(io.tmpdir, `${RESTART_LOG_PREFIX}${io.stamp}.err.log`);
@@ -4066,7 +4106,7 @@ function planRestart(io, port) {
4066
4106
  node,
4067
4107
  launch,
4068
4108
  respawn,
4069
- helper: restartHelperSource({ spawned: respawn, cwd: launch.cwd, logs: { out: logOut, err: logErr }, port }),
4109
+ helper: restartHelperSource({ spawned: respawn, cwd: launch.cwd, logs: { out: logOut, err: logErr }, port, desktop }),
4070
4110
  logOut,
4071
4111
  logErr,
4072
4112
  port
@@ -7392,6 +7432,7 @@ function apply(ctx, config) {
7392
7432
  const buildIo = () => ({
7393
7433
  platform: process.platform,
7394
7434
  pid: process.pid,
7435
+ ppid: process.ppid,
7395
7436
  argv0: process.argv0,
7396
7437
  execPath: process.execPath,
7397
7438
  argv1: process.argv[1],