@gehennawu/dsh-service 0.14.0 → 0.17.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/README.en.md CHANGED
@@ -8,30 +8,36 @@ A service-control and operations plugin for self-hosted DSH Web. Provides safe r
8
8
 
9
9
  ## Features
10
10
 
11
+ The Settings panel "Service Control" page has six top-level tabs: **Overview, Notifications, Health, Model stats, Backups, Restart**; the Restart tab can also enable a `Restart` quick entry at the bottom of the settings left navigation (off by default).
12
+
11
13
  ### Version and updates
12
14
 
13
15
  - Displays current DSH and plugin versions with links to GitHub Releases
14
16
  - Automatically checks npm registry for stable and preview releases; when a new version exists, the right-side status text (chevron + `New version`) is clickable to expand/collapse, showing the current/latest versions and both dist-tag versions inline, each with npmjs and npmmirror links (npmjs.com is blocked on some networks; npmmirror serves as the mirror entry)
15
- - One-click plugin upgrade with automatic restart after completion
17
+ - One-click plugin upgrade with automatic restart after completion; when no process manager is detected (for example, a manual launch from a Windows terminal), the upgrade first confirms the consequences, then keeps the process running and shows manual-restart instructions
16
18
 
17
19
  ### Safe restart
18
20
 
19
21
  - Detects active agents, background jobs, and terminals before restart; lists them and requires explicit confirmation
20
22
  - `/restart` command also available in conversations; automatically refuses when active work is detected
21
23
  - Automatically probes for the new process after restart and reloads the page; manual reload available after 60 seconds
24
+ - Overview shows the runtime environment; when a manual terminal launch is suspected, the confirmation flow warns that nothing will bring the process back
22
25
  - Optional `Restart` entry at the bottom of the settings left navigation, enabled by a switch in the Restart tab (off by default), sharing the exact same confirmation flow as the Restart tab
23
26
 
24
27
  ### Health diagnostics
25
28
 
26
29
  - Shows uptime, memory, session count, active agents, and background jobs
27
- - Full diagnostics check session storage, workspace registry, backup storage, tar availability, and file permissions
30
+ - The "Process and runtime" card shows platform, architecture, and Node version
31
+ - Full diagnostics check session storage, workspace registry, backup storage, tar availability, file permissions, runtime environment, and Node runtime version; a manual launch is flagged as a warning (no restart assurance), while an unrecognized environment is informational only (not a warning) — both can be declared explicitly via `DSH_SERVICE_RUNTIME_ENV`
32
+ - Having no backups is an informational note, not a warning, and does not light the Health tab ⚠
28
33
  - File-permission deep scan and repair: checks whether the Agent can read/write DSH_HOME and workspaces; repair requires two-step confirmation
29
34
 
30
35
  ### Model statistics
31
36
 
32
37
  - 7-day stacked bar chart of input/output/cache tokens with blue/orange/teal legend
33
38
  - Filter by project; hover for exact values
34
- - Model breakdown sorted by steps: `x times · Cache hit x% · Input xM token · Output xM token`
39
+ - Model breakdown as horizontal stacked bars (same legend colors), sorted by total tokens, largest first; each row keeps `x times · Cache hit x% · Input xM token · Output xM token`
40
+ - Steps whose provider reports no token usage are excluded from the statistics
35
41
  - Last-24-hour model/tool error statistics, collapsed by default
36
42
 
37
43
  ### Backup management
@@ -44,7 +50,7 @@ A service-control and operations plugin for self-hosted DSH Web. Provides safe r
44
50
 
45
51
  ### Task notifications
46
52
 
47
- - Browser notification when a session finishes its turn, or when your approval, plan review, or answer is needed
53
+ - Notification settings live in the top-level Notifications tab: browser notification when a session finishes its turn, or when your approval, plan review, or answer is needed
48
54
  - Clicking a notification focuses the DSH page and closes the popup
49
55
  - Three toggle switches: master switch, task completion, and approvals & questions, each controlled independently
50
56
  - Bell icon in the conversation input bar toggles the master switch quickly
@@ -87,6 +93,8 @@ dsh plugin --profile web add link:/path/to/dsh-service
87
93
 
88
94
  The plugin only sends an exit signal; it does not start the process again. Without a process manager, selecting restart stops DSH Web.
89
95
 
96
+ The plugin passively detects whether a process manager is present (environment variables, `/.dockerenv`, `/proc/1/cgroup`, terminal TTY): with Docker/systemd/pm2/supervisord/Kubernetes detected it restarts as usual; when nothing is detected and stdin/stdout are an interactive terminal, it treats the environment as a likely manual launch, labels the runtime environment in the overview, and switches one-click upgrade to keep the process running with manual-restart instructions. Heuristics cannot cover redirected output or wrappers like NSSM/WinSW; set `DSH_SERVICE_RUNTIME_ENV=managed|manual` to declare it explicitly.
97
+
90
98
  ### Docker Compose
91
99
 
92
100
  ```yaml
@@ -119,6 +127,8 @@ pm2 start "dsh web --host 127.0.0.1" --name dsh-web
119
127
  | macOS / Windows + pm2 or similar | Not blocked by the code | Managed externally | Not tested |
120
128
  | Direct `dsh web` execution | Supported | Not supported | Expected behavior |
121
129
 
130
+ When launched directly from a terminal (PowerShell/CMD/bash), the panel labels the environment as a likely manual launch and the one-click upgrade no longer exits the process automatically.
131
+
122
132
  Requirements: Node.js `>=22`, and a DSH Web installation capable of loading both Host and Client plugin halves. Update checks require access to `registry.npmjs.org`; network failures do not affect other features.
123
133
 
124
134
  ## Security design
package/README.md CHANGED
@@ -8,30 +8,36 @@
8
8
 
9
9
  ## 功能
10
10
 
11
+ 设置页「服务控制」面板包含六个顶部标签:**概览、通知、健康诊断、模型统计、备份维护、重启**;重启标签还可在设置页左侧标签列底部开启「重启」快捷入口(默认关闭)。
12
+
11
13
  ### 版本与更新
12
14
 
13
15
  - 显示当前 DSH 和插件版本,版本号链接到 GitHub Releases
14
16
  - 自动检查 npm registry 的正式版和预览版;有新版本时右侧「小三角 + 有新版本」状态文本可点击展开/收起,行内展示当前/最新版本与正式版/预览版双 tag,版本号分别带 npmjs 与 npmmirror 链接(部分网络会被 npmjs.com 拦截,npmmirror 作为镜像入口)
15
- - 一键升级插件,升级后自动重启
17
+ - 一键升级插件,升级后自动重启;未检测到进程管理器(如在 Windows 终端手动启动)时,升级前先确认后果,安装完成后保持运行并提示手动重启
16
18
 
17
19
  ### 安全重启
18
20
 
19
21
  - 重启前检测活跃 Agent、后台任务和终端,展示清单并要求显式确认
20
22
  - 对话中输入 `/restart` 也可触发,检测到运行中工作时自动拒绝
21
23
  - 重启后自动探测新进程并刷新页面,60 秒未恢复时提供手动刷新
24
+ - 概览显示「运行环境」;疑似终端手动启动时,确认流程会提示「退出后不会自动拉起」
22
25
  - 可在「重启」标签开启「设置页左列显示入口」开关(默认关闭),开启后在设置页左侧标签列底部显示「重启」快捷入口,与「重启」标签共用同一套确认流程
23
26
 
24
27
  ### 健康诊断
25
28
 
26
29
  - 显示运行时间、内存、会话数、活跃 Agent 和后台任务
27
- - 完整诊断检查会话存储、工作区注册表、备份目录、tar 可用性和文件权限
30
+ - 「进程与运行环境」卡显示平台、架构和 Node 版本
31
+ - 完整诊断检查会话存储、工作区注册表、备份目录、tar 可用性、文件权限、运行环境和 Node 运行时版本;手动启动环境按警告提示重启无保障,未识别环境为信息级提示(不算警告),均可通过 `DSH_SERVICE_RUNTIME_ENV` 显式声明
32
+ - 没有备份属于信息级提示,不算警告,也不点亮健康诊断标签的 ⚠
28
33
  - 文件权限深检与修复:检查 Agent 是否能读写 DSH_HOME 和工作区,修复需两段式确认
29
34
 
30
35
  ### 模型统计
31
36
 
32
37
  - 近 7 天输入/输出/缓存 token 堆叠柱图,蓝/橙/青图例
33
38
  - 按项目筛选,鼠标悬停显示精确数值
34
- - 模型明细按步骤数降序,格式为「x次 · 缓存命中 x% · 输入 xM token · 输出 xM token
39
+ - 模型明细为横向堆叠柱形图(沿用主图图例配色),按 token 总量从多到少排序;每行附「x次 · 缓存命中 x% · 输入 xM token · 输出 xM token」明细
40
+ - 提供方未上报 token 用量的模型步骤不纳入统计
35
41
  - 最近 24 小时模型/工具报错统计,默认折叠
36
42
 
37
43
  ### 备份管理
@@ -44,7 +50,7 @@
44
50
 
45
51
  ### 任务通知
46
52
 
47
- - 会话完成一轮任务、或需要你授权/审阅计划/选择答案时发送浏览器通知
53
+ - 通知设置位于服务控制顶部的「通知」标签:会话完成一轮任务、或需要你授权/审阅计划/选择答案时发送浏览器通知
48
54
  - 点击系统通知弹窗会聚焦到 DSH 页面并关闭该通知
49
55
  - 三档开关条:总开关、任务结束通知、授权与提问通知,各自独立控制
50
56
  - 对话栏内铃铛图标快速切换总开关
@@ -87,6 +93,8 @@ dsh plugin --profile web add link:/path/to/dsh-service
87
93
 
88
94
  插件只发送退出信号,不负责重新启动进程。没有进程管理器时,点击重启会直接停止 DSH Web。
89
95
 
96
+ 插件会用被动信号(环境变量、`/.dockerenv`、`/proc/1/cgroup`、终端 TTY)判断当前是否由进程管理器拉起:检测到 Docker/systemd/pm2/supervisord/Kubernetes 时照常自动重启;都没有且 stdin/stdout 是交互终端时视为「疑似手动启动」,概览会标注运行环境,一键升级改为「不自动退出 + 提示手动重启」。启发式无法覆盖输出重定向、NSSM/WinSW 等场景,可用环境变量 `DSH_SERVICE_RUNTIME_ENV=managed|manual` 显式声明。
97
+
90
98
  ### Docker Compose
91
99
 
92
100
  ```yaml
