dsh-stop-service 0.3.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/LICENSE ADDED
@@ -0,0 +1,64 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 KhaosGx
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.
22
+
23
+ --------------------------------------------------------------------------------
24
+ THIRD-PARTY NOTICES
25
+ --------------------------------------------------------------------------------
26
+
27
+ This project includes source code adapted from dsh-client-ui-skill-explorer
28
+ (https://github.com/zhu1090093659/dsh-web): the loopback request fence in
29
+ lib/index.js (isIPv4Loopback / isLoopbackAddress / isLoopbackHostname /
30
+ isLoopbackRequest). That portion retains its original license, reproduced
31
+ below verbatim as required by its terms:
32
+
33
+ BSD 3-Clause License
34
+
35
+ Copyright (c) 2026, zhu1090093659
36
+ All rights reserved.
37
+
38
+ Redistribution and use in source and binary forms, with or without
39
+ modification, are permitted provided that the following conditions are met:
40
+
41
+ 1. Redistributions of source code must retain the above copyright notice, this
42
+ list of conditions and the following disclaimer.
43
+
44
+ 2. Redistributions in binary form must reproduce the above copyright notice,
45
+ this list of conditions and the following disclaimer in the documentation
46
+ and/or other materials provided with the distribution.
47
+
48
+ 3. Neither the name of the copyright holder nor the names of its
49
+ contributors may be used to endorse or promote products derived from
50
+ this software without specific prior written permission.
51
+
52
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
53
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
54
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
55
+ ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
56
+ LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
57
+ CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE
58
+ GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
59
+ HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
60
+ LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT
61
+ OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
62
+
63
+ Modifications to that portion, and all other files in this repository, are
64
+ Copyright (c) 2026 KhaosGx and licensed under the project MIT license above.
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # dsh-stop-service
2
+
3
+ A stop-service button for DeepSeek Harness (DSH) Web: one confirmed click gracefully terminates the running dsh host — no more orphaned background processes after closing a terminal. · 为 DeepSeek Harness (DSH) Web 提供「终止服务」按钮:一次带确认的点击即可优雅停止当前 dsh 宿主进程,告别关终端后残留的后台孤儿进程。
4
+
5
+ ## 功能 / Features
6
+
7
+ - **实时服务信息**:进程 PID、启动时间、运行时长、活跃会话数、内存占用、CPU 占用、Node / DSH 宿主 / 插件版本、平台——每 5 秒自动刷新,附内存趋势迷你图
8
+ - **复制诊断信息**:一键复制版本与运行状态摘要,提 issue 时直接粘贴
9
+ - 带**确认弹窗**的「终止服务」按钮:确认框显示将断开的活跃会话数;宿主进程收到 SIGTERM 优雅退出——会话落盘、端口释放,效果与在终端执行 `kill <pid>` 完全一致
10
+ - **服务回归自动刷新**:停止后持续探测,终端里重启完成时页面自动刷新接回
11
+ - 界面适配 `--dsw-*` 主题变量,跟随皮肤切换;中英双语文案
12
+
13
+ - Live service info: PID, start time, uptime, active sessions, memory, CPU, Node/DSH/plugin versions, platform — auto-refreshed every 5s, with a memory sparkline.
14
+ - Copy-diagnostics button for issue reports.
15
+ - Confirm-guarded stop button that names the live session count; SIGTERM graceful shutdown, identical to `kill <pid>` from a terminal.
16
+ - Auto-reload once the restarted host comes back.
17
+ - Theme-aware via `--dsw-*` tokens; bilingual (zh/en).
18
+
19
+ ## 兼容性 / Compatibility
20
+
21
+ - DeepSeek Harness `>= 0.1.2-rc.1`(在 0.1.2-rc.1 实测 / verified on 0.1.2-rc.1)
22
+ - Web profile(`platform: web`)
23
+
24
+ ## 安装 / Install
25
+
26
+ > [!IMPORTANT]
27
+ > 本插件**未发布到 npm**——`npm install dsh-stop-service` 与 `dsh plugin add dsh-stop-service` 均不可用,请使用下方 Git 安装方式。
28
+ > This plugin is **NOT published on npm** — the npm-based install commands will not work. Use the Git-based methods below.
29
+
30
+ 方式一 / Option 1 — GitHub 直装(实测于 dsh 0.1.2-rc.1 / verified on dsh 0.1.2-rc.1):
31
+
32
+ ```sh
33
+ dsh plugin --profile web add github:KhaosGx/dsh-stop-service
34
+ ```
35
+
36
+ 方式二 / Option 2 — 克隆后本地 link(适合参与开发 / for development):
37
+
38
+ ```sh
39
+ git clone https://github.com/KhaosGx/dsh-stop-service.git
40
+ cd dsh-stop-service
41
+ dsh plugin --profile web add link:$(pwd)
42
+ ```
43
+
44
+ 安装后重启 `dsh web`,按钮位于 **设置 → 服务控制**。/ Restart `dsh web`, then find it under **Settings → Service**.
45
+
46
+ ## 工作原理 / How it works
47
+
48
+ | 文件 | 职责 |
49
+ |---|---|
50
+ | `lib/index.js` | 宿主侧:注册三个仅限 loopback 的路由——`GET /health`(存活探测)、`GET /info`(服务与 profile 信息)、`POST /stop`(应答后 300ms 自发 SIGTERM) |
51
+ | `lib/client.js` | 浏览器侧:通过官方 `settings.section` 槽位注册设置分区与 React 组件,确认弹窗 → POST stop → 显示重启指引 |
52
+ | `cordis.patch.yml` | bundle 层自登记(`dsh.bundle.patch`) |
53
+
54
+ ## 安全模型 / Security
55
+
56
+ - 路由仅接受**本机 loopback** 请求:socket 环回地址 + 环回 Host 头 + 浏览器同源标记三者齐备才放行;`X-Forwarded-For` 永不信任,其余一律 `403 forbidden: loopback-only`
57
+ - `stop` 仅接受 POST;动作等价于终端 `kill`,无额外能力,不做持久化、不触网
58
+
59
+ ## 致谢 / Attribution
60
+
61
+ loopback 请求围栏的实现参考并改编自 [zhu1090093659/dsh-web](https://github.com/zhu1090093659/dsh-web) 的 `dsh-client-ui-skill-explorer`(BSD 3-Clause, © 2026 zhu1090093659),其版权与许可声明保留于 [LICENSE](LICENSE) 的第三方声明部分。
62
+
63
+ The loopback request fence is adapted from `dsh-client-ui-skill-explorer` by zhu1090093659 (BSD 3-Clause); see the third-party notice in [LICENSE](LICENSE).
64
+
65
+ ## 许可 / License
66
+
67
+ [MIT](LICENSE) © 2026 KhaosGx
@@ -0,0 +1,7 @@
1
+ # bundle patch for dsh-stop-service: inserts its plugin row into the web
2
+ # profile roster. Applied as a profile bundle layer (the `dsh.bundle.patch`
3
+ # manifest field); install with
4
+ # `dsh plugin --profile web add link:<repo>/dsh-stop-service`.
5
+ - insert:
6
+ - id: ui-stop-service
7
+ name: dsh-stop-service
package/lib/client.js ADDED
@@ -0,0 +1,379 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Copyright (c) 2026 KhaosGx
3
+
4
+ /**
5
+ * Browser side of dsh-stop-service.
6
+ *
7
+ * Adds a top-level "服务控制 / Service" section to Settings showing live
8
+ * service info (PID, versions, uptime, memory, loaded bundles) and a
9
+ * confirm-guarded stop button. The button POSTs /api/dsh-stop-service/stop;
10
+ * the host replies, then SIGTERMs itself. After the host dies this page
11
+ * keeps running in the browser and shows the restart hint.
12
+ */
13
+ window.__ModuleLoader__.load({
14
+ id: "dsh-stop-service",
15
+ factory: (require) => {
16
+ var module = { exports: {} };
17
+ var exports = module.exports;
18
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
19
+ var react = require("react");
20
+ var jsx_runtime = require("react/jsx-runtime");
21
+ const jsx = jsx_runtime.jsx;
22
+
23
+ /** Dictionary namespace owned by this plugin. */
24
+ const NS = "stopService";
25
+ /** Client services required by the settings registration. */
26
+ const inject = [
27
+ "slots",
28
+ "locale"
29
+ ];
30
+
31
+ const rowStyle = {
32
+ display: "flex",
33
+ justifyContent: "space-between",
34
+ gap: 16,
35
+ padding: "6px 0",
36
+ fontSize: 13,
37
+ borderBottom: "1px solid var(--dsw-border, rgba(128,128,128,0.18))",
38
+ };
39
+ const labelStyle = { opacity: 0.65, flexShrink: 0 };
40
+ const valueStyle = {
41
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
42
+ textAlign: "right",
43
+ wordBreak: "break-all",
44
+ };
45
+ /** Theme-aware card frame; fallbacks keep it legible on any theme. */
46
+ const cardStyle = {
47
+ border: "1px solid var(--dsw-border, rgba(128,128,128,0.28))",
48
+ borderRadius: 10,
49
+ padding: 20,
50
+ background: "var(--dsw-card-bg, transparent)",
51
+ };
52
+
53
+ function formatUptime(ms, t) {
54
+ const s = Math.floor(ms / 1000);
55
+ const d = Math.floor(s / 86400);
56
+ const h = Math.floor((s % 86400) / 3600);
57
+ const m = Math.floor((s % 3600) / 60);
58
+ const sec = s % 60;
59
+ if (d > 0) return `${d}${t("dayUnit")}${h}${t("hourUnit")}`;
60
+ if (h > 0) return `${h}${t("hourUnit")}${m}${t("minUnit")}`;
61
+ if (m > 0) return `${m}${t("minUnit")}${sec}${t("secUnit")}`;
62
+ return `${sec}${t("secUnit")}`;
63
+ }
64
+
65
+ const InfoCard = ({ t }) => {
66
+ const [info, setInfo] = react.useState(null);
67
+ const [history, setHistory] = react.useState([]);
68
+ const [copied, setCopied] = react.useState(false);
69
+
70
+ const copyDiagnostics = async () => {
71
+ if (info === null) return;
72
+ const lines = [
73
+ `DSH host: ${info.hostVersion ?? "-"}`,
74
+ `Plugin: dsh-stop-service ${info.pluginVersion ?? "-"}`,
75
+ `Node: ${info.node ?? "-"}`,
76
+ `Platform: ${info.platform ?? "-"}`,
77
+ `PID: ${info.pid} · Uptime: ${formatUptime(info.uptimeMs, t)}`,
78
+ `Memory (RSS): ${info.memoryRssMb} MB · CPU: ${typeof info.cpuPercent === "number" ? `${info.cpuPercent}%` : "-"} · Sessions: ${typeof info.sessionCount === "number" ? info.sessionCount : "-"}`,
79
+ ];
80
+ try {
81
+ await navigator.clipboard.writeText(lines.join("\n"));
82
+ setCopied(true);
83
+ window.setTimeout(() => setCopied(false), 2000);
84
+ } catch {
85
+ // Clipboard denied; ignore.
86
+ }
87
+ };
88
+
89
+ react.useEffect(() => {
90
+ let alive = true;
91
+ const load = async () => {
92
+ try {
93
+ const res = await fetch("/api/dsh-stop-service/info");
94
+ const data = await res.json();
95
+ if (alive && data?.ok) {
96
+ setInfo(data);
97
+ if (typeof data.memoryRssMb === "number") {
98
+ setHistory((prev) => [...prev, data.memoryRssMb].slice(-36));
99
+ }
100
+ }
101
+ } catch {
102
+ // Host unreachable (e.g. stopping); keep the last snapshot.
103
+ }
104
+ };
105
+ load();
106
+ const timer = window.setInterval(load, 5000);
107
+ return () => {
108
+ alive = false;
109
+ window.clearInterval(timer);
110
+ };
111
+ }, []);
112
+
113
+ if (info === null) {
114
+ return jsx("p", { style: { fontSize: 13, opacity: 0.6 }, children: t("loading") });
115
+ }
116
+
117
+ const rows = [
118
+ [t("infoPid"), String(info.pid)],
119
+ [t("infoStartedAt"), new Date(info.startedAt).toLocaleString()],
120
+ [t("infoUptime"), formatUptime(info.uptimeMs, t)],
121
+ [t("infoSessions"), typeof info.sessionCount === "number" ? String(info.sessionCount) : "-"],
122
+ [t("infoMemory"), `${info.memoryRssMb} MB`],
123
+ [t("infoCpu"), typeof info.cpuPercent === "number" ? `${info.cpuPercent}%` : "-"],
124
+ [t("infoNode"), info.node ?? "-"],
125
+ [t("infoHostVersion"), info.hostVersion ?? "-"],
126
+ [t("infoPluginVersion"), info.pluginVersion ?? "-"],
127
+ [t("infoPlatform"), info.platform ?? "-"],
128
+ ];
129
+
130
+ return jsx("div", {
131
+ style: { marginBottom: 20 },
132
+ children: [
133
+ jsx("div", {
134
+ key: "header",
135
+ style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 },
136
+ children: [
137
+ jsx("h3", {
138
+ key: "title",
139
+ style: { margin: 0, fontSize: 15 },
140
+ children: t("infoTitle")
141
+ }),
142
+ jsx("button", {
143
+ key: "copy",
144
+ onClick: copyDiagnostics,
145
+ style: {
146
+ padding: "4px 12px",
147
+ fontSize: 12,
148
+ borderRadius: 7,
149
+ border: "1px solid var(--dsw-border, rgba(128,128,128,0.35))",
150
+ background: "transparent",
151
+ color: "inherit",
152
+ cursor: "pointer",
153
+ },
154
+ children: copied ? t("copied") : t("copyDiag")
155
+ })
156
+ ]
157
+ }),
158
+ ...rows.map(([label, value]) => jsx("div", { style: rowStyle, children: [
159
+ jsx("span", { style: labelStyle, children: label }),
160
+ jsx("span", { style: valueStyle, children: value })
161
+ ] }, label)),
162
+ history.length >= 2
163
+ ? jsx("div", { key: "sparkline", style: { marginTop: 10 }, children: [
164
+ jsx("div", { key: "label", style: { fontSize: 11, opacity: 0.55, marginBottom: 4 }, children: `${t("memTrend")} (${history.length * 5}s)` }),
165
+ jsx("svg", {
166
+ key: "svg",
167
+ width: "100%",
168
+ height: 28,
169
+ viewBox: "0 0 100 28",
170
+ preserveAspectRatio: "none",
171
+ style: { display: "block" },
172
+ children: jsx("polyline", {
173
+ points: (() => {
174
+ const min = Math.min(...history);
175
+ const max = Math.max(...history);
176
+ const span = max - min || 1;
177
+ const step = 100 / (history.length - 1);
178
+ return history
179
+ .map((v, i) => `${(i * step).toFixed(1)},${(25 - ((v - min) / span) * 22).toFixed(1)}`)
180
+ .join(" ");
181
+ })(),
182
+ fill: "none",
183
+ stroke: "var(--dsw-accent, #4a90d9)",
184
+ strokeWidth: 1.4,
185
+ vectorEffect: "non-scaling-stroke"
186
+ })
187
+ })
188
+ ] })
189
+ : null,
190
+ ]
191
+ });
192
+ };
193
+
194
+ const StopCard = ({ t }) => {
195
+ const [state, setState] = react.useState("idle");
196
+
197
+ // After the host dies, watch for its successor and auto-reload so the
198
+ // user does not have to refresh manually after restarting the service.
199
+ react.useEffect(() => {
200
+ if (state !== "stopped") return;
201
+ let watching = true;
202
+ const probe = async () => {
203
+ try {
204
+ const res = await fetch("/api/dsh-stop-service/health");
205
+ if (!watching) return;
206
+ if (res.ok) window.location.reload();
207
+ } catch {
208
+ // Still down; keep watching.
209
+ }
210
+ };
211
+ const timer = window.setInterval(probe, 2000);
212
+ return () => {
213
+ watching = false;
214
+ window.clearInterval(timer);
215
+ };
216
+ }, [state]);
217
+
218
+ const onStop = async () => {
219
+ // One-shot info fetch so the confirmation can name the live session count.
220
+ let confirmText = t("confirm");
221
+ try {
222
+ const res = await fetch("/api/dsh-stop-service/info");
223
+ const data = await res.json();
224
+ if (data?.ok && typeof data.sessionCount === "number") {
225
+ confirmText = t("confirmN").replace("{n}", String(data.sessionCount));
226
+ }
227
+ } catch {
228
+ // Info unavailable; fall back to the generic confirmation.
229
+ }
230
+ if (!window.confirm(confirmText)) return;
231
+ setState("stopping");
232
+ try {
233
+ await fetch("/api/dsh-stop-service/stop", { method: "POST" });
234
+ } catch {
235
+ // The host dies right after replying; a network error here
236
+ // still means the stop request went through.
237
+ }
238
+ setState("stopped");
239
+ };
240
+
241
+ if (state === "stopped") {
242
+ return jsx("p", {
243
+ style: { fontSize: 14, lineHeight: 1.8, whiteSpace: "pre-line" },
244
+ children: t("stopped")
245
+ });
246
+ }
247
+
248
+ return jsx("div", {
249
+ style: cardStyle,
250
+ children: [
251
+ jsx("h3", {
252
+ key: "title",
253
+ style: { margin: "0 0 8px", fontSize: 15 },
254
+ children: t("title")
255
+ }),
256
+ jsx("p", {
257
+ key: "desc",
258
+ style: { margin: "0 0 4px", fontSize: 13, opacity: 0.85, lineHeight: 1.7 },
259
+ children: t("desc")
260
+ }),
261
+ jsx("p", {
262
+ key: "hint",
263
+ style: { margin: "0 0 16px", fontSize: 12, opacity: 0.6, lineHeight: 1.7 },
264
+ children: t("hint")
265
+ }),
266
+ jsx("button", {
267
+ key: "button",
268
+ onClick: onStop,
269
+ disabled: state === "stopping",
270
+ style: {
271
+ padding: "8px 18px",
272
+ fontSize: 13,
273
+ borderRadius: 8,
274
+ border: "1px solid var(--dsw-danger, #d33)",
275
+ background: state === "stopping" ? "transparent" : "var(--dsw-danger, #d33)",
276
+ color: state === "stopping" ? "var(--dsw-danger, #d33)" : "var(--dsw-danger-text, #fff)",
277
+ cursor: state === "stopping" ? "default" : "pointer",
278
+ },
279
+ children: state === "stopping" ? t("stopping") : t("stop")
280
+ }),
281
+ ]
282
+ });
283
+ };
284
+
285
+ const StopServiceTab = ({ t }) => {
286
+ return jsx("div", {
287
+ style: { padding: "24px 8px", maxWidth: 560 },
288
+ children: [
289
+ jsx(InfoCard, { key: "info", t: t }),
290
+ jsx(StopCard, { key: "stop", t: t }),
291
+ ]
292
+ });
293
+ };
294
+
295
+ /**
296
+ * Contribute the settings section.
297
+ * @param {any} ctx - client context carrying slots/locale.
298
+ */
299
+ function apply(ctx) {
300
+ ctx.effect(() => ctx.locale.register(NS, {
301
+ zh: {
302
+ tab: "服务控制",
303
+ infoTitle: "服务信息",
304
+ copyDiag: "复制诊断信息",
305
+ copied: "已复制 ✓",
306
+ loading: "读取服务信息…",
307
+ infoPid: "进程 PID",
308
+ infoStartedAt: "启动时间",
309
+ infoUptime: "运行时长",
310
+ infoSessions: "活跃会话",
311
+ infoMemory: "内存占用 (RSS)",
312
+ infoCpu: "CPU 占用",
313
+ memTrend: "内存趋势",
314
+ infoNode: "Node 版本",
315
+ infoHostVersion: "DSH 宿主版本",
316
+ infoPluginVersion: "插件版本",
317
+ infoPlatform: "平台",
318
+ dayUnit: "天",
319
+ hourUnit: "小时",
320
+ minUnit: "分",
321
+ secUnit: "秒",
322
+ title: "终止服务",
323
+ desc: "优雅停止当前 DeepSeek Harness 宿主进程(SIGTERM,与终端 kill 相同):会话落盘、端口释放,不会留下后台孤儿进程。",
324
+ hint: "停止后需要重启:在终端运行 npx @deepseek-ai/dsh web,服务回归后本页自动刷新。",
325
+ stop: "终止服务",
326
+ stopping: "正在停止…",
327
+ confirm: "确定要终止当前 DeepSeek Harness 服务吗?所有会话将断开(已保存的会话不受影响)。",
328
+ confirmN: "确定要终止当前 DeepSeek Harness 服务吗?{n} 个活跃会话将断开(已保存的会话不受影响)。",
329
+ stopped: "服务已停止。\n\n重启方法:在终端运行 npx @deepseek-ai/dsh web。检测到服务重新启动后本页将自动刷新。"
330
+ },
331
+ en: {
332
+ tab: "Service",
333
+ infoTitle: "Service Info",
334
+ copyDiag: "Copy diagnostics",
335
+ copied: "Copied ✓",
336
+ loading: "Loading service info…",
337
+ infoPid: "PID",
338
+ infoStartedAt: "Started at",
339
+ infoUptime: "Uptime",
340
+ infoSessions: "Active sessions",
341
+ infoMemory: "Memory (RSS)",
342
+ infoCpu: "CPU",
343
+ memTrend: "Memory trend",
344
+ infoNode: "Node",
345
+ infoHostVersion: "DSH host",
346
+ infoPluginVersion: "Plugin",
347
+ infoPlatform: "Platform",
348
+ dayUnit: "d ",
349
+ hourUnit: "h ",
350
+ minUnit: "m ",
351
+ secUnit: "s",
352
+ title: "Stop Service",
353
+ desc: "Gracefully stops the current DeepSeek Harness host process (SIGTERM, same as a terminal kill): sessions flush and the port is released — no orphaned background process.",
354
+ hint: "To start it again, run npx @deepseek-ai/dsh web in a terminal; this page reloads automatically once it is back.",
355
+ stop: "Stop Service",
356
+ stopping: "Stopping…",
357
+ confirm: "Stop the current DeepSeek Harness service? All sessions disconnect (saved sessions are unaffected).",
358
+ confirmN: "Stop the current DeepSeek Harness service? {n} active sessions will disconnect (saved sessions are unaffected).",
359
+ stopped: "Service stopped.\n\nTo restart: run npx @deepseek-ai/dsh web in a terminal. This page reloads automatically once the service is back."
360
+ }
361
+ }), "stop-service: dictionaries");
362
+ const t = ctx.locale.bind(NS);
363
+ ctx.effect(() => ctx.slots.inject("settings.section", () => ctx.slots.register({
364
+ name: "settings.section",
365
+ id: "stop-service",
366
+ order: 50,
367
+ label: () => t("tab"),
368
+ locale: NS,
369
+ inject: () => ({ t })
370
+ }, StopServiceTab)), "stop-service: settings section");
371
+ }
372
+
373
+ exports.apply = apply;
374
+ exports.inject = inject;
375
+ return module.exports;
376
+ }
377
+ });
378
+
379
+ //# sourceMappingURL=client.js.map
package/lib/index.js ADDED
@@ -0,0 +1,249 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // The loopback request fence below (isIPv4Loopback / isLoopbackAddress /
4
+ // isLoopbackHostname / isLoopbackRequest) is adapted from
5
+ // zhu1090093659/dsh-web (dsh-client-ui-skill-explorer):
6
+ // Copyright (c) 2026, zhu1090093659 — BSD-3-Clause (full text in LICENSE)
7
+ // Modifications and all other code: Copyright (c) 2026 KhaosGx — MIT.
8
+
9
+ /**
10
+ * Host side of dsh-stop-service.
11
+ *
12
+ * Registers loopback-only routes on the DSH web host:
13
+ * GET /api/dsh-stop-service/health — liveness probe
14
+ * GET /api/dsh-stop-service/info — service/process/profile info
15
+ * POST /api/dsh-stop-service/stop — acknowledge, then SIGTERM this process
16
+ *
17
+ * SIGTERM is the same signal `kill <pid>` sends from a terminal, which the
18
+ * host already handles cleanly (session flush + socket release), so the
19
+ * browser gets its HTTP response before shutdown begins.
20
+ */
21
+ import { readFileSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { dirname, join, sep } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ const name = "stop-service";
27
+ const inject = ["webServer", "sessions"];
28
+
29
+ const ROUTES = {
30
+ health: "/api/dsh-stop-service/health",
31
+ info: "/api/dsh-stop-service/info",
32
+ stop: "/api/dsh-stop-service/stop",
33
+ };
34
+
35
+ /** IPv4 127/8 predicate (four decimal octets, first == 127). */
36
+ function isIPv4Loopback(v4) {
37
+ const parts = v4.split(".");
38
+ return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
39
+ }
40
+
41
+ /** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
42
+ function isLoopbackAddress(address) {
43
+ if (address === void 0) return false;
44
+ const normalized = address.toLowerCase();
45
+ if (normalized === "::1") return true;
46
+ if (normalized.startsWith("::ffff:")) return isIPv4Loopback(normalized.slice(7));
47
+ return isIPv4Loopback(normalized);
48
+ }
49
+
50
+ /** Whether a normalized URL hostname names the loopback authority. */
51
+ function isLoopbackHostname(hostname) {
52
+ if (hostname === "localhost" || hostname === "[::1]") return true;
53
+ return isIPv4Loopback(hostname);
54
+ }
55
+
56
+ /**
57
+ * Request-level trust fence: a loopback socket address AND a loopback Host
58
+ * header, plus browser same-origin markers. The socket address is
59
+ * authoritative; X-Forwarded-For is never trusted.
60
+ * @param {import("node:http").IncomingMessage} request
61
+ * @returns {boolean}
62
+ */
63
+ function isLoopbackRequest(request) {
64
+ if (!isLoopbackAddress(request.socket.remoteAddress)) return false;
65
+ const host = request.headers.host;
66
+ if (typeof host !== "string") return false;
67
+ let hostUrl;
68
+ try {
69
+ hostUrl = new URL("http://" + host);
70
+ } catch {
71
+ return false;
72
+ }
73
+ if (!isLoopbackHostname(hostUrl.hostname)) return false;
74
+ if (request.headers["sec-fetch-site"] === "cross-site") return false;
75
+ const origin = request.headers.origin;
76
+ if (origin === void 0) return true;
77
+ try {
78
+ return new URL(origin).host === hostUrl.host;
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ /** Write a JSON response and end it. */
85
+ function writeJson(res, status, body) {
86
+ const payload = JSON.stringify(body);
87
+ res.writeHead(status, {
88
+ "content-type": "application/json; charset=utf-8",
89
+ "cache-control": "no-store",
90
+ });
91
+ res.end(payload);
92
+ }
93
+
94
+ /** Guard helper: fence + method check. */
95
+ function guard(req, res, method) {
96
+ if (!isLoopbackRequest(req)) {
97
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
98
+ return false;
99
+ }
100
+ if (req.method !== method) {
101
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` });
102
+ return false;
103
+ }
104
+ return true;
105
+ }
106
+
107
+ /** Resolved dsh home directory (config > env > default ~/.dsh). */
108
+ function dshHomeOf(config) {
109
+ return config?.dshHome ?? process.env.DSH_HOME ?? homedir() + sep + ".dsh";
110
+ }
111
+
112
+ /**
113
+ * CPU usage percent (single-core basis) since the previous /info request,
114
+ * from process.cpuUsage deltas; null on the first call. Sampled naturally
115
+ * by the panel's polling cadence.
116
+ * @returns {number | null}
117
+ */
118
+ let lastCpuSample = null;
119
+ function cpuPercentSinceLast() {
120
+ const usage = process.cpuUsage();
121
+ const now = process.hrtime.bigint();
122
+ let percent = null;
123
+ if (lastCpuSample !== null) {
124
+ const cpuMs = (usage.user - lastCpuSample.user + usage.system - lastCpuSample.system) / 1000;
125
+ const wallMs = Number(now - lastCpuSample.time) / 1e6;
126
+ if (wallMs > 0) percent = Math.round((100 * cpuMs) / wallMs * 10) / 10;
127
+ }
128
+ lastCpuSample = { user: usage.user, system: usage.system, time: now };
129
+ return percent;
130
+ }
131
+
132
+ /**
133
+ * This plugin's own version, from the package manifest next to lib/.
134
+ * @returns {string | null}
135
+ */
136
+ function pluginVersion() {
137
+ try {
138
+ const libDir = dirname(fileURLToPath(import.meta.url));
139
+ const manifest = JSON.parse(readFileSync(join(dirname(libDir), "package.json"), "utf8"));
140
+ return manifest.name === "dsh-stop-service" ? manifest.version : null;
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Host version serving this profile, from the profile tree's pinned
148
+ * @deepseek-ai/dsh package manifest.
149
+ * @returns {string | null}
150
+ */
151
+ function hostVersion(config) {
152
+ try {
153
+ const manifestPath = join(dshHomeOf(config), "profiles", "node_modules", "@deepseek-ai", "dsh", "package.json");
154
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
155
+ return manifest.name === "@deepseek-ai/dsh" ? manifest.version : null;
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Profile composition info: bundles list and plugin dependencies from the
163
+ * web profile's package.json. Read-only; degrades to null.
164
+ */
165
+ function profileInfo(config) {
166
+ try {
167
+ const manifest = JSON.parse(readFileSync(join(dshHomeOf(config), "profiles", "web", "package.json"), "utf8"));
168
+ return {
169
+ name: "web",
170
+ bundles: manifest?.dsh?.profile?.bundles ?? [],
171
+ plugins: Object.keys(manifest?.dependencies ?? {}),
172
+ };
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Build the route list for ctx.webServer.register.
180
+ * @param {any} ctx - host plugin context carrying webServer/sessions.
181
+ * @param {{dshHome?: string}} [config]
182
+ * @returns {Array<{path: string, handler: (req: any, res: any) => Promise<void>}>}
183
+ */
184
+ function makeRoutes(ctx, config) {
185
+ /** Active session count; null when the registry is unavailable. */
186
+ const sessionCount = () => {
187
+ try {
188
+ return typeof ctx.sessions?.list === "function" ? ctx.sessions.list().length : null;
189
+ } catch {
190
+ return null;
191
+ }
192
+ };
193
+ return [
194
+ {
195
+ path: ROUTES.health,
196
+ handler: async (req, res) => {
197
+ if (!guard(req, res, "GET")) return;
198
+ writeJson(res, 200, { ok: true, plugin: name, pid: process.pid });
199
+ },
200
+ },
201
+ {
202
+ path: ROUTES.info,
203
+ handler: async (req, res) => {
204
+ if (!guard(req, res, "GET")) return;
205
+ writeJson(res, 200, {
206
+ ok: true,
207
+ pid: process.pid,
208
+ node: process.version,
209
+ startedAt: new Date(Date.now() - process.uptime() * 1000).toISOString(),
210
+ uptimeMs: Math.round(process.uptime() * 1000),
211
+ memoryRssMb: Math.round((process.memoryUsage().rss / 1048576) * 10) / 10,
212
+ cpuPercent: cpuPercentSinceLast(),
213
+ platform: `${process.platform} ${process.arch}`,
214
+ sessionCount: sessionCount(),
215
+ hostVersion: hostVersion(config),
216
+ pluginVersion: pluginVersion(),
217
+ profile: profileInfo(config),
218
+ });
219
+ },
220
+ },
221
+ {
222
+ path: ROUTES.stop,
223
+ handler: async (req, res) => {
224
+ if (!guard(req, res, "POST")) return;
225
+ writeJson(res, 200, { ok: true, stopping: true, pid: process.pid });
226
+ // Let the response flush, then shut down exactly like a terminal kill.
227
+ setTimeout(() => process.kill(process.pid, "SIGTERM"), 300);
228
+ },
229
+ },
230
+ ];
231
+ }
232
+
233
+ /**
234
+ * Mount the stop-service routes.
235
+ * @param {any} ctx - host plugin context carrying webServer.
236
+ * @param {{enabled?: boolean, dshHome?: string}} [config] - resolved plugin config.
237
+ */
238
+ function apply(ctx, config) {
239
+ if (config?.enabled === false) return;
240
+ const routes = makeRoutes(ctx, config);
241
+ ctx.effect(() => {
242
+ const disposers = routes.map((route) => ctx.webServer.register(route));
243
+ return () => {
244
+ for (const dispose of disposers) dispose();
245
+ };
246
+ }, "stop-service: routes");
247
+ }
248
+
249
+ export { ROUTES, apply, inject, name };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "dsh-stop-service",
3
+ "version": "0.3.0",
4
+ "type": "module",
5
+ "main": "lib/index.js",
6
+ "description": "A stop-service button for DeepSeek Harness Web: one confirmed click gracefully terminates the running dsh host (SIGTERM) — no orphaned background processes",
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/KhaosGx/dsh-stop-service.git"
11
+ },
12
+ "keywords": [
13
+ "dsh",
14
+ "deepseek-harness",
15
+ "dsh-plugin"
16
+ ],
17
+ "exports": {
18
+ ".": "./lib/index.js",
19
+ "./client": "./lib/client.js",
20
+ "./package.json": "./package.json"
21
+ },
22
+ "files": [
23
+ "lib",
24
+ "cordis.patch.yml",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "dsh": {
29
+ "engines": {
30
+ "dsh": ">=0.1.2-rc.1"
31
+ },
32
+ "bundle": {
33
+ "patch": "./cordis.patch.yml"
34
+ },
35
+ "client": {
36
+ "inject": [
37
+ "@deepseek-ai/dsh-client-connection",
38
+ "@deepseek-ai/dsh-client-runtime",
39
+ "@deepseek-ai/dsh-client-locale",
40
+ "@deepseek-ai/dsh-client-ui-settings",
41
+ "@deepseek-ai/dsh-client-ui-theme"
42
+ ],
43
+ "platform": "web"
44
+ }
45
+ }
46
+ }