dsh-m 0.2.10 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![npm](https://img.shields.io/npm/v/dsh-m?label=npm)](https://www.npmjs.com/package/dsh-m)
5
5
  [![Registry Check](https://img.shields.io/github/actions/workflow/status/iasiv5/dsh-m/registry.yml?branch=main&label=Registry%20Check)](../../actions/workflows/registry.yml)
6
6
  [![License](https://img.shields.io/github/license/iasiv5/dsh-m?label=License)](./LICENSE)
7
- [![DSH Web](https://img.shields.io/badge/DSH%20Web-0.1.2--rc.1%20verified-2563eb)](#faq)
7
+ [![DSH Web](https://img.shields.io/badge/DSH%20Web-0.1.2--rc.1%20%2B%200.1.5--rc.1%20contract%20checked-2563eb)](#faq)
8
8
 
9
9
  English · [中文](./README.md)
10
10
 
@@ -22,7 +22,7 @@ A DeepSeek Harness (DSH) plugin marketplace with a customizable registry: **brow
22
22
  ```text
23
23
  Install and enable the DSH plugin dsh-m:
24
24
  1. Run `dsh plugin --profile web add dsh-m`
25
- 2. Restart DSH Web to load the plugin (on this machine: `systemctl --user restart dsh-web.service`; or restart it your usual way)
25
+ 2. Restart DSH Web to load the plugin (restart the DSH user unit used by your deployment; do not hardcode a unit name)
26
26
  3. Poll `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3080` until it returns 200 again
27
27
  4. Run `curl -s -X POST http://127.0.0.1:3080/dshm -H 'content-type: application/json' -d '{"method":"ping"}'` and confirm the reply has `plugin: dsh-m`
28
28
  5. Then remind me to refresh the page and click "Plugin Marketplace" at the bottom of the sidebar
@@ -40,7 +40,7 @@ The default registry includes DSH Skins, ModSearch, the Lark / QQ / Weixin / WeC
40
40
  | **Installed** | What your web profile actually has, annotated "via market / non-market"; update badges, upgrade, two-step confirm uninstall; 📖 README preview (64KB cap) |
41
41
  | **Settings** | Registry address draft with "Validate & apply / Restore default / Download default registry.json / Check entries reachability"; configured vs active address and status at a glance; force refresh; dsh-m self-update |
42
42
 
43
- After any mutation a "⚡ Restart" banner appears — restart goes through the systemd unit (same-origin checked, detached-helper fallback) and polls by boot id until the service is back. Installs stream live pnpm progress (resolve → download → link → build).
43
+ After any mutation a "⚡ Restart" banner appears — under systemd, the DSH launcher's `appExit` hook hands the restart back to a unit configured with `Restart=on-failure` or `Restart=always`, avoiding a `systemctl` helper inside the unit cgroup that is about to stop; if `appExit` is unavailable, the fallback uses a manager-owned transient `systemd-run` service and only then a detached helper. The client confirms the replacement by boot id and dismisses the banner, leaving DSH Web's own background connection recovery in control; it does not force a full-page reload during the auth/route handoff. The current DSH Web `0.1.5-rc.1` runtime has now live-loaded dsh-m `0.2.11`; `/dshm` ping returned version `0.2.11`, and an authenticated page request completed `303 → 200`. The reported 404 was not reproducible once the service stabilized; journal evidence showed repeated `status=75/TEMPFAIL` restarts during the incident. Live DSH `0.1.2-rc.1`, transient-fallback, and repeated install/uninstall E2E experiments remain pre-release gaps. Installs stream live pnpm progress (resolve → download → link → build).
44
44
 
45
45
  ## Agent tools (7)
46
46
 
@@ -110,6 +110,9 @@ Listings over 200 entries trigger a performance notice. The market list is serve
110
110
  **4. What if my custom source goes down?**
111
111
  dsh-m serves its last successful cache for that source and marks it as cached; with no cache at all the market shows "registry unavailable" while installed plugins stay manageable. Fix the address or restore the default anytime.
112
112
 
113
+ **5. Which DSH Web versions does the restart button support?**
114
+ The public package contracts for `0.1.2-rc.1` and `0.1.5-rc.1` are checked; both expose the dsh-m `appExit` launcher hook. The current `openbmc-dsh.service` runtime has live-loaded dsh-m `0.2.11`; `/dshm` ping returned `0.2.11`, and an authenticated page request completed `303 → 200`. The reported 404 was not reproducible after service stabilization; journal evidence recorded repeated `status=75/TEMPFAIL` restarts during the incident. Live DSH `0.1.2-rc.1`, transient-fallback, and repeated install/uninstall E2E experiments remain pre-release gaps. Systemd deployments must configure `Restart=on-failure` or `Restart=always`; otherwise use the deployment's manual restart procedure.
115
+
113
116
  ## License
114
117
 
115
118
  MIT
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![npm](https://img.shields.io/npm/v/dsh-m?label=npm)](https://www.npmjs.com/package/dsh-m)
5
5
  [![Registry Check](https://img.shields.io/github/actions/workflow/status/iasiv5/dsh-m/registry.yml?branch=main&label=Registry%20Check)](../../actions/workflows/registry.yml)
6
6
  [![License](https://img.shields.io/github/license/iasiv5/dsh-m?label=License)](./LICENSE)
7
- [![DSH Web](https://img.shields.io/badge/DSH%20Web-0.1.2--rc.1%20verified-2563eb)](#faq)
7
+ [![DSH Web](https://img.shields.io/badge/DSH%20Web-0.1.2--rc.1%20%2B%200.1.5--rc.1%20contract%20checked-2563eb)](#faq)
8
8
 
9
9
  [English](./README.en.md) · 中文
10
10
 
@@ -22,7 +22,7 @@
22
22
  ```text
23
23
  安装并启用 DSH 插件 dsh-m:
24
24
  1. 执行 `dsh plugin --profile web add dsh-m`
25
- 2. 重启 DSH Web 使插件加载(本机:`systemctl --user restart dsh-web.service`;或按你的部署方式重启)
25
+ 2. 重启 DSH Web 使插件加载(按部署方式重启对应的 DSH user unit;unit 名称不要硬编码)
26
26
  3. 轮询 `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3080`,直到恢复 200
27
27
  4. 执行 `curl -s -X POST http://127.0.0.1:3080/dshm -H 'content-type: application/json' -d '{"method":"ping"}'`,确认返回 `plugin: dsh-m`
28
28
  5. 完成后提醒我刷新页面,点击侧栏底部的「插件市场」
@@ -40,7 +40,7 @@
40
40
  | **已装** | web profile 实装列表,标注「市场安装 / 非市场安装」;可升级徽标、升级、两段式确认卸载;📖 README 预览(64KB 截断) |
41
41
  | **设置** | registry 地址草稿 +「校验并应用 / 恢复默认 / 下载默认 registry.json / 检查条目可达性」;配置地址、生效来源与状态一目了然;强制刷新;dsh-m 自更新 |
42
42
 
43
- 安装 / 卸载 / 升级完成后出现「⚡ 一键重启」横幅——重启走 systemd 单元(同源校验,兜底 detached helper),按 boot id 轮询直至服务恢复。安装过程实时显示 pnpm 进度(解析 → 下载 → 链接 → 构建)。
43
+ 安装 / 卸载 / 升级完成后出现「⚡ 一键重启」横幅——受 systemd 管理时通过 DSH launcher 的 `appExit` 交给服务的 `Restart` 策略,避免在待停止 unit 的 cgroup 内启动 `systemctl` helper;无 `appExit` 的 systemd 兜底改用 manager-owned transient `systemd-run`,最后才退回 detached-helper。客户端按 boot id 确认新进程已恢复后关闭横幅,交由 DSH Web 自身的后台连接重试恢复页面,不强制整页刷新,避免认证/路由切换期间白屏。已完成当前 DSH Web `0.1.5-rc.1` 运行时的 live 核验:web profile 已加载 dsh-m `0.2.11`,重启后 `/dshm` ping 返回 `version: 0.2.11`,携带当前认证 token 的页面请求流程为 `303 → 200`;无认证请求会被拒绝。此次白屏截图对应的 404 在服务稳定后未复现;journal 显示截图时段发生多次 `status=75/TEMPFAIL` 重启,当前暂判定为重启/认证过渡窗口现象,未发现 dsh-m Host 路由崩溃。DSH `0.1.2-rc.1` live E2E、transient fallback live E2E,以及连续安装/卸载实验仍是正式发布前的验证 gap。安装过程实时显示 pnpm 进度(解析 → 下载 → 链接 → 构建)。
44
44
 
45
45
  ## Agent 工具(7 个)
46
46
 
@@ -110,6 +110,9 @@ main 上的中间提交可能不稳定。dsh-m 只跟踪 **release / tag**(优
110
110
  **4. 自定义源挂了怎么办?**
111
111
  优先使用该源最近一次成功的缓存并标记「缓存来源」;完全没有缓存时市场显示「收录清单不可用」,已安装插件仍可正常管理。修正地址或恢复默认即可。
112
112
 
113
+ **5. 重启按钮支持哪些 DSH Web 版本?**
114
+ 已核对 `0.1.2-rc.1` 与 `0.1.5-rc.1` 的公开包契约;两版都提供 dsh-m 使用的 `appExit` launcher hook。当前 `openbmc-dsh.service` 的 0.1.5 运行时已实际加载 dsh-m `0.2.11` 并完成 `/dshm` ping 与带认证页面的 `303 → 200` 核验;白屏期间的多次 `status=75/TEMPFAIL` 已记录,当前稳定服务未复现 404。0.1.2 live E2E、transient fallback live E2E 和连续插件安装/卸载实验仍需发布前补做。systemd 部署需要 unit 配置 `Restart=on-failure` 或 `Restart=always`,否则请使用部署方的手动重启方式。
115
+
113
116
  ## License
114
117
 
115
118
  MIT
package/docs/DESIGN.md CHANGED
@@ -83,7 +83,7 @@ npm 安装 / GitHub 安装 / 升级 / 自升级 / 卸载是**同一个事务模
83
83
  - **卸载**(事务定序写死):validate(严格读取:NOT_INSTALLED / NOT_DSH_PLUGIN / PLUGIN_METADATA_UNREADABLE,全部零写入零快照)→ 快照 → live-disable(先让 client bundle 下线,避免 404;回滚时尽力反向)→ 摘除该包补丁条目(`pnpm-workspace.yaml` 顶层 `patchedDependencies` 与 `package.json#pnpm.patchedDependencies`;残留条目会令 pnpm 以 `ERR_PNPM_UNUSED_PATCH` 整单失败,只精确匹配 `pkg` / `pkg@ver`,补丁文件本体保留并计入残留报告)→ remove → verify gone(仍在则回滚)。**不清理插件产生的数据/配置**,但把检测到的疑似残留路径(如 `~/.dsh/<plugin>.json`)列出报告。
84
84
  - **升级**:**按需检查**(`dshm_outdated` / `dshm_list` 时实时比对本地版本 vs npm latest / GitHub release,半自动——展示升级计划,确认后执行,即重开一次安装事务)。**不做后台定时器**。
85
85
  - **自更新**:dsh-m 对自己同样做版本比对 + 提示升级(设置页呈现);执行即 `install-npm` 事务(integrity 缺失直接拒绝,不进事务)。
86
- - **重启**:内置**一键重启**,复用 skillhub 验证过的重启路径(本机 `dsh-web.service` 是转发 shim,不新建 systemd 单元、不监听 3080)。安装/卸载/升级完成后 GUI 弹「需重启生效 [一键重启]」横幅,工具返回重启提示。
86
+ - **重启**:内置**一键重启**。在服务管理器托管的 DSH 进程内,只有确认 unit 的 `Restart=on-failure` / `Restart=always` 且 `75` 未被 `SuccessExitStatus` / `RestartPreventExitStatus` 覆盖时,才调用 launcher 提供的 `appExit(75)`;策略未知或不满足时改用 manager-owned transient `systemd-run` 调用 `systemctl`,不猜测或硬编码 unit 名称,也不在即将停止的 cgroup 内 detached spawn `systemctl`。无 systemd/appExit 时再退回 detached-helper 兼容路径。安装/卸载/升级完成后 GUI 弹「需重启生效 [一键重启]」横幅;客户端以 boot id 确认替换进程后关闭横幅,交由 DSH Web 自身后台连接恢复,不强制整页刷新;工具返回重启提示。
87
87
  - **安全基线(5 条)**:
88
88
  1. 所有拉取仅 HTTPS + 响应大小上限 + 超时;
89
89
  2. npm 安装校验 integrity;
@@ -182,5 +182,5 @@ npm 安装 / GitHub 安装 / 升级 / 自升级 / 卸载是**同一个事务模
182
182
 
183
183
  - profile 根:`$DSH_HOME/profiles/web`(默认 `~/.dsh/profiles/web`);已装插件 = 其 `package.json` dependencies + `dsh.profile.bundles`。
184
184
  - 新装插件需**重启 dsh web** 才加载;HMR 仅在 `pnpm run dev:web` watcher 存活时有效。
185
- - 禁止创建监听 3080 的进程;`dsh-web.service` 为 shim,重启走其转发路径。
185
+ - 禁止创建监听 3080 的进程;部署 unit 名称(例如 `openbmc-dsh.service`、`deepseek-harness.service` 或 `dsh-web.service`)只作运行时事实,不硬编码到 dsh-m;managed host 的重启由 `appExit` + unit `Restart` 策略完成。
186
186
  - 插件包协议要点:`type: module`、`main` host 入口、`exports["./client"]` 指向打包产物、`dsh.client.platform: "web"`、`dsh.bundle.patch: ./cordis.patch.yml`(`- insert: - id: dsh-m; name: dsh-m`)。
package/lib/client.js CHANGED
@@ -388,6 +388,7 @@ var restart_wait_exports = {};
388
388
  __export(restart_wait_exports, {
389
389
  RESTART_DEADLINE_MS: () => RESTART_DEADLINE_MS,
390
390
  RESTART_POLL_MS: () => RESTART_POLL_MS,
391
+ isAmbiguousRestartRequestError: () => isAmbiguousRestartRequestError,
391
392
  nextRestartWait: () => nextRestartWait
392
393
  });
393
394
  function nextRestartWait({ phase, now = 0, deadlineAt = Infinity, bootChanged = false }) {
@@ -396,6 +397,10 @@ function nextRestartWait({ phase, now = 0, deadlineAt = Infinity, bootChanged =
396
397
  }
397
398
  return bootChanged ? "done" : "continue";
398
399
  }
400
+ function isAmbiguousRestartRequestError(error) {
401
+ const name = error && typeof error === "object" ? error.name : "";
402
+ return name === "TypeError" || name === "AbortError" || name === "NetworkError";
403
+ }
399
404
  var RESTART_POLL_MS, RESTART_DEADLINE_MS;
400
405
  var init_restart_wait = __esm({
401
406
  "src/client/restart-wait.js"() {
@@ -417,7 +422,7 @@ var { createMarkdown: createMarkdown2 } = (init_markdown(), __toCommonJS(markdow
417
422
  var { ExtLink, MdImg, renderMarkdown } = createMarkdown2(h);
418
423
  var { installedViewModel: installedViewModel2, registrySourceKey: registrySourceKey2 } = (init_installed_view(), __toCommonJS(installed_view_exports));
419
424
  var { pickPayload: pickPayload2, parseToolArgs: parseToolArgs2 } = (init_tool_view(), __toCommonJS(tool_view_exports));
420
- var { RESTART_POLL_MS: RESTART_POLL_MS2, RESTART_DEADLINE_MS: RESTART_DEADLINE_MS2, nextRestartWait: nextRestartWait2 } = (init_restart_wait(), __toCommonJS(restart_wait_exports));
425
+ var { RESTART_POLL_MS: RESTART_POLL_MS2, RESTART_DEADLINE_MS: RESTART_DEADLINE_MS2, nextRestartWait: nextRestartWait2, isAmbiguousRestartRequestError: isAmbiguousRestartRequestError2 } = (init_restart_wait(), __toCommonJS(restart_wait_exports));
421
426
  var ZH = {
422
427
  "market.title": "\u63D2\u4EF6\u5E02\u573A",
423
428
  "tab.market": "\u5E02\u573A",
@@ -567,7 +572,7 @@ var ZH = {
567
572
  "restart.now": "\u26A1 \u4E00\u952E\u91CD\u542F",
568
573
  "restart.failed": "\u91CD\u542F\u5931\u8D25\uFF1A{err}",
569
574
  "restart.timeout": "\u91CD\u542F\u8D85\u65F6\uFF0C\u8BF7\u624B\u52A8\u68C0\u67E5 dsh web \u670D\u52A1\u72B6\u6001",
570
- "restart.hint.done": "\u5DF2\u8BF7\u6C42\u91CD\u542F DSH web\uFF08via {via}\uFF09\u3002\u670D\u52A1\u51E0\u79D2\u5185\u6062\u590D\uFF0C\u4E4B\u540E\u8BA9\u7528\u6237\u5237\u65B0\u9875\u9762\u5373\u53EF\u3002",
575
+ "restart.hint.done": "\u5DF2\u8BF7\u6C42\u91CD\u542F DSH web\uFF08via {via}\uFF09\u3002\u670D\u52A1\u6062\u590D\u540E DSH Web \u4F1A\u5728\u540E\u53F0\u81EA\u52A8\u91CD\u8FDE\u3002",
571
576
  "phase.resolving": "\u89E3\u6790\u4F9D\u8D56",
572
577
  "phase.downloading": "\u4E0B\u8F7D",
573
578
  "phase.linking": "\u94FE\u63A5\u5B89\u88C5",
@@ -586,8 +591,6 @@ var ZH = {
586
591
  };
587
592
  var EN = {
588
593
  "market.title": "Plugin Marketplace",
589
- "title.panel": "Plugin Marketplace",
590
- "title.full": "DeepSeek Harness Plugin Marketplace",
591
594
  "tab.market": "Market",
592
595
  "tab.installed": "Installed",
593
596
  "tab.settings": "Settings",
@@ -735,7 +738,7 @@ var EN = {
735
738
  "restart.now": "\u26A1 Restart",
736
739
  "restart.failed": "Restart failed: {err}",
737
740
  "restart.timeout": "Restart timed out \u2014 check the dsh web service manually",
738
- "restart.hint.done": "Restart requested (via {via}). The service will be back in seconds; ask the user to refresh afterwards.",
741
+ "restart.hint.done": "Restart requested (via {via}). DSH Web will reconnect in the background after the service returns.",
739
742
  "phase.resolving": "Resolving",
740
743
  "phase.downloading": "Downloading",
741
744
  "phase.linking": "Linking",
@@ -1012,7 +1015,11 @@ function RestartBanner({ note, onDone }) {
1012
1015
  setPhase("restarting");
1013
1016
  try {
1014
1017
  const ping0 = await api("ping");
1015
- await api("restart");
1018
+ try {
1019
+ await api("restart");
1020
+ } catch (requestError) {
1021
+ if (!isAmbiguousRestartRequestError2(requestError)) throw requestError;
1022
+ }
1016
1023
  setPhase("waiting");
1017
1024
  const deadlineAt = Date.now() + RESTART_DEADLINE_MS2;
1018
1025
  for (; ; ) {
@@ -161,6 +161,7 @@ export function createApiDispatcher(ctx) {
161
161
  checkRegistryEntries: ctx.deps?.checkRegistryEntries ?? checkRegistryEntries,
162
162
  npmLatest: ctx.deps?.npmLatest ?? npmLatest,
163
163
  runTransaction: ctx.deps?.runTransaction ?? runProfileTransaction,
164
+ scheduleRestart: ctx.deps?.scheduleRestart ?? scheduleRestart,
164
165
  };
165
166
  const cfg = () => ctx.controller.config;
166
167
  return async function handleApi(req, res) {
@@ -273,7 +274,7 @@ export function createApiDispatcher(ctx) {
273
274
  break;
274
275
  }
275
276
  case 'restart': {
276
- const result = scheduleRestart(servingPort(req));
277
+ const result = d.scheduleRestart(servingPort(req));
277
278
  payload = { ...result };
278
279
  break;
279
280
  }
@@ -1,10 +1,11 @@
1
1
  /**
2
- * 自重启:移植自 skillhub restart.ts(tag→OIDC 同源的 dsh-web shim 环境已验证)。
3
- * 优先 systemd 单元重启(本机 = deepseek-harness.service,dsh-web.service 为转发 shim);
4
- * 无 systemd 时退回 detached helper 等端口释放后换身重拉。
2
+ * 自重启:优先使用 DSH launcher 提供的 appExit 生命周期钩子,把重启交给
3
+ * systemd 等服务管理器的 Restart 策略;没有受管服务时再退回 detached helper。
4
+ * 只有无法使用 appExit 且能从 cgroup 识别服务时,才通过 manager-owned transient
5
+ * systemd-run 任务调用 systemctl 作为兼容兜底,避免 helper 留在待停止 unit cgroup。
5
6
  * 安全:restart 端点必须通过 trustedRestartRequest(Origin 与 Host 同源)。
6
7
  */
7
- import { spawn } from 'node:child_process';
8
+ import { execFileSync, spawn } from 'node:child_process';
8
9
  import { readFileSync } from 'node:fs';
9
10
  import { tmpdir } from 'node:os';
10
11
  import { join } from 'node:path';
@@ -96,17 +97,32 @@ export function systemdUnitName(cgroupText) {
96
97
  }
97
98
  return null;
98
99
  }
100
+ function shellQuote(part) {
101
+ return `'${part.replace(/'/g, "'\\''")}'`;
102
+ }
103
+ /**
104
+ * Build a transient systemd-run command for callers that do not have appExit.
105
+ * The transient service is owned by the manager, not by the DSH unit that it
106
+ * will restart, so KillMode=control-group cannot kill the restart command.
107
+ */
99
108
  export function systemdRestartArgv(opts) {
100
109
  const unit = systemdUnitName(opts.cgroup);
101
110
  if (unit === null)
102
111
  return null;
103
- if (opts.cgroup.includes('/user.slice/')) {
104
- return { file: 'systemctl', args: ['--user', 'restart', '--no-block', unit] };
105
- }
106
- if (opts.uid === 0) {
107
- return { file: 'systemctl', args: ['restart', '--no-block', unit] };
108
- }
109
- return { file: 'sudo', args: ['-n', 'systemctl', 'restart', '--no-block', unit] };
112
+ const userManager = opts.cgroup.includes('/user.slice/');
113
+ const targetArgs = userManager
114
+ ? ['systemctl', '--user', 'restart', '--no-block', unit]
115
+ : ['systemctl', 'restart', '--no-block', unit];
116
+ const script = `sleep 0.2; exec ${targetArgs.map(shellQuote).join(' ')}`;
117
+ const transientArgs = [
118
+ '--unit', `dshm-restart-${opts.pid ?? process.pid}-${Date.now()}.service`,
119
+ '--collect', '--service-type=exec', '/bin/sh', '-c', script,
120
+ ];
121
+ if (userManager)
122
+ return { file: 'systemd-run', args: ['--user', ...transientArgs] };
123
+ if (opts.uid === 0)
124
+ return { file: 'systemd-run', args: transientArgs };
125
+ return { file: 'sudo', args: ['-n', 'systemd-run', ...transientArgs] };
110
126
  }
111
127
  export function restartLaunch() {
112
128
  const launch = dshArgv();
@@ -129,6 +145,79 @@ function respawnInvocation(launch, platform = process.platform) {
129
145
  detached: false,
130
146
  };
131
147
  }
148
+ /** DSH's launcher treats a non-zero appExit as a service-manager restart. */
149
+ export const MANAGED_RESTART_EXIT_CODE = 75;
150
+ /**
151
+ * Detect a process started by a service manager without depending on the unit
152
+ * name or on a particular DSH release's cgroup layout.
153
+ */
154
+ export function serviceManagedEnv(env = process.env) {
155
+ const present = (value) => typeof value === 'string' && value.trim() !== '';
156
+ return typeof env === 'object' && env !== null
157
+ && (present(env.INVOCATION_ID) || present(env.NOTIFY_SOCKET));
158
+ }
159
+ function statusContainsExitCode(status, code) {
160
+ return String(status ?? '').split(/\s+/u).some((token) => token === String(code) || token === `STATUS=${code}`);
161
+ }
162
+ export function restartPolicyAllowsExit(policy, code = MANAGED_RESTART_EXIT_CODE) {
163
+ if (policy === null || policy === undefined)
164
+ return false;
165
+ if (policy.restart !== 'on-failure' && policy.restart !== 'always')
166
+ return false;
167
+ if (statusContainsExitCode(policy.successExitStatus, code))
168
+ return false;
169
+ if (statusContainsExitCode(policy.restartPreventExitStatus, code))
170
+ return false;
171
+ return true;
172
+ }
173
+ /** Query policy before voluntarily exiting; failure fails over to transient restart. */
174
+ export function readSystemdRestartPolicy(opts) {
175
+ const unit = systemdUnitName(opts.cgroup);
176
+ if (unit === null)
177
+ return null;
178
+ const userManager = opts.cgroup.includes('/user.slice/');
179
+ const file = userManager || opts.uid === 0 ? 'systemctl' : 'sudo';
180
+ const args = userManager
181
+ ? ['--user', 'show', '--value', '--property=Restart', '--property=SuccessExitStatus', '--property=RestartPreventExitStatus', unit]
182
+ : opts.uid === 0
183
+ ? ['show', '--value', '--property=Restart', '--property=SuccessExitStatus', '--property=RestartPreventExitStatus', unit]
184
+ : ['-n', 'systemctl', 'show', '--value', '--property=Restart', '--property=SuccessExitStatus', '--property=RestartPreventExitStatus', unit];
185
+ try {
186
+ const output = execFileSync(file, args, {
187
+ encoding: 'utf8',
188
+ timeout: 1_000,
189
+ stdio: ['ignore', 'pipe', 'ignore'],
190
+ env: process.env,
191
+ });
192
+ const [restart = '', successExitStatus = '', restartPreventExitStatus = ''] = String(output).split(/\r?\n/u);
193
+ return { restart: restart.trim(), successExitStatus: successExitStatus.trim(), restartPreventExitStatus: restartPreventExitStatus.trim() };
194
+ }
195
+ catch {
196
+ return null;
197
+ }
198
+ }
199
+ function reportRestartError(scope, error) {
200
+ try {
201
+ const message = error instanceof Error ? error.message : String(error);
202
+ process.stderr.write(`[dsh-m] ${scope} failed: ${message}\n`);
203
+ }
204
+ catch {
205
+ /* stderr may already be closed while the host is shutting down */
206
+ }
207
+ }
208
+ /** Read the optional launcher hook without coupling core code to Cordis types. */
209
+ export function appExitFromContext(context) {
210
+ const getter = context?.get;
211
+ if (typeof getter !== 'function')
212
+ return undefined;
213
+ try {
214
+ const candidate = getter.call(context, 'appExit');
215
+ return typeof candidate === 'function' ? candidate : undefined;
216
+ }
217
+ catch {
218
+ return undefined;
219
+ }
220
+ }
132
221
  function restartHelperSource(spawned, launch, logs, port) {
133
222
  return [
134
223
  "const { spawn } = require('node:child_process')",
@@ -181,19 +270,35 @@ function restartHelperSource(spawned, launch, logs, port) {
181
270
  }
182
271
  export function scheduleRestart(port = null, deps = {}) {
183
272
  const pid = deps.pid ?? process.pid;
184
- const systemd = systemdRestartArgv({
185
- cgroup: deps.cgroup ?? readProcCgroup(),
186
- uid: deps.uid ?? (typeof process.getuid === 'function' ? process.getuid() : 1),
187
- });
273
+ const cgroup = deps.cgroup ?? readProcCgroup();
274
+ const uid = deps.uid ?? (typeof process.getuid === 'function' ? process.getuid() : 1);
275
+ const systemd = systemdRestartArgv({ cgroup, uid, pid });
276
+ const managed = typeof deps.appExit === 'function' && serviceManagedEnv(deps.env ?? process.env);
277
+ const policy = deps.restartPolicy ?? (managed && systemd !== null ? readSystemdRestartPolicy({ cgroup, uid }) : null);
278
+ if (managed && (systemd === null || restartPolicyAllowsExit(policy))) {
279
+ const timer = (deps.setTimeout ?? setTimeout)(() => deps.appExit(MANAGED_RESTART_EXIT_CODE), 150);
280
+ timer.unref?.();
281
+ return { pid, helperPid: undefined, via: 'app-exit' };
282
+ }
188
283
  if (systemd !== null) {
189
284
  ;
190
285
  (deps.setTimeout ?? setTimeout)(() => {
191
- const helper = (deps.spawn ?? spawn)(systemd.file, systemd.args, {
192
- detached: true,
193
- stdio: 'ignore',
194
- env: process.env,
195
- });
196
- helper.unref();
286
+ try {
287
+ const helper = (deps.spawn ?? spawn)(systemd.file, systemd.args, {
288
+ detached: true,
289
+ stdio: 'ignore',
290
+ env: process.env,
291
+ });
292
+ helper.once?.('error', (error) => reportRestartError('transient restart helper', error));
293
+ helper.once?.('exit', (code, signal) => {
294
+ if (code !== 0)
295
+ reportRestartError('transient restart helper', `exit=${code ?? 'null'} signal=${signal ?? 'none'}`);
296
+ });
297
+ helper.unref();
298
+ }
299
+ catch (error) {
300
+ reportRestartError('transient restart helper', error);
301
+ }
197
302
  }, 500);
198
303
  return { pid, helperPid: undefined, via: 'systemd' };
199
304
  }
@@ -202,12 +307,29 @@ export function scheduleRestart(port = null, deps = {}) {
202
307
  const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
203
308
  const logOut = join(tmpdir(), `dshm-restart-${stamp}.out.log`);
204
309
  const logErr = join(tmpdir(), `dshm-restart-${stamp}.err.log`);
205
- const helper = (deps.spawn ?? spawn)((deps.nodeExecutable ?? nodeExecutable)(), ['-e', restartHelperSource(spawned, launch, { out: logOut, err: logErr }, port)], {
206
- detached: true,
207
- stdio: 'ignore',
208
- env: process.env,
209
- });
310
+ let helper;
311
+ try {
312
+ helper = (deps.spawn ?? spawn)((deps.nodeExecutable ?? nodeExecutable)(), ['-e', restartHelperSource(spawned, launch, { out: logOut, err: logErr }, port)], {
313
+ detached: true,
314
+ stdio: 'ignore',
315
+ env: process.env,
316
+ });
317
+ helper.once?.('error', (error) => reportRestartError('detached restart helper', error));
318
+ }
319
+ catch (error) {
320
+ // Do not terminate the live host if the replacement could not be started.
321
+ reportRestartError('detached restart helper', error);
322
+ return { pid, helperPid: undefined, via: 'helper' };
323
+ }
210
324
  helper.unref();
211
- (deps.setTimeout ?? setTimeout)(() => (deps.kill ?? process.kill)(pid, 'SIGTERM'), 500);
325
+ (deps.setTimeout ?? setTimeout)(() => {
326
+ try {
327
+ ;
328
+ (deps.kill ?? process.kill)(pid, 'SIGTERM');
329
+ }
330
+ catch (error) {
331
+ reportRestartError('detached host shutdown', error);
332
+ }
333
+ }, 500);
212
334
  return { pid, helperPid: helper.pid, via: 'helper' };
213
335
  }
package/lib/host.js CHANGED
@@ -4,6 +4,7 @@ import { createApiDispatcher } from './core/host-api.js';
4
4
  import { bindLoaderHost } from './core/live-plugin.js';
5
5
  import { createRegistryController } from './core/registry-controller.js';
6
6
  import { registerTools } from './tools.js';
7
+ import { appExitFromContext, scheduleRestart } from './core/restart.js';
7
8
  const require = createRequire(import.meta.url);
8
9
  const pkg = require('../package.json');
9
10
  export const name = 'dshm';
@@ -20,7 +21,11 @@ export function apply(ctx, config) {
20
21
  // registry controller:active config / configured / pending / rejected 分离 + generation fence;
21
22
  // tools 与 Host API 共用同一 active config object(apply 原地更新字段,live 生效)
22
23
  const controller = createRegistryController(config);
23
- registerTools(ctx, controller.config);
24
+ // DSH 0.1.2-rc.1 and 0.1.5-rc.1 both expose appExit through dsh-cmdline;
25
+ // using it avoids guessing the service unit from release-specific cgroups.
26
+ const appExit = appExitFromContext(ctx);
27
+ const restart = (port = null) => scheduleRestart(port, { appExit });
28
+ registerTools(ctx, controller.config, { restart });
24
29
  // 设置页(GUI 设置卡片的宿主命名空间):applies 'live',scope 提供 get/update/watch
25
30
  ctx.inject(['settings'], (c) => {
26
31
  const settings = c.settings;
@@ -35,7 +40,7 @@ export function apply(ctx, config) {
35
40
  // 本地 API:单路由 + method 分发(防护与状态映射在 core/host-api.ts)
36
41
  ctx.inject(['webServer'], (c) => {
37
42
  const server = c.webServer;
38
- const handleApi = createApiDispatcher({ controller, pkg });
43
+ const handleApi = createApiDispatcher({ controller, pkg, deps: { scheduleRestart: restart } });
39
44
  server.register({
40
45
  kind: 'exact',
41
46
  path: '/dshm',
package/lib/tools.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools';
2
2
  import { installTimeoutMs } from './core/env.js';
3
3
  import { installFromRegistry, listInstalledWithMeta, listMarket, uninstallPlugin, upgradePlugin, } from './core/market.js';
4
- import { scheduleRestart } from './core/restart.js';
4
+ import { appExitFromContext, scheduleRestart } from './core/restart.js';
5
5
  export const CATEGORY_LABELS = {
6
6
  market: '市场',
7
7
  tools: '工具',
@@ -24,6 +24,7 @@ export function registerTools(ctx, cfg, deps = {}) {
24
24
  uninstallPlugin: deps.uninstallPlugin ?? uninstallPlugin,
25
25
  upgradePlugin: deps.upgradePlugin ?? upgradePlugin,
26
26
  };
27
+ const restart = deps.restart ?? ((port = null) => scheduleRestart(port, { appExit: appExitFromContext(ctx) }));
27
28
  ctx.tools.register(defineTool({
28
29
  name: 'dshm_search',
29
30
  description: 'Search your personal DSH plugin marketplace (dsh-m) and show clickable plugin cards. ALWAYS call this instead of web_search or bash when the user wants to find/recommend/browse their curated DSH plugins (插件). Call EXACTLY ONCE per user message; extract a real keyword (主题, 搜索) rather than pasting the whole sentence. Omit query to browse all listings. After cards appear, reply with AT MOST one short sentence. Do not print install commands.',
@@ -240,13 +241,13 @@ export function registerTools(ctx, cfg, deps = {}) {
240
241
  }));
241
242
  ctx.tools.register(defineTool({
242
243
  name: 'dshm_restart',
243
- description: 'Restart DSH web so newly installed/uninstalled/upgraded plugins take effect. ONLY call after the user agrees (用户同意重启后). The page reloads automatically after the service comes back.',
244
+ description: 'Restart DSH web so newly installed/uninstalled/upgraded plugins take effect. ONLY call after the user agrees (用户同意重启后). DSH Web reconnects in the background after the service comes back.',
244
245
  parameters: {},
245
246
  output: {
246
247
  schema: { type: 'object', additionalProperties: true },
247
248
  render: (_args, value) => [{
248
249
  type: 'text',
249
- text: `已请求重启 DSH web(via ${value.via})。服务几秒内恢复,之后让用户刷新页面即可。对用户最多一句短话。`,
250
+ text: `已请求重启 DSH web(via ${value.via})。服务恢复后 DSH Web 会在后台自动重连。对用户最多一句短话。`,
250
251
  }],
251
252
  presentationMeta: (_args, value) => ({ kind: 'dshm-restart', ...value }),
252
253
  },
@@ -258,7 +259,7 @@ export function registerTools(ctx, cfg, deps = {}) {
258
259
  }),
259
260
  timeoutMs: 15_000,
260
261
  async execute() {
261
- return cloneJson(scheduleRestart(null));
262
+ return cloneJson(restart(null));
262
263
  },
263
264
  }));
264
265
  ctx.inject(['systemPrompt'], (c) => {
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "dsh-m",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
4
4
  "description": "DSH Marketplace — 个人自用的 DeepSeek Harness 插件市场:收录、安装、卸载、升级 DSH 插件",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "lib/host.js",
8
8
  "bin": {
9
- "dshm": "./lib/cli.js"
9
+ "dshm": "lib/cli.js"
10
10
  },
11
11
  "exports": {
12
12
  ".": "./lib/host.js",