@@ -119,6 +127,8 @@ pm2 start "dsh web --host 127.0.0.1" --name dsh-web
119
127
  | macOS / Windows + pm2 等 | 代码未限制 | 由进程管理器负责 | 未验证 |
120
128
  | 直接运行 `dsh web` | 支持 | 不支持 | 预期行为 |
121
129
 
130
+ 直接在终端运行(PowerShell/CMD/bash)时,面板会标注「疑似终端手动启动」,一键升级不再自动退出进程。
131
+
122
132
  运行要求:Node.js `>=22`,DSH Web 能加载 Host 和 Client 两半插件。检查更新需要访问 `registry.npmjs.org`;网络失败不影响其他功能。
123
133
 
124
134
  ## 安全设计
package/client.js CHANGED
@@ -20,6 +20,8 @@ window.__ModuleLoader__.load({
20
20
  'health.title': '运行状况',
21
21
  'health.uptime': '运行时间',
22
22
  'health.rss': '内存 RSS',
23
+ 'health.platform': '平台',
24
+ 'health.nodeVersion': 'Node 版本',
23
25
  'health.liveSessions': '存活会话',
24
26
  'health.persistedSessions': '持久化会话',
25
27
  'health.activeAgents': '活跃 Agent',
@@ -43,6 +45,14 @@ window.__ModuleLoader__.load({
43
45
  'health.check.backup-storage': '备份存储',
44
46
  'health.check.tar': 'tar',
45
47
  'health.check.permissions': '文件权限',
48
+ 'health.check.runtime-env': '运行环境',
49
+ 'health.check.node-version': 'Node 运行时',
50
+ 'health.detail.runtime-env.managed': '由{kind}管理,重启后会自动拉起',
51
+ 'health.detail.runtime-env.declared': '已通过 DSH_SERVICE_RUNTIME_ENV 声明由外部进程管理器管理',
52
+ 'health.detail.runtime-env.manual': '疑似终端手动启动,重启后不会自动拉起;一键升级已改为不自动退出',
53
+ 'health.detail.runtime-env.unknown': '未检测到进程管理器,无法确认重启后是否自动拉起;如实际有,可设置 DSH_SERVICE_RUNTIME_ENV=managed 声明',
54
+ 'health.detail.node-version.ok': '{version},满足 ≥{required} 要求',
55
+ 'health.detail.node-version.warning': '{version} 低于插件要求的 {required}.x,建议升级 Node',
46
56
  'health.detail.session-storage.ok': '可用,共 {count} 个会话快照',
47
57
  'health.detail.workspace-registry.ok': '可用,共 {count} 个工作区',
48
58
  'health.detail.dsh-home.ok': '目录可访问,权限模式 {mode}',
@@ -56,7 +66,7 @@ window.__ModuleLoader__.load({
56
66
  'tabs.notify': '通知',
57
67
  'tabs.health': '健康诊断',
58
68
  'tabs.usage': '模型统计',
59
- 'overview.container': '容器信息',
69
+ 'overview.container': '进程与运行环境',
60
70
  'overview.errors': '报错信息',
61
71
  'tabs.backup': '备份维护',
62
72
  'tabs.restart': '重启',
@@ -141,6 +151,22 @@ window.__ModuleLoader__.load({
141
151
  'update.failAddingToRoot': 'pnpm 拒绝在 workspace 根目录安装(缺少 -w)',
142
152
  'update.failNotWorkspace': 'profile 不是 pnpm workspace 却传入了 -w',
143
153
  'update.failIgnoredBuilds': '依赖构建脚本被 pnpm 默认拦截,无法完成升级',
154
+ 'env.line': '运行环境:{value}',
155
+ 'env.kind.docker': 'Docker 容器',
156
+ 'env.kind.container': '容器(containerd/Kubernetes)',
157
+ 'env.kind.systemd': 'systemd 服务',
158
+ 'env.kind.pm2': 'pm2',
159
+ 'env.kind.supervisord': 'supervisord',
160
+ 'env.kind.kubernetes': 'Kubernetes',
161
+ 'env.kind.declared': '外部进程管理器(手动指定)',
162
+ 'env.kind.unrecognized': '检测到未识别的进程管理器',
163
+ 'env.likelyManual': '疑似终端手动启动',
164
+ 'env.unknown': '未检测到进程管理器',
165
+ 'update.manualConfirmTitle': '升级前请确认:当前疑似手动启动环境',
166
+ 'update.manualConfirm': '未检测到 Docker/systemd/pm2 等进程管理器。升级完成后 DSH 不会自动重启——新版本写入磁盘,但当前进程继续运行旧版本;你需要手动关闭并重新运行 dsh 才能启用新版本。',
167
+ 'update.manualProceed': '仍要升级',
168
+ 'update.manualRestartTitle': '升级完成,需要手动重启',
169
+ 'update.manualRestartBody': '新版本已安装,但当前进程仍在运行旧版本。请在运行 dsh 的终端窗口按 Ctrl+C(或直接关闭窗口),然后重新启动 dsh。',
144
170
  'restart.title': '服务重启',
145
171
  'restart.description': '重启 dsh web 进程。运行中的工作会中断,持久化会话可恢复。也可在对话中输入 /restart。',
146
172
  'restart.button': '重启 dsh web',
@@ -151,6 +177,8 @@ window.__ModuleLoader__.load({
151
177
  'restart.sent': '重启指令已发出。',
152
178
  'restart.sentHint': '页面连接即将断开,服务恢复后将自动刷新。',
153
179
  'restart.idleHint': '当前没有检测到运行中的工作。确认后将断开连接,等待服务自动重启。',
180
+ 'restart.manualWarn': '当前疑似终端手动启动环境:确认后进程将退出且不会自动拉起,需要你手动重新运行 dsh。',
181
+ 'restart.sentManualHint': '当前为手动启动环境,服务不会自动拉起。请在原终端重新运行 dsh;服务起来后刷新此页面即可恢复。',
154
182
  'restart.navToggle': '设置页左列显示「重启」入口',
155
183
  'restart.navToggleHint': '默认关闭;开启后在设置页左侧标签列底部显示快捷重启入口',
156
184
  'activity.agent': 'Agent',
@@ -172,11 +200,14 @@ window.__ModuleLoader__.load({
172
200
  'usage.axis': 'token 纵轴',
173
201
  'usage.models.more': '展开其余 {count} 个模型',
174
202
  'usage.models.less': '收起模型列表',
203
+ 'usage.modelSortHint': '按 token 总量从多到少排列',
204
+ 'usage.modelBar': '{model}:共 {total} token',
175
205
  'usage.refresh': '刷新统计',
176
206
  'usage.refreshing': '刷新中…',
177
207
  'usage.empty': '尚未建立使用统计索引。点击刷新统计开始只读建立索引。',
178
208
  'usage.error': '无法读取模型使用统计',
179
209
  'usage.allProjects': '全部项目',
210
+ 'usage.total': 'token 总量',
180
211
  'usage.steps': '成功模型步骤',
181
212
  'usage.stepsValue': '{count} 次',
182
213
  'usage.modelLine': '{steps}次 · 缓存命中 {hitRate} · 输入 {input} token · 输出 {output} token',
@@ -186,7 +217,6 @@ window.__ModuleLoader__.load({
186
217
  'usage.hitRate': '缓存命中率',
187
218
  'usage.today': '今天',
188
219
  'usage.sevenDays': '近 7 天',
189
- 'usage.missing': '{count} 个步骤没有 token 数据',
190
220
  'usage.errors.title': '模型报错',
191
221
  'usage.errors.toggle': '模型报错({count} 类)',
192
222
  'usage.errors.recent': '最近 24 小时',
@@ -196,17 +226,14 @@ window.__ModuleLoader__.load({
196
226
  'usage.toolErrors.empty': '最近 24 小时没有记录到工具报错。',
197
227
  'usage.errors.count': '{count} 次',
198
228
  'notification.title': '通知',
199
- 'notification.description': '任务结束或需要你授权、选择答案时发送浏览器通知。需要授权浏览器通知权限;各开关在页面刷新后保持。',
229
+ 'notification.description': '任务结束或需要你授权、抉择时发送浏览器通知。需要授权浏览器通知权限。',
200
230
  'notification.enable': '开启通知',
201
231
  'notification.enabled': '通知已开启',
202
232
  'notification.disable': '关闭通知',
203
233
  'notification.denied': '通知权限被拒绝',
204
234
  'notification.master': '通知总开关',
205
- 'notification.masterHint': '关闭时下面两个开关暂停生效',
206
235
  'notification.done': '任务结束通知',
207
- 'notification.doneHint': '会话完成一轮任务时提醒',
208
236
  'notification.input': '授权与提问通知',
209
- 'notification.inputHint': '需要授权、审阅计划或选择答案时提醒',
210
237
  'notification.doneTitle': '任务完成',
211
238
  'notification.doneBody': '{title} 已完成本轮任务',
212
239
  'notification.inputTitle': '需要你的确认',
@@ -229,6 +256,8 @@ window.__ModuleLoader__.load({
229
256
  'health.title': 'Health',
230
257
  'health.uptime': 'Uptime',
231
258
  'health.rss': 'Memory RSS',
259
+ 'health.platform': 'Platform',
260
+ 'health.nodeVersion': 'Node version',
232
261
  'health.liveSessions': 'Live sessions',
233
262
  'health.persistedSessions': 'Persisted sessions',
234
263
  'health.activeAgents': 'Active agents',
@@ -252,6 +281,14 @@ window.__ModuleLoader__.load({
252
281
  'health.check.backup-storage': 'Backup storage',
253
282
  'health.check.tar': 'tar',
254
283
  'health.check.permissions': 'File permissions',
284
+ 'health.check.runtime-env': 'Runtime environment',
285
+ 'health.check.node-version': 'Node runtime',
286
+ 'health.detail.runtime-env.managed': 'Managed by {kind}; the process restarts automatically after exit',
287
+ 'health.detail.runtime-env.declared': 'Declared externally managed via DSH_SERVICE_RUNTIME_ENV',
288
+ 'health.detail.runtime-env.manual': 'Likely a manual terminal launch — nothing restarts the process after exit; one-click upgrade already keeps it running',
289
+ 'health.detail.runtime-env.unknown': 'No process manager detected, so automatic restart cannot be confirmed; set DSH_SERVICE_RUNTIME_ENV=managed if one exists',
290
+ 'health.detail.node-version.ok': '{version}, meets the >={required} requirement',
291
+ 'health.detail.node-version.warning': '{version} is below the required {required}.x; upgrading Node is recommended',
255
292
  'health.detail.session-storage.ok': 'Available, with {count} session snapshots',
256
293
  'health.detail.workspace-registry.ok': 'Available, with {count} workspaces',
257
294
  'health.detail.dsh-home.ok': 'Directory is accessible with mode {mode}',
@@ -265,7 +302,7 @@ window.__ModuleLoader__.load({
265
302
  'tabs.notify': 'Notifications',
266
303
  'tabs.health': 'Health',
267
304
  'tabs.usage': 'Models',
268
- 'overview.container': 'Container info',
305
+ 'overview.container': 'Process and runtime',
269
306
  'overview.errors': 'Errors',
270
307
  'tabs.backup': 'Backup',
271
308
  'tabs.restart': 'Restart',
@@ -350,6 +387,22 @@ window.__ModuleLoader__.load({
350
387
  'update.failAddingToRoot': 'pnpm refused to add at the workspace root (missing -w)',
351
388
  'update.failNotWorkspace': '-w was passed but the profile is not a pnpm workspace',
352
389
  'update.failIgnoredBuilds': 'Dependency build scripts are blocked by pnpm by default, so the upgrade could not finish',
390
+ 'env.line': 'Runtime environment: {value}',
391
+ 'env.kind.docker': 'Docker container',
392
+ 'env.kind.container': 'Container (containerd/Kubernetes)',
393
+ 'env.kind.systemd': 'systemd service',
394
+ 'env.kind.pm2': 'pm2',
395
+ 'env.kind.supervisord': 'supervisord',
396
+ 'env.kind.kubernetes': 'Kubernetes',
397
+ 'env.kind.declared': 'External process manager (declared manually)',
398
+ 'env.kind.unrecognized': 'Unrecognized process manager detected',
399
+ 'env.likelyManual': 'Likely launched manually from a terminal',
400
+ 'env.unknown': 'No process manager detected',
401
+ 'update.manualConfirmTitle': 'Confirm before upgrading: manual launch suspected',
402
+ 'update.manualConfirm': 'No process manager such as Docker/systemd/pm2 was detected. After the upgrade DSH will NOT restart automatically — the new version is written to disk while the current process keeps running the old one; close and rerun dsh manually to activate it.',
403
+ 'update.manualProceed': 'Upgrade anyway',
404
+ 'update.manualRestartTitle': 'Upgrade finished — manual restart required',
405
+ 'update.manualRestartBody': 'The new version is installed, but the current process still runs the old one. Press Ctrl+C in the terminal running dsh (or simply close the window), then start dsh again.',
353
406
  'restart.title': 'Service restart',
354
407
  'restart.description': 'Restart the dsh web process. Active work will be interrupted; persisted sessions can be resumed. You can also type /restart in a conversation.',
355
408
  'restart.button': 'Restart dsh web',
@@ -360,6 +413,8 @@ window.__ModuleLoader__.load({
360
413
  'restart.sent': 'Restart request sent.',
361
414
  'restart.sentHint': 'The connection will close shortly. The page will reload automatically after recovery.',
362
415
  'restart.idleHint': 'No active work was detected. Confirm to disconnect and wait for the service to restart.',
416
+ 'restart.manualWarn': 'This looks like a manual terminal launch: once confirmed the process exits and nothing restarts it — you must run dsh again yourself.',
417
+ 'restart.sentManualHint': 'This is a manual-launch environment, so the service will not come back on its own. Run dsh again in the original terminal; refresh this page once the service is up.',
363
418
  'restart.navToggle': 'Show "Restart" entry in settings left nav',
364
419
  'restart.navToggleHint': 'Off by default; when enabled, a quick-restart entry appears at the bottom of the settings left navigation',
365
420
  'activity.agent': 'Agent',
@@ -381,11 +436,14 @@ window.__ModuleLoader__.load({
381
436
  'usage.axis': 'token vertical axis',
382
437
  'usage.models.more': 'Show {count} more models',
383
438
  'usage.models.less': 'Collapse model list',
439
+ 'usage.modelSortHint': 'Sorted by total tokens, largest first',
440
+ 'usage.modelBar': '{model}: {total} tokens in total',
384
441
  'usage.refresh': 'Refresh usage',
385
442
  'usage.refreshing': 'Refreshing…',
386
443
  'usage.empty': 'No usage index yet. Select Refresh usage to build the read-only index.',
387
444
  'usage.error': 'Could not read model usage',
388
445
  'usage.allProjects': 'All projects',
446
+ 'usage.total': 'Total tokens',
389
447
  'usage.steps': 'Successful model steps',
390
448
  'usage.stepsValue': '{count} times',
391
449
  'usage.modelLine': '{steps} times · Cache hit {hitRate} · Input {input} token · Output {output} token',
@@ -395,7 +453,6 @@ window.__ModuleLoader__.load({
395
453
  'usage.hitRate': 'Cache hit rate',
396
454
  'usage.today': 'Today',
397
455
  'usage.sevenDays': 'Last 7 days',
398
- 'usage.missing': '{count} step(s) have no token data',
399
456
  'usage.errors.title': 'Model errors',
400
457
  'usage.errors.toggle': 'Model errors ({count} types)',
401
458
  'usage.errors.recent': 'Last 24 hours',
@@ -405,17 +462,14 @@ window.__ModuleLoader__.load({
405
462
  'usage.toolErrors.empty': 'No tool errors were recorded in the last 24 hours.',
406
463
  'usage.errors.count': '{count} occurrence(s)',
407
464
  'notification.title': 'Notifications',
408
- 'notification.description': 'Send a browser notification when a task finishes or when your approval or answer is needed. Requires browser notification permission; the toggles persist across page reloads.',
465
+ 'notification.description': 'Send a browser notification when a task finishes or when your approval or decision is needed. Requires browser notification permission.',
409
466
  'notification.enable': 'Enable notifications',
410
467
  'notification.enabled': 'Notifications enabled',
411
468
  'notification.disable': 'Disable notifications',
412
469
  'notification.denied': 'Notification permission denied',
413
470
  'notification.master': 'Master switch',
414
- 'notification.masterHint': 'Both kinds below stay paused while this is off',
415
471
  'notification.done': 'Task completion',
416
- 'notification.doneHint': 'Notify when a session finishes its turn',
417
472
  'notification.input': 'Approvals & questions',
418
- 'notification.inputHint': 'Notify when approval, plan review, or an answer is requested',
419
473
  'notification.doneTitle': 'Task complete',
420
474
  'notification.doneBody': '{title} has finished its turn',
421
475
  'notification.inputTitle': 'Your attention needed',
@@ -609,6 +663,53 @@ window.__ModuleLoader__.load({
609
663
  }, [])
610
664
  return snapshot
611
665
  }
666
+
667
+ // 运行环境(version RPC 随 instanceId 返回):概览展示、升级前置确认与重启警告共用。
668
+ // null = 宿主未返回该字段(旧版本)或尚未拉取,一切行为静默退回现状。
669
+ const runtimeEnvListeners = new Set()
670
+ let runtimeEnvState = null
671
+ const setRuntimeEnvState = (next) => {
672
+ runtimeEnvState = next
673
+ for (const listener of runtimeEnvListeners) listener(next)
674
+ }
675
+ const useRuntimeEnv = () => {
676
+ const [snapshot, setSnapshot] = useState(runtimeEnvState)
677
+ useEffect(() => {
678
+ runtimeEnvListeners.add(setSnapshot)
679
+ setSnapshot(runtimeEnvState)
680
+ return () => runtimeEnvListeners.delete(setSnapshot)
681
+ }, [])
682
+ return snapshot
683
+ }
684
+ // 宿主字段按形状校验后才入库:manualStartLikely 必须 boolean、supervisorKind 缺省或 string,
685
+ // 形状不对按「旧宿主无该字段」降级,绝不让坏值悄悄关掉确认门。
686
+ const applyVersionRuntimeEnv = (value) => {
687
+ const env = value ? value.runtimeEnv : undefined
688
+ if (env === null || typeof env !== 'object') return
689
+ if (typeof env.manualStartLikely !== 'boolean') return
690
+ if (env.supervisorKind !== undefined && env.supervisorKind !== null && typeof env.supervisorKind !== 'string') return
691
+ setRuntimeEnvState({ platform: typeof env.platform === 'string' ? env.platform : '', supervisorKind: env.supervisorKind === undefined || env.supervisorKind === null ? null : env.supervisorKind, manualStartLikely: env.manualStartLikely })
692
+ }
693
+ // version 快照全插件只取一次:ServicePanel 与左列入口无论谁先挂载都共享同一请求,
694
+ // 升级前取 instanceId/运行环境也复用它。失败清缓存,下一个消费者重试。
695
+ let versionSnapshotPromise = null
696
+ const fetchVersionSnapshot = () => {
697
+ if (versionSnapshotPromise === null) {
698
+ versionSnapshotPromise = ctx.connection.rpc.call('/dsh-service', 'version', {})
699
+ .then((res) => {
700
+ if (res && res.ok) applyVersionRuntimeEnv(res.value)
701
+ return res
702
+ })
703
+ .catch(() => {
704
+ versionSnapshotPromise = null
705
+ return null
706
+ })
707
+ }
708
+ return versionSnapshotPromise
709
+ }
710
+ // 升级执行中标志放在 factory 作用域:闭包状态挡不住同一 tick 的重入,跨渲染的新闭包
711
+ // 也各自持有独立的 false,只有插件级可变标志能同时覆盖两种情况。
712
+ let upgradeInFlight = false
612
713
  const checkRestart = async () => {
613
714
  setRestartFlow({ ...restartFlow, busy: true, error: null })
614
715
  try {
@@ -694,6 +795,7 @@ window.__ModuleLoader__.load({
694
795
  recoveryGeneration += 1
695
796
  recoveryListeners.clear()
696
797
  restartFlowListeners.clear()
798
+ runtimeEnvListeners.clear()
697
799
  updateListeners.clear()
698
800
  }, 'dsh-service recovery')
699
801
 
@@ -799,6 +901,11 @@ window.__ModuleLoader__.load({
799
901
  function RestartSection({ showNavToggle }) {
800
902
  const translate = useTranslation()
801
903
  const flow = useRestartFlow()
904
+ const runtimeEnv = useRuntimeEnv()
905
+ // 左列专属入口可能先于「服务控制」面板挂载:走共享 version 快照(与面板同一次请求)。
906
+ useEffect(() => {
907
+ fetchVersionSnapshot()
908
+ }, [])
802
909
  const [navEnabled, setNavEnabled] = useRestartNavEnabled()
803
910
  const btn = { minHeight: '32px', padding: '6px 14px', borderRadius: '7px', border: '1px solid transparent', cursor: 'pointer', fontSize: '13px', fontWeight: 550, transition: 'border-color 120ms ease, color 120ms ease, background 120ms ease', lineHeight: '20px' }
804
911
  const danger = { ...btn, background: 'var(--dsw-alias-state-error-primary)', color: '#fff', borderColor: 'var(--dsw-alias-state-error-primary)' }
@@ -809,12 +916,12 @@ window.__ModuleLoader__.load({
809
916
  const displaySurface = { background: 'var(--dsh-svc-surface-bg)', color: 'var(--dsw-alias-label-primary)', border: '1px solid var(--dsw-alias-border-l1)', borderRadius: '8px', padding: '10px' }
810
917
  const sectionTitle = { fontSize: '14px', fontWeight: 700, margin: '0 0 8px', color: 'var(--dsw-alias-label-primary)' }
811
918
 
812
- // 重启后提示
919
+ // 重启后提示:手动启动环境不会自动拉起,等待文案换成手动指引。
813
920
  if (flow.stage === 2) {
814
921
  return React.createElement('div', { 'data-testid': 'restart-card', style: card },
815
922
  React.createElement('div', { style: sectionTitle }, translate('restart.title')),
816
923
  React.createElement('p', { style: { margin: 0, fontSize: '13px' } }, translate('restart.sent')),
817
- React.createElement('p', { style: hint }, translate('restart.sentHint')))
924
+ React.createElement('p', { style: hint }, translate(runtimeEnv !== null && runtimeEnv.manualStartLikely === true ? 'restart.sentManualHint' : 'restart.sentHint')))
818
925
  }
819
926
 
820
927
  const activityLabels = {
@@ -823,6 +930,10 @@ window.__ModuleLoader__.load({
823
930
  terminal: translate('activity.terminal'),
824
931
  }
825
932
  const activityItems = flow.activity && Array.isArray(flow.activity.items) ? flow.activity.items : []
933
+ // 疑似终端手动启动:确认前就把「退出后无人拉起」讲清楚,两段式确认的后果清单。
934
+ const manualWarn = runtimeEnv !== null && runtimeEnv.manualStartLikely === true
935
+ ? React.createElement('p', { 'data-testid': 'restart-manual-warn', style: Object.assign({}, hint, { color: 'var(--dsw-alias-state-warn-primary)', margin: '6px 0 0' }) }, translate('restart.manualWarn'))
936
+ : null
826
937
  const activityWarning = flow.stage === 3
827
938
  ? React.createElement('div', { style: { marginTop: '12px', padding: '10px 12px', borderRadius: '6px', background: 'rgba(211,51,51,0.1)', border: '1px solid rgba(211,51,51,0.35)' } },
828
939
  React.createElement('p', { style: { margin: '0 0 8px', color: 'var(--dsw-alias-state-error-primary)', fontSize: '13px', fontWeight: 600 } },
@@ -842,6 +953,7 @@ window.__ModuleLoader__.load({
842
953
  React.createElement('div', { style: sectionTitle }, translate('restart.title')),
843
954
  React.createElement('div', { 'data-testid': 'restart-region', style: displaySurface },
844
955
  React.createElement('p', { style: { margin: 0, fontSize: '13px' } }, translate('restart.description')),
956
+ manualWarn,
845
957
  activityWarning,
846
958
  React.createElement('div', { style: row },
847
959
  flow.stage === 0
@@ -908,6 +1020,9 @@ window.__ModuleLoader__.load({
908
1020
  const [usageError, setUsageError] = useState(null)
909
1021
  const [upgradeBusy, setUpgradeBusy] = useState(false)
910
1022
  const [upgradeError, setUpgradeError] = useState(null)
1023
+ // 疑似手动启动环境的升级两段式:确认后果 → 仍要升级;成功后不自动退出,改示指引。
1024
+ const [upgradeManualConfirm, setUpgradeManualConfirm] = useState(false)
1025
+ const [upgradeManualPending, setUpgradeManualPending] = useState(false)
911
1026
  const [hoveredUsageSegment, setHoveredUsageSegment] = useState(null)
912
1027
  const [usageProject, setUsageProject] = useState('all')
913
1028
  const [modelErrorsOpen, setModelErrorsOpen] = useState(false)
@@ -918,16 +1033,18 @@ window.__ModuleLoader__.load({
918
1033
  const [channelOpen, setChannelOpen] = useState(false)
919
1034
  // 重启流程状态来自共享流(与设置页左列底部的专属入口同源)
920
1035
  const restartFlowState = useRestartFlow()
1036
+ const runtimeEnv = useRuntimeEnv()
921
1037
  const usageRequestPayload = { timezoneOffsetMinutes: new Date().getTimezoneOffset() }
922
1038
 
923
1039
  // 进入面板时拉取当前版本和健康快照;健康数据每 5 秒刷新,卸载即停止。
1040
+ // version 走全插件共享的缓存快照:无论哪个挂载点先到,都只有一次请求。
924
1041
  useEffect(() => {
925
- ctx.connection.rpc.call('/dsh-service', 'version', {}).then((res) => {
1042
+ fetchVersionSnapshot().then((res) => {
926
1043
  if (res && res.ok) {
927
1044
  setVersion(res.value.current)
928
1045
  setPluginVersion(res.value.pluginVersion || null)
929
1046
  }
930
- }).catch(() => {})
1047
+ })
931
1048
  }, [])
932
1049
  useEffect(() => {
933
1050
  let active = true
@@ -1175,10 +1292,25 @@ window.__ModuleLoader__.load({
1175
1292
  'ignored-builds': 'update.failIgnoredBuilds',
1176
1293
  }
1177
1294
  const upgradePlugin = async () => {
1178
- setUpgradeBusy(true)
1179
- setUpgradeError(null)
1295
+ if (upgradeInFlight) return
1296
+ upgradeInFlight = true
1180
1297
  try {
1181
- const versionRes = await ctx.connection.rpc.call('/dsh-service', 'version', {})
1298
+ // check-update 先于 version 返回时按钮可能先出现:env 未知就等共享快照落地再判。
1299
+ let env = runtimeEnv
1300
+ if (env === null) {
1301
+ await fetchVersionSnapshot()
1302
+ env = runtimeEnvState
1303
+ }
1304
+ // 疑似终端手动启动:升级成功不会自动重启,先两段式确认后果再动手(安全教义)。
1305
+ if (!upgradeManualConfirm && env !== null && env.manualStartLikely === true) {
1306
+ setUpgradeError(null)
1307
+ setUpgradeManualConfirm(true)
1308
+ return
1309
+ }
1310
+ setUpgradeManualConfirm(false)
1311
+ setUpgradeBusy(true)
1312
+ setUpgradeError(null)
1313
+ const versionRes = await fetchVersionSnapshot()
1182
1314
  const previousInstanceId = versionRes && versionRes.ok ? versionRes.value.instanceId : undefined
1183
1315
  const res = await ctx.connection.rpc.call('/dsh-service', 'upgrade', {})
1184
1316
  if (!res || res.ok === false) {
@@ -1192,6 +1324,11 @@ window.__ModuleLoader__.load({
1192
1324
  }
1193
1325
  return
1194
1326
  }
1327
+ if (res.value && res.value.requiresManualRestart === true) {
1328
+ // 宿主保持运行(没有 exit,就不会有新实例):不启动恢复轮询,改示手动重启指引。
1329
+ setUpgradeManualPending(true)
1330
+ return
1331
+ }
1195
1332
  if (typeof previousInstanceId === 'string' && previousInstanceId.length > 0) {
1196
1333
  startRecovery(previousInstanceId).catch(() => {})
1197
1334
  }
@@ -1200,6 +1337,7 @@ window.__ModuleLoader__.load({
1200
1337
  console.error('dsh-service: upgrade failed', detail || err)
1201
1338
  setUpgradeError(detail ? translate('update.upgradeErrorDetail', { detail }) : translate('update.upgradeError'))
1202
1339
  } finally {
1340
+ upgradeInFlight = false
1203
1341
  setUpgradeBusy(false)
1204
1342
  }
1205
1343
  }
@@ -1264,15 +1402,19 @@ window.__ModuleLoader__.load({
1264
1402
  ]
1265
1403
  const usageTotalsFor = (day) => {
1266
1404
  const source = usage && usage.days ? usage.days[day] : null
1267
- if (!source) return { steps: 0, missingUsage: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cacheHitRate: 0 }
1405
+ if (!source) return { steps: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cacheHitRate: 0 }
1268
1406
  if (usageProject === 'all') return source.totals
1269
1407
  const project = source.projects.find((item) => item.id === usageProject)
1270
- return project ? project.totals : { steps: 0, missingUsage: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cacheHitRate: 0 }
1408
+ return project ? project.totals : { steps: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cacheHitRate: 0 }
1271
1409
  }
1272
1410
  const modelCacheHitRate = (model) => {
1273
1411
  const denominator = Number(model.inputTokens || 0) + Number(model.cacheReadTokens || 0) + Number(model.cacheWriteTokens || 0)
1274
1412
  return denominator === 0 ? 0 : Number(model.cacheReadTokens || 0) / denominator
1275
1413
  }
1414
+ const modelTotalTokens = (model) => Number(model.inputTokens || 0) + Number(model.outputTokens || 0) + Number(model.cacheReadTokens || 0) + Number(model.cacheWriteTokens || 0)
1415
+ const modelSegmentValue = (model, metric) => metric === 'cacheTokens'
1416
+ ? Number(model.cacheReadTokens || 0) + Number(model.cacheWriteTokens || 0)
1417
+ : Number(model[metric] || 0)
1276
1418
  const usageValue = (totals, metricName) => metricName === 'cacheTokens'
1277
1419
  ? totals.cacheReadTokens + totals.cacheWriteTokens
1278
1420
  : totals[metricName] || 0
@@ -1295,13 +1437,12 @@ window.__ModuleLoader__.load({
1295
1437
  const sevenTotals = usageDays.reduce((total, day) => {
1296
1438
  const source = usageTotalsFor(day.key)
1297
1439
  total.steps += source.steps || 0
1298
- total.missingUsage += source.missingUsage || 0
1299
1440
  total.inputTokens += source.inputTokens || 0
1300
1441
  total.outputTokens += source.outputTokens || 0
1301
1442
  total.cacheReadTokens += source.cacheReadTokens || 0
1302
1443
  total.cacheWriteTokens += source.cacheWriteTokens || 0
1303
1444
  return total
1304
- }, { steps: 0, missingUsage: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 })
1445
+ }, { steps: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 })
1305
1446
  const sevenDenominator = sevenTotals.inputTokens + sevenTotals.cacheReadTokens + sevenTotals.cacheWriteTokens
1306
1447
  sevenTotals.cacheHitRate = sevenDenominator === 0 ? 0 : sevenTotals.cacheReadTokens / sevenDenominator
1307
1448
  const selectedProjects = usageProject === 'all'
@@ -1337,9 +1478,21 @@ window.__ModuleLoader__.load({
1337
1478
  }
1338
1479
  if (check.id === 'tar' && check.status === 'ok') return translate('health.detail.tar.ok')
1339
1480
  if (check.id === 'permissions') return translate(check.status === 'ok' ? 'health.detail.permissions.ok' : 'health.detail.permissions.warning', { count: detail || '0' })
1481
+ if (check.id === 'runtime-env') {
1482
+ if (detail === 'manual') return translate('health.detail.runtime-env.manual')
1483
+ if (detail === 'declared') return translate('health.detail.runtime-env.declared')
1484
+ if (detail === 'unknown') return translate('health.detail.runtime-env.unknown')
1485
+ // 已识别的管理器 kind:复用概览运行环境的标签词典。
1486
+ return translate('health.detail.runtime-env.managed', { kind: translate(`env.kind.${detail}`) })
1487
+ }
1488
+ if (check.id === 'node-version') {
1489
+ const [version = '', required = ''] = detail.split(':')
1490
+ return translate(check.status === 'ok' ? 'health.detail.node-version.ok' : 'health.detail.node-version.warning', { version, required })
1491
+ }
1340
1492
  return translate('health.detail.generic', { status: translate(`health.status.${check.status}`) })
1341
1493
  }
1342
1494
  const summaryItems = (totals) => [
1495
+ ['usage.total', formatTokenValue(usageValue(totals, 'inputTokens') + usageValue(totals, 'outputTokens') + usageValue(totals, 'cacheTokens'))],
1343
1496
  ...usageSegments.map(([metricName, label]) => [label, formatTokenValue(usageValue(totals, metricName))]),
1344
1497
  ['usage.steps', formatUsageValue(totals.steps, 'steps')],
1345
1498
  ['usage.hitRate', formatUsageValue(totals.cacheHitRate, 'cacheHitRate')],
@@ -1350,9 +1503,10 @@ window.__ModuleLoader__.load({
1350
1503
  React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)' } }, translate(label)),
1351
1504
  React.createElement('span', { style: { fontWeight: 650 } }, value)))
1352
1505
  )
1353
- const sortedModels = [...modelTotals.values()].sort((a, b) => b.steps - a.steps || a.id.localeCompare(b.id))
1506
+ const sortedModels = [...modelTotals.values()].sort((a, b) => modelTotalTokens(b) - modelTotalTokens(a) || b.steps - a.steps || a.id.localeCompare(b.id))
1354
1507
  const visibleModels = modelsOpen ? sortedModels : sortedModels.slice(0, 3)
1355
1508
  const hiddenModelCount = Math.max(0, sortedModels.length - 3)
1509
+ const maxModelTokens = Math.max(1, ...visibleModels.map((model) => modelTotalTokens(model)))
1356
1510
  const selectedErrors = (list) => (list || [])
1357
1511
  .filter((error) => usageProject === 'all' || error.projectId === usageProject)
1358
1512
  .sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
@@ -1411,24 +1565,43 @@ window.__ModuleLoader__.load({
1411
1565
  React.createElement('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '8px', marginTop: '10px' } },
1412
1566
  summaryBlock('today', 'usage.today', todayTotals),
1413
1567
  summaryBlock('seven', 'usage.sevenDays', sevenTotals)),
1414
- sevenTotals.missingUsage > 0 ? React.createElement('p', { style: hint }, translate('usage.missing', { count: sevenTotals.missingUsage })) : null,
1415
1568
  React.createElement('div', { 'data-testid': 'usage-model-list', style: { marginTop: '10px', padding: '8px 10px', borderRadius: '8px', background: 'var(--dsw-alias-bg-layer-2)', border: '1px solid var(--dsw-alias-border-l1)' } },
1416
- visibleModels.map((model, index) => React.createElement('div', { key: model.id, style: { display: 'flex', justifyContent: 'space-between', gap: '12px', fontSize: '12px', padding: '8px 2px', borderTop: index === 0 ? 0 : '1px solid var(--dsw-alias-border-l1)' } },
1417
- React.createElement('span', null, model.id),
1418
- React.createElement('span', { style: { textAlign: 'right' } }, translate('usage.modelLine', {
1419
- steps: Number(model.steps || 0).toLocaleString(),
1420
- hitRate: formatUsageValue(modelCacheHitRate(model), 'cacheHitRate'),
1421
- input: formatTokenValue(model.inputTokens),
1422
- output: formatTokenValue(model.outputTokens),
1423
- })))),
1569
+ React.createElement('div', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', marginBottom: '4px' } }, translate('usage.modelSortHint')),
1570
+ visibleModels.map((model, index) => {
1571
+ const total = modelTotalTokens(model)
1572
+ const fillWidth = `${Math.round(total / maxModelTokens * 10000) / 100}%`
1573
+ return React.createElement('div', { key: model.id, 'data-testid': `usage-model-row-${model.id}`, style: { padding: '8px 2px', borderTop: index === 0 ? 0 : '1px solid var(--dsw-alias-border-l1)' } },
1574
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: '12px', alignItems: 'baseline', fontSize: '12px' } },
1575
+ React.createElement('span', { style: { overflowWrap: 'anywhere' } }, model.id),
1576
+ React.createElement('span', { style: { fontWeight: 650, whiteSpace: 'nowrap' } }, formatTokenValue(total))),
1577
+ React.createElement('div', { 'data-testid': `usage-model-bar-${model.id}`, 'data-value': total, 'aria-label': translate('usage.modelBar', { model: model.id, total: formatTokenValue(total) }), style: { height: '8px', borderRadius: '4px', marginTop: '6px', overflow: 'hidden', background: 'var(--dsw-alias-border-l1)' } },
1578
+ React.createElement('div', { style: { display: 'flex', height: '100%', width: fillWidth } },
1579
+ usageSegments.map(([metricName, , color]) => {
1580
+ const value = modelSegmentValue(model, metricName)
1581
+ return value > 0 ? React.createElement('div', { key: metricName, 'data-testid': `usage-model-segment-${model.id}-${metricName}`, style: { width: `${value / total * 100}%`, background: color } }) : null
1582
+ }))),
1583
+ React.createElement('div', { style: { color: 'var(--dsw-alias-label-secondary)', fontSize: '11px', marginTop: '4px', textAlign: 'right' } }, translate('usage.modelLine', {
1584
+ steps: Number(model.steps || 0).toLocaleString(),
1585
+ hitRate: formatUsageValue(modelCacheHitRate(model), 'cacheHitRate'),
1586
+ input: formatTokenValue(model.inputTokens),
1587
+ output: formatTokenValue(model.outputTokens),
1588
+ })))
1589
+ }),
1424
1590
  hiddenModelCount > 0 ? React.createElement('button', { style: Object.assign({}, toggle, { borderTop: '1px solid var(--dsw-alias-border-l1)', marginTop: '2px' }), onClick: () => setModelsOpen((value) => !value) }, `${modelsOpen ? '▾' : '▸'} ${translate(modelsOpen ? 'usage.models.less' : 'usage.models.more', { count: hiddenModelCount })}`) : null))
1425
1591
  : React.createElement('p', { style: hint }, usageError || translate('usage.empty')),
1426
1592
  React.createElement('div', { style: row }, React.createElement('button', { style: neutral, 'data-variant': 'neutral', onClick: refreshUsage, disabled: usageBusy }, translate(usageBusy ? 'usage.refreshing' : 'usage.refresh'))))
1427
1593
 
1594
+ // 平台标签:process.platform 映射为常见系统名,arch 跟在后面(均为专有名词,不走词典)。
1595
+ const platformNames = { win32: 'Windows', darwin: 'macOS', linux: 'Linux', freebsd: 'FreeBSD', openbsd: 'OpenBSD' }
1596
+ const platformLabel = (health && typeof health.platform === 'string' && health.platform)
1597
+ ? `${platformNames[health.platform] || health.platform}${typeof health.arch === 'string' && health.arch ? ` · ${health.arch}` : ''}`
1598
+ : '—'
1428
1599
  const containerInfoBlock = React.createElement('div', { key: 'container-info', style: { marginTop: '18px' } },
1429
1600
  React.createElement('div', { style: sectionTitle }, translate('overview.container')),
1430
1601
  health
1431
1602
  ? React.createElement('div', { 'data-testid': 'health-display', style: Object.assign({}, displaySurface, { display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: '8px' }) },
1603
+ metric('health.platform', platformLabel),
1604
+ metric('health.nodeVersion', typeof health.nodeVersion === 'string' && health.nodeVersion ? health.nodeVersion : '—'),
1432
1605
  metric('health.uptime', formatUptime(health.uptimeSeconds)),
1433
1606
  metric('health.rss', formatBytes(health.rssBytes)),
1434
1607
  metric('health.liveSessions', String(health.liveSessions)),
@@ -1458,7 +1631,7 @@ window.__ModuleLoader__.load({
1458
1631
  ? React.createElement('div', { 'data-testid': 'health-check-list', style: Object.assign({}, displaySurface, { marginTop: '10px', padding: '8px 10px' }) },
1459
1632
  diagnostics.checks.map((check, index) => React.createElement('div', { key: check.id, style: { display: 'flex', justifyContent: 'space-between', gap: '14px', fontSize: '12px', padding: '9px 2px', borderTop: index === 0 ? 0 : '1px solid var(--dsw-alias-border-l1)' } },
1460
1633
  React.createElement('span', null, translate(`health.check.${check.id}`)),
1461
- React.createElement('span', { style: { color: check.status === 'ok' ? 'var(--dsw-alias-state-success-primary)' : check.status === 'warning' ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-error-primary)', textAlign: 'right' } }, diagnosticDetail(check)))))
1634
+ React.createElement('span', { style: { color: check.status === 'ok' ? 'var(--dsw-alias-state-success-primary)' : check.status === 'warning' ? 'var(--dsw-alias-state-warn-primary)' : check.status === 'info' ? 'var(--dsw-alias-brand-primary)' : 'var(--dsw-alias-state-error-primary)', textAlign: 'right' } }, diagnosticDetail(check)))))
1462
1635
  : null)
1463
1636
 
1464
1637
  const permissionAbnormal = permissions && permissions.supported === true
@@ -1587,9 +1760,22 @@ window.__ModuleLoader__.load({
1587
1760
  const dshUpdate = updateInfo?.dsh
1588
1761
  const dshExpandable = dshUpdate && dshUpdate.status !== 'unpublished' && dshUpdate.status !== 'unavailable' && !dshUpdate.upToDate
1589
1762
  const pluginUpdate = updateInfo?.plugin && !updateInfo.plugin.upToDate && updateInfo.plugin.status === 'available'
1590
- const pluginAction = pluginUpdate
1763
+ // 确认后果或已装好待手动重启期间收起升级按钮,避免重复触发或撞 no-newer-version 守卫。
1764
+ const pluginAction = pluginUpdate && !upgradeManualConfirm && !upgradeManualPending
1591
1765
  ? React.createElement('button', { style: Object.assign({}, neutral, { minHeight: '24px', padding: '2px 8px', fontSize: '11px' }), disabled: upgradeBusy, onClick: upgradePlugin }, translate(upgradeBusy ? 'update.upgrading' : 'update.upgrade'))
1592
1766
  : null
1767
+ // 运行环境一行:managed 显示管理器标签;疑似手动启动用警示色;未知非空 kind 不得
1768
+ // 显示成「未检测到」(那是 supervisorKind 为 null 时的语义)。
1769
+ const runtimeEnvKinds = ['docker', 'container', 'systemd', 'pm2', 'supervisord', 'kubernetes', 'declared']
1770
+ const runtimeEnvLabelKey = runtimeEnv === null
1771
+ ? null
1772
+ : runtimeEnv.manualStartLikely === true
1773
+ ? 'env.likelyManual'
1774
+ : typeof runtimeEnv.supervisorKind === 'string' && runtimeEnvKinds.includes(runtimeEnv.supervisorKind)
1775
+ ? `env.kind.${runtimeEnv.supervisorKind}`
1776
+ : typeof runtimeEnv.supervisorKind === 'string' && runtimeEnv.supervisorKind.length > 0
1777
+ ? 'env.kind.unrecognized'
1778
+ : 'env.unknown'
1593
1779
  const versionBlock = React.createElement('div', { key: 'version-card', 'data-testid': 'version-card', style: card },
1594
1780
  React.createElement('div', { key: 'title', style: sectionTitle }, translate('version.title')),
1595
1781
  React.createElement('div', { style: displaySurface },
@@ -1601,6 +1787,23 @@ window.__ModuleLoader__.load({
1601
1787
  React.createElement('div', null, translate('update.details.latest', { version: dshUpdate?.latest || '—' })),
1602
1788
  channelLines(translate, dshUpdate?.tags))
1603
1789
  : null,
1790
+ runtimeEnvLabelKey !== null
1791
+ ? React.createElement('div', { 'data-testid': 'runtime-env-line', style: { marginTop: '8px', paddingTop: '8px', borderTop: '1px solid var(--dsw-alias-border-l1)', fontSize: '12px', color: runtimeEnv.manualStartLikely === true ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-label-secondary)' } },
1792
+ translate('env.line', { value: translate(runtimeEnvLabelKey) }))
1793
+ : null,
1794
+ upgradeManualConfirm
1795
+ ? React.createElement('div', { 'data-testid': 'upgrade-manual-confirm', style: { marginTop: '10px', padding: '10px 12px', borderRadius: '6px', background: 'rgba(211,51,51,0.08)', border: '1px solid rgba(211,51,51,0.3)' } },
1796
+ React.createElement('p', { style: { margin: '0 0 6px', color: 'var(--dsw-alias-state-error-primary)', fontSize: '13px', fontWeight: 600 } }, translate('update.manualConfirmTitle')),
1797
+ React.createElement('p', { style: Object.assign({}, hint, { margin: '0 0 8px' }) }, translate('update.manualConfirm')),
1798
+ React.createElement('div', { style: row },
1799
+ React.createElement('button', { style: dangerGhost, 'data-variant': 'danger', disabled: upgradeBusy, onClick: upgradePlugin }, translate(upgradeBusy ? 'update.upgrading' : 'update.manualProceed')),
1800
+ React.createElement('button', { style: ghost, disabled: upgradeBusy, onClick: () => setUpgradeManualConfirm(false) }, translate('restart.cancel'))))
1801
+ : null,
1802
+ upgradeManualPending
1803
+ ? React.createElement('div', { 'data-testid': 'upgrade-manual-pending', style: { marginTop: '10px', padding: '10px 12px', borderRadius: '6px', border: '1px solid var(--dsw-alias-state-warn-primary)', background: 'var(--dsw-alias-bg-layer-2)' } },
1804
+ React.createElement('p', { style: { margin: '0 0 4px', color: 'var(--dsw-alias-state-warn-primary)', fontSize: '13px', fontWeight: 650 } }, translate('update.manualRestartTitle')),
1805
+ React.createElement('p', { style: Object.assign({}, hint, { margin: 0 }) }, translate('update.manualRestartBody')))
1806
+ : null,
1604
1807
  upgradeError ? React.createElement('p', { style: Object.assign({}, hint, { color: 'var(--dsw-alias-state-error-primary)', margin: '4px 0 0' }) }, upgradeError) : null))
1605
1808
 
1606
1809
  // 重启区块复用共享组件(「重启」标签还承载设置页左列入口的显示开关;左侧入口默认关闭)
@@ -1622,10 +1825,10 @@ window.__ModuleLoader__.load({
1622
1825
  cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.5 : 1, lineHeight: 0,
1623
1826
  },
1624
1827
  }, React.createElement('span', { style: { position: 'absolute', top: '1px', left: on ? '15px' : '1px', width: '16px', height: '16px', borderRadius: '50%', background: on ? '#fff' : 'var(--dsw-alias-label-tertiary)' } }))
1625
- const notifyRow = (label, labelHint, on, onChange, disabled) => React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', padding: '5px 0' } },
1828
+ const notifyRow = (testId, label, labelHint, on, onChange, disabled) => React.createElement('div', { 'data-testid': testId, style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', padding: '5px 0' } },
1626
1829
  React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '2px' } },
1627
1830
  React.createElement('span', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-primary)' } }, label),
1628
- React.createElement('span', { style: hint }, labelHint)),
1831
+ labelHint ? React.createElement('span', { style: hint }, labelHint) : null),
1629
1832
  notifySwitch(on, onChange, disabled))
1630
1833
  const notificationBlock = !notifSupported ? null
1631
1834
  : React.createElement('div', { style: { marginTop: '18px' } },
@@ -1637,14 +1840,14 @@ window.__ModuleLoader__.load({
1637
1840
  React.createElement('button', { style: neutral, onClick: () => { Notification.requestPermission().then((p) => { if (p === 'granted') setNotifyOn(true) }) } }, translate('notification.enable')),
1638
1841
  React.createElement('span', { style: hint }, notifPermission === 'denied' ? translate('notification.denied') : ''))
1639
1842
  : React.createElement('div', { style: { marginTop: '8px', display: 'flex', flexDirection: 'column' } },
1640
- notifyRow(translate('notification.master'), translate('notification.masterHint'), notifyOn, setNotifyOn, false),
1641
- notifyRow(translate('notification.done'), translate('notification.doneHint'), notifyDoneOn, setNotifyDoneOn, !notifyOn),
1642
- notifyRow(translate('notification.input'), translate('notification.inputHint'), notifyInputOn, setNotifyInputOn, !notifyOn))))
1843
+ notifyRow('notify-row-master', translate('notification.master'), null, notifyOn, setNotifyOn, false),
1844
+ notifyRow('notify-row-done', translate('notification.done'), null, notifyDoneOn, setNotifyDoneOn, !notifyOn),
1845
+ notifyRow('notify-row-input', translate('notification.input'), null, notifyInputOn, setNotifyInputOn, !notifyOn))))
1643
1846
  const overviewBlock = React.createElement('div', null, versionBlock, containerInfoBlock, overviewErrorsBlock)
1644
1847
  // 任务通知独立成顶部标签(v0.14 起不再混在概览里)
1645
1848
  const notifyBlock = React.createElement('div', null, notificationBlock)
1646
1849
  const maintenanceBlock = React.createElement('div', { key: 'maintenance-card', 'data-testid': 'maintenance-card', style: card }, backupBlock)
1647
- const diagnosticFailure = diagnostics?.checks?.some((check) => check.status === 'error' || (check.status === 'warning' && !(check.id === 'backup-storage' && String(check.detail || '').startsWith('0:')))) === true
1850
+ const diagnosticFailure = diagnostics?.checks?.some((check) => check.status === 'error' || check.status === 'warning') === true
1648
1851
  const tabWarnings = {
1649
1852
  overview: false,
1650
1853
  notify: false,
package/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // 「服务控制」:版本信息 + 检查更新 + 一键重启 dsh web
3
3
  import { createHmac, randomBytes, randomUUID } from 'node:crypto'
4
4
  import { Buffer } from 'node:buffer'
5
- import { constants as fsConstants } from 'node:fs'
5
+ import { constants as fsConstants, existsSync, readFileSync } from 'node:fs'
6
6
  import { access, cp, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'
7
7
  import { homedir } from 'node:os'
8
8
  import { basename, dirname, join, relative, resolve, sep } from 'node:path'
@@ -21,7 +21,7 @@ const MAX_BACKUP_TRANSFER_BYTES = 128 * 1024 * 1024
21
21
  const instanceId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
22
22
  const backupIdSecret = randomBytes(32)
23
23
  const BACKUP_NAME = /^dsh-backup-\d{8}-\d{6}\.tar\.gz$/
24
- const USAGE_INDEX_VERSION = 4
24
+ const USAGE_INDEX_VERSION = 5
25
25
  const USAGE_INDEX_FILE = 'dsh-service-usage-index.json'
26
26
 
27
27
  // 升级目标白名单:命令与包名全部来自宿主常量,浏览器不传任何输入。
@@ -44,6 +44,13 @@ try {
44
44
  } catch (__) {}
45
45
  }
46
46
 
47
+ // Node 最低要求只读 package.json engines 一处(与版本号同源),启动时解析一次。
48
+ let requiredNodeMajor = 22
49
+ try {
50
+ const enginesNode = require('./package.json').engines?.node || ''
51
+ requiredNodeMajor = parseInt(enginesNode.replace(/[^0-9].*$/, ''), 10) || 22
52
+ } catch (_) {}
53
+
47
54
  function isSemverIdentifier(value) {
48
55
  if (value.length === 0) return false
49
56
  return [...value].every((char) => (char >= '0' && char <= '9') || (char >= 'A' && char <= 'Z') || (char >= 'a' && char <= 'z') || char === '-')
@@ -376,12 +383,11 @@ function localDayForHour(hour, timezoneOffsetMinutes = 0) {
376
383
  }
377
384
 
378
385
  function emptyUsageTotals() {
379
- return { steps: 0, missingUsage: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
386
+ return { steps: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
380
387
  }
381
388
 
382
389
  function addUsageTotals(target, source) {
383
390
  target.steps += source.steps || 0
384
- target.missingUsage += source.missingUsage || 0
385
391
  target.inputTokens += source.inputTokens || 0
386
392
  target.outputTokens += source.outputTokens || 0
387
393
  target.cacheReadTokens += source.cacheReadTokens || 0
@@ -571,19 +577,18 @@ function foldUsageEvents(ctx, record, previous, events) {
571
577
  if (addToolError(session, event)) continue
572
578
  if (addUsageError(session, event, model)) continue
573
579
  if (event.type !== 'assistant/message') continue
580
+ // 提供方未上报 token 用量的步骤直接忽略:不计步骤、不建模型桶、不提示。
581
+ if (event.data?.usage === undefined) continue
574
582
  const hour = usageHour(event.time)
575
583
  const bucket = session.hours[hour] || (session.hours[hour] = { totals: emptyUsageTotals(), models: {} })
576
584
  const modelBucket = bucket.models[model.id] || (bucket.models[model.id] = { id: model.id, provider: model.provider, model: model.model, totals: emptyUsageTotals() })
577
- const usage = event.data?.usage
585
+ const usage = event.data.usage
578
586
  const delta = emptyUsageTotals()
579
587
  delta.steps = 1
580
- if (usage === undefined) delta.missingUsage = 1
581
- else {
582
- delta.inputTokens = Number(usage.inputTokens) || 0
583
- delta.outputTokens = Number(usage.outputTokens) || 0
584
- delta.cacheReadTokens = Number(usage.cacheReadTokens) || 0
585
- delta.cacheWriteTokens = Number(usage.cacheWriteTokens) || 0
586
- }
588
+ delta.inputTokens = Number(usage.inputTokens) || 0
589
+ delta.outputTokens = Number(usage.outputTokens) || 0
590
+ delta.cacheReadTokens = Number(usage.cacheReadTokens) || 0
591
+ delta.cacheWriteTokens = Number(usage.cacheWriteTokens) || 0
587
592
  addUsageTotals(bucket.totals, delta)
588
593
  addUsageTotals(modelBucket.totals, delta)
589
594
  }
@@ -796,7 +801,8 @@ function classifyUpgradeFailure(output) {
796
801
  // 一键升级:走「dsh plugin --profile <p> add <pkg>@<版本>」,落在 DSH 真正读取的 profile
797
802
  // node_modules,替代旧的无升级语义的 npm install -g。命令、包名、版本全部宿主白名单常量。
798
803
  // 安全教义守卫:活动工作拒用、link/file 拒绝、latest 不高于当前版本拒绝(防降级)一律先于网络。
799
- async function upgradePlugin(ctx, dshHome) {
804
+ // runtimeEnv 来自 apply() 的 detectRuntimeEnv():疑似终端手动启动时安装成功不调度 exit(42)。
805
+ async function upgradePlugin(ctx, dshHome, runtimeEnv) {
800
806
  const activity = collectActiveWork(ctx)
801
807
  if (activity.hasActive) throw new Error('active-work')
802
808
 
@@ -851,6 +857,12 @@ async function upgradePlugin(ctx, dshHome) {
851
857
  : installed !== pluginVersion
852
858
  if (!advanced) throw new Error('upgrade-stale')
853
859
 
860
+ // 手动终端启动环境没有进程管理器拉起 exit(42):保持旧版本继续服务,把重启时机交给用户。
861
+ // requiresManualRestart 让客户端改走「手动重启指引」而不是恢复轮询(不会有新实例)。
862
+ if (runtimeEnv !== undefined && runtimeEnv !== null && runtimeEnv.manualStartLikely === true) {
863
+ return { result: 'upgraded', profile: profile.name, previous: pluginVersion, installed, requiresManualRestart: true }
864
+ }
865
+
854
866
  scheduleRestart(ctx)
855
867
  return { result: 'upgraded', profile: profile.name, previous: pluginVersion, installed }
856
868
  }
@@ -1001,7 +1013,20 @@ async function repairPermissions(ctx, dshHome, plans, planId) {
1001
1013
  return permissionSnapshot(ctx, dshHome, plans)
1002
1014
  }
1003
1015
 
1004
- async function collectDiagnostics(ctx, dshHome) {
1016
+ // 运行环境检查(v0.17):managed/declared ok;疑似手动启动 → warning(重启无保障);
1017
+ // unknown → info(未检测到管理器也无终端特征:输出重定向、NSSM/WinSW 等包装器——维持现状
1018
+ // 行为本就是默认路径,不算异常,只提示可显式声明)。detail 是客户端映射词典的令牌。
1019
+ function runtimeEnvCheck(runtimeEnv) {
1020
+ if (runtimeEnv === undefined || runtimeEnv === null) return null
1021
+ if (runtimeEnv.manualStartLikely === true) return { id: 'runtime-env', status: 'warning', detail: 'manual' }
1022
+ if (runtimeEnv.supervisorKind === 'declared') return { id: 'runtime-env', status: 'ok', detail: 'declared' }
1023
+ if (typeof runtimeEnv.supervisorKind === 'string' && runtimeEnv.supervisorKind.length > 0) {
1024
+ return { id: 'runtime-env', status: 'ok', detail: runtimeEnv.supervisorKind }
1025
+ }
1026
+ return { id: 'runtime-env', status: 'info', detail: 'unknown' }
1027
+ }
1028
+
1029
+ async function collectDiagnostics(ctx, dshHome, runtimeEnv) {
1005
1030
  const checks = []
1006
1031
  const add = (id, status, detail) => checks.push({ id, status, ...(detail === undefined ? {} : { detail: String(detail) }) })
1007
1032
 
@@ -1029,7 +1054,8 @@ async function collectDiagnostics(ctx, dshHome) {
1029
1054
 
1030
1055
  try {
1031
1056
  const backups = await listBackups(dshHome)
1032
- add('backup-storage', backups.items.length === 0 ? 'warning' : 'ok', `${backups.items.length}:${backups.totalBytes}`)
1057
+ // 没有备份不算警告(v0.14 口径的落地):空列表是信息级提示,不点亮标签 ⚠、不影响 overall。
1058
+ add('backup-storage', backups.items.length === 0 ? 'info' : 'ok', `${backups.items.length}:${backups.totalBytes}`)
1033
1059
  } catch (error) { add('backup-storage', 'error', error?.code || error?.message || error) }
1034
1060
 
1035
1061
  const subprocess = ctx.get('subprocess')
@@ -1046,6 +1072,14 @@ async function collectDiagnostics(ctx, dshHome) {
1046
1072
  } catch (error) { add('permissions', 'warning', error?.message || error) }
1047
1073
  }
1048
1074
 
1075
+ const runtimeCheck = runtimeEnvCheck(runtimeEnv)
1076
+ if (runtimeCheck !== null) checks.push(runtimeCheck)
1077
+
1078
+ // Node 运行时版本检查:低于 package.json engines 的最低 major 时告警。
1079
+ // detail 形如 "v22.14.0:22"(当前版本:要求 major),客户端按 backup-storage 同款分隔符解析。
1080
+ const nodeMajor = parseInt(process.versions.node, 10)
1081
+ add('node-version', Number.isFinite(nodeMajor) && nodeMajor >= requiredNodeMajor ? 'ok' : 'warning', `${process.version}:${requiredNodeMajor}`)
1082
+
1049
1083
  let status = 'ok'
1050
1084
  if (checks.some((check) => check.status === 'error')) status = 'error'
1051
1085
  else if (checks.some((check) => check.status === 'warning')) status = 'warning'
@@ -1062,6 +1096,9 @@ async function collectHealth(ctx) {
1062
1096
  return {
1063
1097
  uptimeSeconds: process.uptime(),
1064
1098
  rssBytes: memory.rss,
1099
+ platform: process.platform,
1100
+ arch: process.arch,
1101
+ nodeVersion: process.version,
1065
1102
  liveSessions: sessionsService === undefined ? 0 : sessionsService.list().length,
1066
1103
  persistedSessions: sessionRecords.filter((record) => record.persisted === true).length,
1067
1104
  activeAgents: activity.items.filter((item) => item.type === 'agent').length,
@@ -1129,6 +1166,50 @@ function collectActiveWork(ctx) {
1129
1166
  return { hasActive: items.length > 0, items }
1130
1167
  }
1131
1168
 
1169
+ // ---- 运行环境探测(v0.16:Windows 终端手动启动场景,用户点名)----
1170
+ // 重启 = process.exit(42),依赖外层进程管理器拉起(见 AGENTS.md);PowerShell/CMD 手动
1171
+ // 启动时没有任何东西会把它拉起来。这里只用跨平台被动信号(env + 固定路径 fs + Node 自带
1172
+ // isTTY,零 spawn、浏览器零输入)把环境分成三态:
1173
+ // supervisorKind 非 null —— 检测到管理器,exit 后会被自动拉起,维持自动重启;
1174
+ // manualStartLikely=true —— 无管理器且 stdin/stdout 双 TTY(终端直启特征),升级后宿主
1175
+ // 不自动退出,客户端改示手动重启指引;
1176
+ // 其余 —— unknown(输出重定向、NSSM/WinSW 等包装器),维持现状不折腾。
1177
+ // 探测不到的包装器可用 DSH_SERVICE_RUNTIME_ENV=managed|manual 由用户显式声明。
1178
+ const RUNTIME_SUPERVISOR_ENV = [
1179
+ ['pm2', ['pm_id', 'PM2_HOME', 'pm_uptime']],
1180
+ ['systemd', ['INVOCATION_ID', 'JOURNAL_STREAM', 'NOTIFY_SOCKET']],
1181
+ ['supervisord', ['SUPERVISOR_ENABLED', 'SUPERVISOR_PROCESS_NAME']],
1182
+ ['kubernetes', ['KUBERNETES_SERVICE_HOST']],
1183
+ ]
1184
+
1185
+ function detectRuntimeEnv(options = {}) {
1186
+ const env = options.env ?? process.env
1187
+ const platform = options.platform ?? process.platform
1188
+ const stdinIsTTY = options.stdinIsTTY ?? (process.stdin ? process.stdin.isTTY === true : false)
1189
+ const stdoutIsTTY = options.stdoutIsTTY ?? (process.stdout ? process.stdout.isTTY === true : false)
1190
+ // 默认探测器只在未被注入时执行;win32 直接短路(/.dockerenv 与 /proc 不存在),单测全部显式传原语。
1191
+ const dockerEnvExists = options.dockerEnvExists ?? (platform === 'win32' ? false : (() => { try { return existsSync('/.dockerenv') } catch (_) { return false } })())
1192
+ const cgroupText = options.cgroupText ?? (platform === 'win32' ? '' : (() => { try { return readFileSync('/proc/1/cgroup', 'utf8') } catch (_) { return '' } })())
1193
+
1194
+ const forced = typeof env.DSH_SERVICE_RUNTIME_ENV === 'string' ? env.DSH_SERVICE_RUNTIME_ENV.trim().toLowerCase() : ''
1195
+ if (forced === 'manual') return { platform, supervisorKind: null, manualStartLikely: true }
1196
+ if (forced === 'managed') return { platform, supervisorKind: 'declared', manualStartLikely: false }
1197
+
1198
+ for (const [kind, keys] of RUNTIME_SUPERVISOR_ENV) {
1199
+ if (keys.some((key) => env[key] !== undefined && env[key] !== '')) {
1200
+ return { platform, supervisorKind: kind, manualStartLikely: false }
1201
+ }
1202
+ }
1203
+ // /.dockerenv 与 /proc 只在 POSIX 内核上存在;win32 原生进程不做这两个探测。
1204
+ if (platform !== 'win32') {
1205
+ if (dockerEnvExists === true) return { platform, supervisorKind: 'docker', manualStartLikely: false }
1206
+ if (/docker/i.test(cgroupText)) return { platform, supervisorKind: 'docker', manualStartLikely: false }
1207
+ if (/containerd|kubepods|lxc|podman/i.test(cgroupText)) return { platform, supervisorKind: 'container', manualStartLikely: false }
1208
+ }
1209
+ if (stdinIsTTY === true && stdoutIsTTY === true) return { platform, supervisorKind: null, manualStartLikely: true }
1210
+ return { platform, supervisorKind: null, manualStartLikely: false }
1211
+ }
1212
+
1132
1213
  function scheduleRestart(ctx) {
1133
1214
  const doExit = () => {
1134
1215
  try {
@@ -1145,6 +1226,8 @@ function scheduleRestart(ctx) {
1145
1226
 
1146
1227
  function apply(ctx) {
1147
1228
  const dshHome = resolveDshHome()
1229
+ // 进程运行环境在生命周期内不变:挂载时探测一次,version RPC 与升级分支共用。
1230
+ const runtimeEnv = detectRuntimeEnv()
1148
1231
  const permissionPlans = new Map()
1149
1232
  const downloadTokens = new Map()
1150
1233
  let usageIndexPromise = loadUsageIndex(dshHome)
@@ -1207,7 +1290,9 @@ function apply(ctx) {
1207
1290
  // 合法示例:channel=/dsh-service,endpoint=version/check-update/activity/web。
1208
1291
  ctx.connection.rpc.handle('/dsh-service', async (endpoint, payload) => {
1209
1292
  if (endpoint === 'version') {
1210
- return { ok: true, value: { current: dshVersion, pluginVersion, instanceId } }
1293
+ // runtimeEnv 随进程身份(instanceId)一起返回:概览展示、升级前置确认与重启警告共用,
1294
+ // 客户端对缺字段的老宿主静默降级。
1295
+ return { ok: true, value: { current: dshVersion, pluginVersion, instanceId, runtimeEnv } }
1211
1296
  }
1212
1297
 
1213
1298
  if (endpoint === 'check-update') {
@@ -1245,7 +1330,7 @@ function apply(ctx) {
1245
1330
 
1246
1331
  if (endpoint === 'upgrade') {
1247
1332
  try {
1248
- return { ok: true, value: await upgradePlugin(ctx, dshHome) }
1333
+ return { ok: true, value: await upgradePlugin(ctx, dshHome, runtimeEnv) }
1249
1334
  } catch (error) {
1250
1335
  return { ok: false, error: error?.message || String(error) }
1251
1336
  }
@@ -1265,7 +1350,7 @@ function apply(ctx) {
1265
1350
 
1266
1351
  if (endpoint === 'diagnostics') {
1267
1352
  try {
1268
- return { ok: true, value: await collectDiagnostics(ctx, dshHome) }
1353
+ return { ok: true, value: await collectDiagnostics(ctx, dshHome, runtimeEnv) }
1269
1354
  } catch (error) {
1270
1355
  return { ok: false, error: error?.message || String(error) }
1271
1356
  }
@@ -1395,5 +1480,5 @@ function apply(ctx) {
1395
1480
  }, { authority: 'loopback' })
1396
1481
  }
1397
1482
 
1398
- export { apply, inject, name }
1399
- export default { apply, inject, name }
1483
+ export { apply, detectRuntimeEnv, inject, name, runtimeEnvCheck }
1484
+ export default { apply, detectRuntimeEnv, inject, name, runtimeEnvCheck }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gehennawu/dsh-service",
3
- "version": "0.14.0",
3
+ "version": "0.17.0",
4
4
  "description": "DSH Web 自托管运维面板:安全重启、健康监控、备份和 Linux 权限维护。",
5
5
  "type": "module",
6
6
  "scripts": {