@kesike/dsh-exit 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,12 +7,13 @@
7
7
  [![dsh web plugin](https://img.shields.io/badge/dsh-web%20plugin-4f46e5?style=for-the-badge)](https://npmjs.com/package/@deepseek-ai/dsh)
8
8
  [![npm](https://img.shields.io/npm/v/%40kesike%2Fdsh-exit?style=for-the-badge&label=npm)](https://www.npmjs.com/package/@kesike/dsh-exit)
9
9
  [![Platform](https://img.shields.io/badge/platform-web-0ea5e9?style=for-the-badge)](https://github.com/KeS1Ke/dsh-exit)
10
- [![Version](https://img.shields.io/badge/version-0.2.4-64748b?style=for-the-badge)](package.json)
10
+ [![Version](https://img.shields.io/badge/version-0.2.3-64748b?style=for-the-badge)](package.json)
11
11
  [![Dependencies](https://img.shields.io/badge/runtime%20dependencies-none-16a34a?style=for-the-badge)](package.json)
12
12
 
13
13
  [简体中文](https://github.com/KeS1Ke/dsh-exit/blob/main/README.zh-CN.md)
14
14
 
15
15
  </div>
16
+
16
17
  > [!IMPORTANT]
17
18
  > Confirming the action terminates the dsh host process and releases its ports. The host terminal window and the current browser tab are intentionally left open.
18
19
 
@@ -30,7 +31,7 @@
30
31
 
31
32
  ## 🎬 GitHub showcase
32
33
 
33
- The animation, video, and screenshot below are repository showcase assets. They stay in GitHub for documentation and are intentionally excluded from the npm package.
34
+ Follow the exit flow below, then see the plugin in a running dsh settings panel.
34
35
 
35
36
  <p align="center">
36
37
  <img src="https://raw.githubusercontent.com/KeS1Ke/dsh-exit/main/docs/dsh-exit-landscape.webp" alt="Landscape animated dsh-exit flow generated by Archify" width="720">
@@ -61,7 +62,7 @@ The capture below is a current dsh settings view with `dsh-exit` installed. The
61
62
  | Bundle patch | [`cordis.patch.yml`](cordis.patch.yml) | Inserts the plugin row into the dsh profile. |
62
63
  | Package metadata | [`package.json`](package.json) | Declares the host entry, web client, and bundle patch. |
63
64
 
64
- The npm package contains only the four core runtime files above plus package metadata and README files. GitHub-only media is kept separately:
65
+ The package stays focused on the runtime files and package metadata. Additional screenshots and source materials are provided below:
65
66
 
66
67
  | Showcase asset | Location |
67
68
  | --- | --- |
package/README.zh-CN.md CHANGED
@@ -7,12 +7,13 @@
7
7
  [![dsh Web 插件](https://img.shields.io/badge/dsh-Web%20%E6%8F%92%E4%BB%B6-4f46e5?style=for-the-badge)](https://npmjs.com/package/@deepseek-ai/dsh)
8
8
  [![npm](https://img.shields.io/npm/v/%40kesike%2Fdsh-exit?style=for-the-badge&label=npm)](https://www.npmjs.com/package/@kesike/dsh-exit)
9
9
  [![平台](https://img.shields.io/badge/%E5%B9%B3%E5%8F%B0-Web-0ea5e9?style=for-the-badge)](https://github.com/KeS1Ke/dsh-exit)
10
- [![版本](https://img.shields.io/badge/%E7%89%88%E6%9C%AC-0.2.4-64748b?style=for-the-badge)](package.json)
10
+ [![版本](https://img.shields.io/badge/%E7%89%88%E6%9C%AC-0.2.3-64748b?style=for-the-badge)](package.json)
11
11
  [![依赖](https://img.shields.io/badge/%E8%BF%90%E8%A1%8C%E6%97%B6%E4%BE%9D%E8%B5%96-%E6%97%A0-16a34a?style=for-the-badge)](package.json)
12
12
 
13
13
  **简体中文** | [English](https://github.com/KeS1Ke/dsh-exit/blob/main/README.md)
14
14
 
15
15
  </div>
16
+
16
17
  > [!IMPORTANT]
17
18
  > 确认退出后会结束 dsh 宿主进程并释放其占用的端口;宿主终端窗口和当前浏览器标签页会被刻意保留。
18
19
 
@@ -30,7 +31,7 @@
30
31
 
31
32
  ## 🎬 GitHub 展示区
32
33
 
33
- 下面的动画、视频和截图是仓库专用展示素材,保留在 GitHub 用于文档展示,并明确排除在 npm 包之外。
34
+ 下面展示退出流程,以及插件在 dsh 设置面板中的实际效果。
34
35
 
35
36
  <p align="center">
36
37
  <img src="https://raw.githubusercontent.com/KeS1Ke/dsh-exit/main/docs/dsh-exit-landscape.webp" alt="由 Archify 生成的横版 dsh-exit 动态流程图" width="720">
@@ -61,7 +62,7 @@
61
62
  | Bundle 补丁 | [`cordis.patch.yml`](cordis.patch.yml) | 向 dsh profile 插入插件行。 |
62
63
  | 包元数据 | [`package.json`](package.json) | 声明宿主入口、Web 客户端和 Bundle 补丁。 |
63
64
 
64
- npm 包只包含上面四个核心运行文件、包元数据和 README;GitHub 展示素材单独保留在仓库中:
65
+ npm 包聚焦于运行所需的源码和包元数据,下面同时提供截图和流程源文件:
65
66
 
66
67
  | 展示素材 | 位置 |
67
68
  | --- | --- |
package/lib/client.js CHANGED
@@ -2,8 +2,9 @@
2
2
  /**
3
3
  * dsh-exit 客户端:
4
4
  * - 在 dsh 设置菜单中注册一个始终排在最后的“退出”栏目
5
- * - 栏目中的退出按钮首次点击弹确认弹窗;确认后调用 remote.dshExit.exit(),
6
- * 服务端结束宿主进程并释放端口,不操作终端窗口或浏览器标签页
5
+ * - 提供对齐展示的退出与重新启动操作;确认后分别调用
6
+ * remote.dshExit.exit() / remote.dshExit.restart()
7
+ * - 重启成功后等待后台服务恢复并自动刷新页面
7
8
  *
8
9
  * 页面使用 dsh 已提供的 React、slots 和 locale 运行时,不引入新的运行时依赖。
9
10
  */
@@ -19,9 +20,11 @@ window.__ModuleLoader__.load({
19
20
 
20
21
  /** 与 archive-manager「删除会话」相同的错误红(设计令牌)。 */
21
22
  const ERROR_RED = "var(--dsw-alias-state-error-primary)";
23
+ const RESTART_BLUE = "var(--dsw-alias-state-business-primary)";
22
24
 
23
25
  /** Lucide power 图标(ISC 许可,免费),16px 线性风格贴合现有按钮。 */
24
- const ICON_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.77.04"/></svg>';
26
+ const EXIT_ICON_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.77.04"/></svg>';
27
+ const RESTART_ICON_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 0 0-15.5-6.2L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 15.5 6.2L21 16"/><path d="M16 16h5v5"/></svg>';
25
28
 
26
29
  const CSS_TEXT = [
27
30
  ".dshExitSettings{box-sizing:border-box;max-width:720px;padding:28px 32px 40px;color:var(--dsw-alias-label-primary)}",
@@ -30,12 +33,22 @@ window.__ModuleLoader__.load({
30
33
  ".dshExitSettingsIcon svg{width:20px;height:20px}",
31
34
  ".dshExitSettingsTitle{margin:0;font-size:20px;line-height:28px;font-weight:650;color:var(--dsw-alias-label-primary)}",
32
35
  ".dshExitSettingsDescription{margin:6px 0 0;max-width:560px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary)}",
33
- ".dshExitSettingsCard{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-top:24px;padding:20px;border:1px solid var(--dsw-alias-border-l2);border-radius:14px;background:var(--dsw-alias-bg-layer-2)}",
36
+ ".dshExitSettingsActions{display:flex;flex-direction:column;gap:12px;margin-top:24px}",
37
+ ".dshExitSettingsCard{display:grid;grid-template-columns:34px minmax(0,1fr) auto;align-items:center;gap:14px;padding:20px;border:1px solid var(--dsw-alias-border-l2);border-radius:14px;background:var(--dsw-alias-bg-layer-2)}",
38
+ ".dshExitActionIcon{box-sizing:border-box;width:34px;height:34px;display:grid;place-items:center;border:1px solid currentColor;border-radius:10px;background:var(--dsw-alias-button-elevated-fill)}",
39
+ ".dshExitActionIcon--exit{color:" + ERROR_RED + "}",
40
+ ".dshExitActionIcon--restart{color:" + RESTART_BLUE + "}",
41
+ ".dshExitActionIcon svg{width:18px;height:18px}",
42
+ ".dshExitSettingsCopy{min-width:0}",
43
+ ".dshExitSettingsActionTitle{margin:0 0 3px;font-size:14px;line-height:20px;font-weight:650;color:var(--dsw-alias-label-primary)}",
34
44
  ".dshExitSettingsWarning{margin:0;max-width:430px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary)}",
35
- ".dshExitSettingsButton{display:inline-flex;align-items:center;justify-content:center;gap:8px;flex:none;min-width:112px;height:36px;padding:0 16px;cursor:pointer;border:0;border-radius:10px;background:" + ERROR_RED + ";color:#fff;font-size:13px;font-weight:650;transition:filter .12s ease,opacity .12s ease}",
45
+ ".dshExitSettingsButton{display:inline-flex;align-items:center;justify-content:center;gap:8px;flex:none;min-width:112px;height:36px;padding:0 16px;cursor:pointer;border:0;border-radius:10px;color:#fff;font-size:13px;font-weight:650;transition:filter .12s ease,opacity .12s ease}",
46
+ ".dshExitSettingsButton--exit{background:" + ERROR_RED + "}",
47
+ ".dshExitSettingsButton--restart{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground)}",
36
48
  ".dshExitSettingsButton svg{width:16px;height:16px}",
37
49
  ".dshExitSettingsButton:hover:not(:disabled){filter:brightness(1.08)}",
38
- ".dshExitSettingsButton:focus-visible{outline:2px solid " + ERROR_RED + ";outline-offset:2px}",
50
+ ".dshExitSettingsButton--restart:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}",
51
+ ".dshExitSettingsButton:focus-visible{outline:2px solid var(--dsw-alias-border-l3);outline-offset:2px}",
39
52
  ".dshExitSettingsButton:disabled{cursor:default;opacity:.6}",
40
53
  ".dshExitNavCell{color:" + ERROR_RED + " !important}",
41
54
  ".dshExitNavCell .dshExitNavLabel{color:" + ERROR_RED + " !important;font-weight:650}",
@@ -53,8 +66,11 @@ window.__ModuleLoader__.load({
53
66
  ".dshExitCancel:hover{background:var(--dsw-alias-interactive-bg-hover)}",
54
67
  ".dshExitCancel:disabled{cursor:default;opacity:.6}",
55
68
  ".dshExitConfirm{cursor:pointer;height:32px;padding:0 14px;border-radius:10px;border:none;background:" + ERROR_RED + ";color:#fff;font-size:13px;font-weight:600}",
69
+ ".dshExitConfirm--restart{background:var(--dsw-alias-button-primary-fill);color:var(--dsw-alias-label-primary-foreground)}",
56
70
  ".dshExitConfirm:hover:not(:disabled){filter:brightness(1.08)}",
57
- ".dshExitConfirm:disabled{cursor:default;opacity:.6}"
71
+ ".dshExitConfirm--restart:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)}",
72
+ ".dshExitConfirm:disabled{cursor:default;opacity:.6}",
73
+ "@media (max-width:620px){.dshExitSettings{padding:24px 20px 32px}.dshExitSettingsCard{grid-template-columns:34px minmax(0,1fr)}.dshExitSettingsButton{grid-column:1 / -1;justify-self:start}}"
58
74
  ].join("\n");
59
75
 
60
76
  /** 客户端严格编解码 shim:与服务端 exitedSchema 对应。 */
@@ -81,6 +97,19 @@ window.__ModuleLoader__.load({
81
97
  typeSymbol: "@kesike/dsh-exit/types#Exited",
82
98
  schema: exitedClientSchema
83
99
  }
100
+ },
101
+ {
102
+ id: "@kesike/dsh-exit#dshExit/restart",
103
+ service: "dshExit",
104
+ namespace: "dshExit",
105
+ method: "restart",
106
+ invocation: { kind: "direct" },
107
+ parameters: [],
108
+ result: {
109
+ mode: "strict",
110
+ typeSymbol: "@kesike/dsh-exit/types#Restarted",
111
+ schema: exitedClientSchema
112
+ }
84
113
  }
85
114
  ]
86
115
  };
@@ -89,35 +118,57 @@ window.__ModuleLoader__.load({
89
118
  const DICT = {
90
119
  zh: {
91
120
  nav: "退出",
92
- title: "退出 dsh",
93
- description: "从这里安全结束 dsh 宿主进程,并释放它占用的端口。",
94
- warning: "确认后将结束所有运行中的会话并停止后台服务,当前 dsh Web 端口 {endpoint} 会随之释放。",
95
- button: "确认退出",
121
+ title: "退出与重新启动 dsh",
122
+ description: "从这里安全结束或重新启动 dsh 宿主进程,并管理它占用的端口。",
123
+ exitActionTitle: "结束 dsh",
124
+ exitWarning: "确认后将结束所有运行中的会话并停止后台服务,当前 dsh Web 端口 {endpoint} 会随之释放。",
125
+ exitButton: "确认退出",
126
+ restartActionTitle: "重新启动 dsh",
127
+ restartWarning: "重新启动会短暂中断所有运行中的会话;后台服务恢复后,页面将自动刷新。",
128
+ restartButton: "重新启动",
96
129
  dialogTitle: "退出 DeepSeek Harness?",
97
130
  dialogBody: "退出后将结束所有运行中的会话并停止后台服务,当前 dsh Web 端口 {endpoint} 会随之释放。",
131
+ restartDialogTitle: "重新启动 DeepSeek Harness?",
132
+ restartDialogBody: "这会结束当前运行中的会话,后台服务将释放并重新监听 {endpoint}。服务恢复后页面将自动刷新。",
98
133
  cancel: "取消",
99
134
  confirm: "确认退出",
135
+ restartConfirm: "确认重启",
100
136
  exiting: "正在退出…",
101
- errorPrefix: "退出失败:"
137
+ restarting: "正在重新启动…",
138
+ restartWaiting: "后台服务正在重新启动,恢复后页面将自动刷新。",
139
+ restartTimeout: "后台服务尚未恢复,请稍后手动刷新页面。",
140
+ exitErrorPrefix: "退出失败:",
141
+ restartErrorPrefix: "重启失败:"
102
142
  },
103
143
  en: {
104
144
  nav: "Exit",
105
- title: "Exit dsh",
106
- description: "Safely terminate the dsh host process and release the ports it owns.",
107
- warning: "Confirming ends all running sessions and stops the background service, releasing the current dsh Web port {endpoint}.",
108
- button: "Confirm exit",
145
+ title: "Exit or restart dsh",
146
+ description: "Safely terminate or restart the dsh host process and manage the ports it owns.",
147
+ exitActionTitle: "Exit dsh",
148
+ exitWarning: "Confirming ends all running sessions and stops the background service, releasing the current dsh Web port {endpoint}.",
149
+ exitButton: "Confirm exit",
150
+ restartActionTitle: "Restart dsh",
151
+ restartWarning: "Restarting briefly interrupts all running sessions; this page refreshes automatically after the service recovers.",
152
+ restartButton: "Restart",
109
153
  dialogTitle: "Exit DeepSeek Harness?",
110
154
  dialogBody: "This ends all running sessions and stops the background service, releasing the current dsh Web port {endpoint}.",
155
+ restartDialogTitle: "Restart DeepSeek Harness?",
156
+ restartDialogBody: "This ends the current sessions while the background service releases and reclaims {endpoint}. The page refreshes automatically after recovery.",
111
157
  cancel: "Cancel",
112
158
  confirm: "Confirm exit",
159
+ restartConfirm: "Confirm restart",
113
160
  exiting: "Exiting…",
114
- errorPrefix: "Exit failed: "
161
+ restarting: "Restarting…",
162
+ restartWaiting: "The background service is restarting. This page will refresh after it recovers.",
163
+ restartTimeout: "The background service has not recovered yet. Please refresh this page later.",
164
+ exitErrorPrefix: "Exit failed: ",
165
+ restartErrorPrefix: "Restart failed: "
115
166
  }
116
167
  };
117
168
 
118
- let exiting = false;
119
169
  let overlayEl = null;
120
170
  let escHandler = null;
171
+ let restartPollTimer = null;
121
172
 
122
173
  function injectCss() {
123
174
  if (document.querySelector("style[data-plugin-css='dsh-exit']")) return;
@@ -150,7 +201,7 @@ window.__ModuleLoader__.load({
150
201
  const originalIcon = cell.firstElementChild;
151
202
  const icon = document.createElement("span");
152
203
  icon.className = "dshExitNavIcon";
153
- icon.innerHTML = ICON_SVG;
204
+ icon.innerHTML = EXIT_ICON_SVG;
154
205
  if (originalIcon !== null && originalIcon !== label) {
155
206
  originalIcon.dataset.dshExitOriginalIcon = "true";
156
207
  originalIcon.style.display = "none";
@@ -173,6 +224,31 @@ window.__ModuleLoader__.load({
173
224
 
174
225
  function ExitSettingsSection({ ctx, t }) {
175
226
  const endpoint = currentDshEndpoint();
227
+ const actionCard = ({ action, icon, title, warning, button }) => h("div", {
228
+ className: "dshExitSettingsCard",
229
+ key: action
230
+ }, [
231
+ h("span", {
232
+ className: "dshExitActionIcon dshExitActionIcon--" + action,
233
+ dangerouslySetInnerHTML: { __html: icon },
234
+ "aria-hidden": "true",
235
+ key: "icon"
236
+ }),
237
+ h("div", { className: "dshExitSettingsCopy", key: "copy" }, [
238
+ h("h3", { className: "dshExitSettingsActionTitle", key: "title" }, textOf(t, title)),
239
+ h("p", { className: "dshExitSettingsWarning", key: "warning" }, textOf(t, warning, { endpoint }))
240
+ ]),
241
+ h("button", {
242
+ type: "button",
243
+ className: "dshExitSettingsButton dshExitSettingsButton--" + action,
244
+ "aria-label": textOf(t, button),
245
+ onClick: (event) => openModal(ctx, event.currentTarget, t, action),
246
+ key: "button"
247
+ }, [
248
+ h("span", { dangerouslySetInnerHTML: { __html: icon }, "aria-hidden": "true", key: "icon" }),
249
+ textOf(t, button)
250
+ ])
251
+ ]);
176
252
  return h("section", {
177
253
  className: "dshExitSettings",
178
254
  "aria-labelledby": "dsh-exit-settings-title"
@@ -180,7 +256,7 @@ window.__ModuleLoader__.load({
180
256
  h("div", { className: "dshExitSettingsHeader", key: "header" }, [
181
257
  h("span", {
182
258
  className: "dshExitSettingsIcon",
183
- dangerouslySetInnerHTML: { __html: ICON_SVG },
259
+ dangerouslySetInnerHTML: { __html: EXIT_ICON_SVG },
184
260
  key: "icon"
185
261
  }),
186
262
  h("div", { key: "copy" }, [
@@ -188,23 +264,30 @@ window.__ModuleLoader__.load({
188
264
  h("p", { className: "dshExitSettingsDescription", key: "description" }, textOf(t, "description"))
189
265
  ])
190
266
  ]),
191
- h("div", { className: "dshExitSettingsCard", key: "card" }, [
192
- h("p", { className: "dshExitSettingsWarning", key: "warning" }, textOf(t, "warning", { endpoint })),
193
- h("button", {
194
- type: "button",
195
- className: "dshExitSettingsButton",
196
- "aria-label": textOf(t, "button"),
197
- onClick: (event) => openModal(ctx, event.currentTarget, t),
198
- key: "button"
199
- }, [
200
- h("span", { dangerouslySetInnerHTML: { __html: ICON_SVG }, key: "icon" }),
201
- textOf(t, "button")
202
- ])
267
+ h("div", { className: "dshExitSettingsActions", key: "actions" }, [
268
+ actionCard({
269
+ action: "exit",
270
+ icon: EXIT_ICON_SVG,
271
+ title: "exitActionTitle",
272
+ warning: "exitWarning",
273
+ button: "exitButton"
274
+ }),
275
+ actionCard({
276
+ action: "restart",
277
+ icon: RESTART_ICON_SVG,
278
+ title: "restartActionTitle",
279
+ warning: "restartWarning",
280
+ button: "restartButton"
281
+ })
203
282
  ])
204
283
  ]);
205
284
  }
206
285
 
207
286
  function closeModal() {
287
+ if (restartPollTimer !== null) {
288
+ clearTimeout(restartPollTimer);
289
+ restartPollTimer = null;
290
+ }
208
291
  if (escHandler) {
209
292
  document.removeEventListener("keydown", escHandler);
210
293
  escHandler = null;
@@ -215,8 +298,53 @@ window.__ModuleLoader__.load({
215
298
  }
216
299
  }
217
300
 
218
- function openModal(ctx, exitBtn, t) {
301
+ function waitForRestart({ title, body, errorEl, actions, cancel, confirm, t }) {
302
+ const startedAt = Date.now();
303
+ let sawUnavailable = false;
304
+ title.textContent = textOf(t, "restarting");
305
+ body.textContent = textOf(t, "restartWaiting");
306
+ actions.style.display = "none";
307
+ const check = async () => {
308
+ try {
309
+ const response = await fetch("/?dshRestartProbe=" + Date.now(), { cache: "no-store" });
310
+ if (response.ok && (sawUnavailable || Date.now() - startedAt >= 5_000)) {
311
+ globalThis.location.reload();
312
+ return;
313
+ }
314
+ } catch {
315
+ sawUnavailable = true;
316
+ }
317
+ if (Date.now() - startedAt >= 60_000) {
318
+ restartPollTimer = null;
319
+ errorEl.textContent = textOf(t, "restartTimeout");
320
+ actions.style.removeProperty("display");
321
+ confirm.style.display = "none";
322
+ cancel.disabled = false;
323
+ return;
324
+ }
325
+ restartPollTimer = setTimeout(check, 500);
326
+ };
327
+ restartPollTimer = setTimeout(check, 800);
328
+ }
329
+
330
+ function openModal(ctx, actionBtn, t, action) {
219
331
  const endpoint = currentDshEndpoint();
332
+ const isRestart = action === "restart";
333
+ const keys = isRestart ? {
334
+ title: "restartDialogTitle",
335
+ body: "restartDialogBody",
336
+ confirm: "restartConfirm",
337
+ pending: "restarting",
338
+ button: "restartButton",
339
+ error: "restartErrorPrefix"
340
+ } : {
341
+ title: "dialogTitle",
342
+ body: "dialogBody",
343
+ confirm: "confirm",
344
+ pending: "exiting",
345
+ button: "exitButton",
346
+ error: "exitErrorPrefix"
347
+ };
220
348
  const text = (key) => textOf(t, key, { endpoint });
221
349
  closeModal();
222
350
  overlayEl = document.createElement("div");
@@ -225,13 +353,13 @@ window.__ModuleLoader__.load({
225
353
  panel.className = "dshExitModal";
226
354
  panel.setAttribute("role", "dialog");
227
355
  panel.setAttribute("aria-modal", "true");
228
- panel.setAttribute("aria-label", text("dialogTitle"));
356
+ panel.setAttribute("aria-label", text(keys.title));
229
357
  const title = document.createElement("h3");
230
358
  title.className = "dshExitTitle";
231
- title.textContent = text("dialogTitle");
359
+ title.textContent = text(keys.title);
232
360
  const body = document.createElement("p");
233
361
  body.className = "dshExitBody";
234
- body.textContent = text("dialogBody");
362
+ body.textContent = text(keys.body);
235
363
  const errorEl = document.createElement("p");
236
364
  errorEl.className = "dshExitError";
237
365
  const actions = document.createElement("div");
@@ -242,48 +370,51 @@ window.__ModuleLoader__.load({
242
370
  cancel.textContent = text("cancel");
243
371
  const confirm = document.createElement("button");
244
372
  confirm.type = "button";
245
- confirm.className = "dshExitConfirm";
246
- confirm.textContent = text("confirm");
373
+ confirm.className = "dshExitConfirm" + (isRestart ? " dshExitConfirm--restart" : "");
374
+ confirm.textContent = text(keys.confirm);
247
375
  actions.append(cancel, confirm);
248
376
  panel.append(title, body, errorEl, actions);
249
377
  overlayEl.appendChild(panel);
250
378
 
251
379
  escHandler = (event) => {
252
- if (event.key === "Escape" && !confirm.disabled) closeModal();
380
+ if (event.key === "Escape" && !cancel.disabled) closeModal();
253
381
  };
254
382
  document.addEventListener("keydown", escHandler);
255
383
  overlayEl.addEventListener("click", (event) => {
256
- if (event.target === overlayEl && !confirm.disabled) closeModal();
384
+ if (event.target === overlayEl && !cancel.disabled) closeModal();
257
385
  });
258
386
  cancel.addEventListener("click", () => {
259
- if (!confirm.disabled) closeModal();
387
+ if (!cancel.disabled) closeModal();
260
388
  });
261
389
  confirm.addEventListener("click", () => {
262
- exiting = true;
263
390
  confirm.disabled = true;
264
391
  cancel.disabled = true;
265
392
  errorEl.textContent = "";
266
- exitBtn.disabled = true;
267
- exitBtn.setAttribute("aria-label", text("exiting"));
393
+ actionBtn.disabled = true;
394
+ actionBtn.setAttribute("aria-label", text(keys.pending));
268
395
  Promise.resolve()
269
396
  .then(() => {
270
397
  const service = ctx.get("remote.dshExit");
271
398
  if (service === undefined) throw new Error("dsh-exit 服务不可用(宿主未注册?)");
272
- return service.exit();
399
+ if (typeof service[action] !== "function") throw new Error("dsh-exit 不支持 " + action + " 操作");
400
+ return service[action]();
273
401
  })
274
402
  .then((result) => {
275
403
  if (!result || result.ok !== true) {
276
- throw new Error((result && result.error && result.error.message) || "退出调用失败");
404
+ throw new Error((result && result.error && result.error.message) || action + " 调用失败");
405
+ }
406
+ if (isRestart) {
407
+ waitForRestart({ title, body, errorEl, actions, cancel, confirm, t });
408
+ } else {
409
+ closeModal();
277
410
  }
278
- closeModal();
279
411
  })
280
412
  .catch((reason) => {
281
- exiting = false;
282
413
  confirm.disabled = false;
283
414
  cancel.disabled = false;
284
- exitBtn.disabled = false;
285
- exitBtn.setAttribute("aria-label", "退出");
286
- errorEl.textContent = text("errorPrefix") + (reason instanceof Error ? reason.message : String(reason));
415
+ actionBtn.disabled = false;
416
+ actionBtn.setAttribute("aria-label", text(keys.button));
417
+ errorEl.textContent = text(keys.error) + (reason instanceof Error ? reason.message : String(reason));
287
418
  });
288
419
  });
289
420
  document.body.appendChild(overlayEl);
@@ -316,7 +447,7 @@ window.__ModuleLoader__.load({
316
447
  };
317
448
  }
318
449
 
319
- exports.inject = ["slots", "locale"];
450
+ exports.inject = ["slots", "locale", "remote"];
320
451
  exports.apply = apply;
321
452
  return module.exports;
322
453
  }
package/lib/index.js CHANGED
@@ -2,10 +2,15 @@
2
2
  /**
3
3
  * dsh-exit:把「退出 dsh 宿主并释放端口」暴露给浏览器的一个宿主服务。
4
4
  *
5
- * 远程面:`remote.dshExit.exit()`(Typert Remote,经 /api/dshExit/exit 网关)。
6
- * 调用后:先应答,再延迟 400ms 结束宿主进程——端口随进程退出释放;
5
+ * 远程面:`remote.dshExit.exit()` / `remote.dshExit.restart()`(Typert Remote)。
6
+ * 调用后:先应答,再延迟 400ms 请求 dsh 完成有界清理并结束宿主进程;
7
+ * 重启时由独立辅助进程等待旧宿主退出,再在 Windows Terminal 新前台窗口中
8
+ * 用原启动参数拉起新宿主。
7
9
  * 不操作宿主终端窗口或系统托盘。
8
10
  */
11
+ import { existsSync } from "node:fs";
12
+ import { spawn, spawnSync } from "node:child_process";
13
+ import { join } from "node:path";
9
14
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
10
15
 
11
16
  const exitedSchema = {
@@ -30,6 +35,20 @@ const EXIT_INVOCATIONS = [
30
35
  schema: exitedSchema
31
36
  },
32
37
  sourceLocation: { file: "@kesike/dsh-exit/lib/index.js", line: 1, column: 1 }
38
+ },
39
+ {
40
+ id: "@kesike/dsh-exit#dshExit/restart",
41
+ service: "dshExit",
42
+ namespace: "dshExit",
43
+ method: "restart",
44
+ invocation: { kind: "direct" },
45
+ parameters: [],
46
+ result: {
47
+ mode: "strict",
48
+ typeSymbol: "@kesike/dsh-exit/types#Restarted",
49
+ schema: exitedSchema
50
+ },
51
+ sourceLocation: { file: "@kesike/dsh-exit/lib/index.js", line: 1, column: 1 }
33
52
  }
34
53
  ];
35
54
 
@@ -57,14 +76,192 @@ function markRemoteMethod(instance, method) {
57
76
  Remote(method)(void 0, context);
58
77
  }
59
78
 
79
+ /** 拒绝在 supervisor/Node IPC 管道下自脱离重启,避免新宿主失去外部生命周期管理。 */
80
+ function hasSupervisorChannel() {
81
+ return process.env.NODE_CHANNEL_FD !== undefined ||
82
+ process.env.NODE_UNIQUE_ID !== undefined ||
83
+ typeof process.send === "function";
84
+ }
85
+
86
+ /** 等待 detached 子进程确认已成功创建;异步 error 不能由 spawn() 的 try/catch 捕获。 */
87
+ function waitForSpawn(child, label) {
88
+ return new Promise((resolve, reject) => {
89
+ child.once("spawn", resolve);
90
+ child.once("error", (error) => {
91
+ const detail = error instanceof Error ? error.message : String(error);
92
+ reject(new Error(`${label} failed to start: ${detail}`, { cause: error }));
93
+ });
94
+ });
95
+ }
96
+
97
+ /** 解析 PowerShell 7,避免新 Windows Terminal tab 落回 Windows PowerShell 5。 */
98
+ function resolvePowerShell7() {
99
+ const candidates = [
100
+ process.env.ProgramW6432 === undefined ? undefined : join(process.env.ProgramW6432, "PowerShell", "7", "pwsh.exe"),
101
+ process.env.ProgramFiles === undefined ? undefined : join(process.env.ProgramFiles, "PowerShell", "7", "pwsh.exe"),
102
+ process.env.LOCALAPPDATA === undefined ? undefined : join(process.env.LOCALAPPDATA, "Programs", "PowerShell", "7", "pwsh.exe")
103
+ ].filter((candidate) => candidate !== undefined && existsSync(candidate));
104
+ if (candidates[0] !== undefined) return candidates[0];
105
+ const result = spawnSync("where.exe", ["pwsh.exe"], {
106
+ encoding: "utf8",
107
+ stdio: ["ignore", "pipe", "ignore"],
108
+ windowsHide: true
109
+ });
110
+ const executable = String(result.stdout || "")
111
+ .split(/\r?\n/)
112
+ .map((line) => line.trim())
113
+ .find(Boolean);
114
+ if (result.status === 0 && executable !== undefined) return executable;
115
+ throw new Error("dsh-exit: PowerShell 7 (pwsh.exe) is not available");
116
+ }
117
+
118
+ /** 通过 PowerShell 7 解析当前用户安装的 Windows Terminal 本体。 */
119
+ function resolveWindowsTerminal(shellPath) {
120
+ const command = [
121
+ "$package = Get-AppxPackage -Name 'Microsoft.WindowsTerminal' | Sort-Object Version -Descending | Select-Object -First 1",
122
+ "if ($null -eq $package) { exit 1 }",
123
+ "$terminal = Join-Path $package.InstallLocation 'WindowsTerminal.exe'",
124
+ "if (-not (Test-Path -LiteralPath $terminal)) { exit 1 }",
125
+ "Write-Output $terminal"
126
+ ].join("; ");
127
+ const result = spawnSync(shellPath, [
128
+ "-NoLogo",
129
+ "-NoProfile",
130
+ "-NonInteractive",
131
+ "-ExecutionPolicy",
132
+ "Bypass",
133
+ "-Command",
134
+ command
135
+ ], {
136
+ encoding: "utf8",
137
+ stdio: ["ignore", "pipe", "pipe"],
138
+ windowsHide: true
139
+ });
140
+ const terminal = String(result.stdout || "")
141
+ .split(/\r?\n/)
142
+ .map((line) => line.trim())
143
+ .find((line) => line.endsWith("WindowsTerminal.exe") && existsSync(line));
144
+ if (result.status === 0 && terminal !== undefined) return terminal;
145
+ const detail = String(result.stderr || "").trim();
146
+ throw new Error("dsh-exit: Windows Terminal (WindowsTerminal.exe) is not available" +
147
+ (detail === "" ? "" : ": " + detail));
148
+ }
149
+
150
+ /** 将 Node 重启参数编码成 PowerShell 7 的 -EncodedCommand,避免 WT 二次解析。 */
151
+ function encodePowerShellLaunch(payload) {
152
+ const payloadBase64 = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
153
+ const nodeCommand = [
154
+ `$payloadJson = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${payloadBase64}'))`,
155
+ "$payload = $payloadJson | ConvertFrom-Json",
156
+ "Set-Location -LiteralPath $payload.cwd",
157
+ "$arguments = @($payload.args)",
158
+ "& $payload.execPath @arguments",
159
+ "exit $LASTEXITCODE"
160
+ ].join("; ");
161
+ const nodeCommandBase64 = Buffer.from(nodeCommand, "utf16le").toString("base64");
162
+ return nodeCommandBase64;
163
+ }
164
+
165
+ const RESTART_HELPER = String.raw`
166
+ const { spawn } = require("node:child_process");
167
+ const payload = JSON.parse(process.argv[1]);
168
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
169
+ const isAlive = (pid) => {
170
+ try {
171
+ process.kill(pid, 0);
172
+ return true;
173
+ } catch (error) {
174
+ // ESRCH means the PID is gone. Treat every other failure as alive so
175
+ // permissions or transient Windows errors fail closed instead of
176
+ // starting a second host while the old one may still own the port.
177
+ return error?.code !== "ESRCH";
178
+ }
179
+ };
180
+ (async () => {
181
+ const deadline = Date.now() + payload.waitTimeoutMs;
182
+ while (isAlive(payload.parentPid) && Date.now() < deadline) await sleep(200);
183
+ if (isAlive(payload.parentPid)) process.exit(1);
184
+ await sleep(250);
185
+ const terminalLaunch = payload.launcher === "windows-terminal";
186
+ const launchArgs = terminalLaunch ? [
187
+ "-w",
188
+ "new",
189
+ "new-tab",
190
+ "--title",
191
+ payload.title,
192
+ "--startingDirectory",
193
+ payload.cwd,
194
+ "--inheritEnvironment",
195
+ payload.shellPath,
196
+ "-NoLogo",
197
+ "-NoProfile",
198
+ "-ExecutionPolicy",
199
+ "Bypass",
200
+ "-EncodedCommand",
201
+ payload.commandBase64
202
+ ] : payload.args;
203
+ const child = spawn(terminalLaunch ? payload.terminalPath : payload.execPath, launchArgs, {
204
+ cwd: payload.cwd,
205
+ env: process.env,
206
+ detached: true,
207
+ stdio: "ignore",
208
+ windowsHide: terminalLaunch ? false : true
209
+ });
210
+ child.once("error", () => {
211
+ process.exitCode = 1;
212
+ });
213
+ child.once("spawn", () => child.unref());
214
+ })().catch(() => {
215
+ process.exitCode = 1;
216
+ });
217
+ `;
218
+
219
+ /** 启动一个与旧宿主生命周期解耦的 helper;Windows 下直接打开新的可见 Windows Terminal 窗口。 */
220
+ function scheduleRestart() {
221
+ if (hasSupervisorChannel()) {
222
+ throw new Error("dsh-exit: refusing self-restart under supervisor or Node IPC");
223
+ }
224
+ const launcher = process.platform === "win32" ? "windows-terminal" : "direct";
225
+ const env = { ...process.env };
226
+ delete env.NODE_CHANNEL_FD;
227
+ delete env.NODE_UNIQUE_ID;
228
+ const restartPayload = {
229
+ parentPid: process.pid,
230
+ execPath: process.execPath,
231
+ args: [...process.execArgv, ...process.argv.slice(1)],
232
+ cwd: process.cwd(),
233
+ };
234
+ const shellPath = launcher === "windows-terminal" ? resolvePowerShell7() : undefined;
235
+ const terminalPath = launcher === "windows-terminal" ? resolveWindowsTerminal(shellPath) : undefined;
236
+ const payload = JSON.stringify({
237
+ ...restartPayload,
238
+ launcher,
239
+ shellPath,
240
+ terminalPath,
241
+ commandBase64: launcher === "windows-terminal" ? encodePowerShellLaunch(restartPayload) : undefined,
242
+ title: "dsh-web",
243
+ waitTimeoutMs: 15_000
244
+ });
245
+ const helper = spawn(process.execPath, ["-e", RESTART_HELPER, payload], {
246
+ env,
247
+ detached: true,
248
+ stdio: "ignore",
249
+ windowsHide: true
250
+ });
251
+ const started = waitForSpawn(helper, "restart helper");
252
+ helper.unref();
253
+ return started;
254
+ }
255
+
60
256
  class DshExitService extends TypertRemoteService {
61
257
  static name = "dshExit";
62
258
  static inject = ["typert"];
63
- exiting = false;
259
+ closing = null;
64
260
 
65
261
  constructor(ctx) {
66
262
  super(ctx, "dshExit");
67
263
  markRemoteMethod(this, "exit");
264
+ markRemoteMethod(this, "restart");
68
265
  const existing = ctx.get("typert");
69
266
  if (existing !== undefined) {
70
267
  existing.register(EXIT_TYPERT);
@@ -75,12 +272,41 @@ class DshExitService extends TypertRemoteService {
75
272
  }
76
273
  }
77
274
 
275
+ scheduleRestart() {
276
+ return scheduleRestart();
277
+ }
278
+
279
+ /** 让远程响应先写回浏览器,再通过 dsh 的 appExit 执行有界资源清理。 */
280
+ requestShutdown() {
281
+ const appExit = this.ctx.get("appExit");
282
+ setTimeout(() => {
283
+ if (typeof appExit === "function") appExit(0);
284
+ else process.exit(0);
285
+ }, 400);
286
+ }
287
+
78
288
  /** 结束宿主并释放端口;不触碰宿主终端窗口。 */
79
289
  async exit() {
80
- if (this.exiting) return true;
81
- this.exiting = true;
290
+ if (this.closing !== null) return true;
291
+ this.closing = "exit";
82
292
  this.ctx.logger?.info?.("dsh-exit: exit requested from the web UI");
83
- setTimeout(() => process.exit(0), 400);
293
+ this.requestShutdown();
294
+ return true;
295
+ }
296
+
297
+ /** 清理并结束当前宿主,待旧进程退出后使用相同 Node 参数重新启动。 */
298
+ async restart() {
299
+ if (this.closing !== null) return true;
300
+ this.closing = "restart";
301
+ try {
302
+ await this.scheduleRestart();
303
+ } catch (error) {
304
+ this.closing = null;
305
+ this.ctx.logger?.error?.("dsh-exit: failed to schedule restart", error);
306
+ throw error;
307
+ }
308
+ this.ctx.logger?.info?.("dsh-exit: restart requested from the web UI");
309
+ this.requestShutdown();
84
310
  return true;
85
311
  }
86
312
  }
package/package.json CHANGED
@@ -1,9 +1,11 @@
1
1
  {
2
2
  "name": "@kesike/dsh-exit",
3
- "version": "0.2.4",
4
- "license": "MIT",
3
+ "version": "0.2.5",
5
4
  "type": "module",
6
5
  "description": "在 dsh web 界面提供退出按钮:确认后结束宿主进程并释放端口",
6
+ "scripts": {
7
+ "test": "node --test"
8
+ },
7
9
  "keywords": [
8
10
  "dsh",
9
11
  "deepseek-harness",
@@ -30,6 +32,13 @@
30
32
  "engines": {
31
33
  "node": ">=22.19.0 || >=24.0.0"
32
34
  },
35
+ "peerDependencies": {
36
+ "@deepseek-ai/dsh-api-remotes": "^0.1.1-rc.2",
37
+ "@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2",
38
+ "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2",
39
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.2",
40
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.1-rc.2"
41
+ },
33
42
  "files": [
34
43
  "lib",
35
44
  "cordis.patch.yml",
@@ -45,11 +54,13 @@
45
54
  },
46
55
  "client": {
47
56
  "inject": [
57
+ "@deepseek-ai/dsh-api-remotes",
48
58
  "@deepseek-ai/dsh-client-runtime",
49
- "@deepseek-ai/dsh-client-ui-slots",
59
+ "@deepseek-ai/dsh-client-ui-settings",
50
60
  "@deepseek-ai/dsh-client-locale"
51
61
  ],
52
62
  "platform": "web"
53
63
  }
54
64
  }
55
65
  }
66
+
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 KeS1Ke
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.