dsh-windows-tray 1.0.13 → 1.0.14

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.
@@ -2,20 +2,153 @@
2
2
  # 被 dsh-control.ps1 (控制台) 与 dsh-tray.ps1 (系统托盘) 点源引用 (dot-source)
3
3
  # 所有路径/端口/进程管理逻辑的单一事实来源: 修改此处, 两个入口同时生效
4
4
  $ErrorActionPreference = 'Stop'
5
+ # 原生命令非零退出在 Stop 模式下默认抛 NativeCommandExitException(PS 7.3/7.4 起
6
+ # $PSNativeCommandUseErrorActionPreference 默认 $true)⇒ taskkill 命中已退 PID / 引擎调用失败
7
+ # 会**终止控制流**并吞掉诊断。统一钉 false,退出码一律走 $LASTEXITCODE 判(2026-09-23 独审 P2-2)。
8
+ $PSNativeCommandUseErrorActionPreference = $false
5
9
 
6
- $DSH_CMD = 'D:\Program Files (x86)\dsh\dsh.cmd'
7
- $NODE = 'C:\Program Files\nodejs\node.exe'
8
- $BIN = 'D:\Program Files (x86)\dsh\node_modules\@deepseek-ai\dsh\lib\bin.js'
9
- $WORKDIR = 'D:\Program Files (x86)\dsh'
10
- $PORT = 3080
10
+ $DSH_CMD = 'SET-BY-INSTALL-PS1\dsh.cmd' # install.ps1 [6/7] 回填本机值(源码不写死机器路径,release-standards §7)
11
+ $NODE = 'SET-BY-INSTALL-PS1\node.exe' # 同上
12
+ $BIN = 'SET-BY-INSTALL-PS1\node_modules\@deepseek-ai\dsh\lib\bin.js' # 同上
13
+ $WORKDIR = 'SET-BY-INSTALL-PS1' # 同上
14
+ $PORT = 0
11
15
  $URL = "http://127.0.0.1:$PORT"
12
16
  $LOG_DIR = Join-Path (Join-Path $env:USERPROFILE '.dsh\logs') 'dsh-windows-tray' # 本插件专属日志目录 (logs\<插件名>\, 边界规则见 release-standards §3.1)
13
17
  $WT = "$env:LOCALAPPDATA\Microsoft\WindowsApps\wt.exe"
14
- $PWSH = 'D:\Program Files (x86)\PowerShell-7.6.4\pwsh.exe'
15
- $TRAY_VERSION = '1.0.13' # 托盘版本 (与 npm 包版本同步, 用于日志/排查)
18
+ $PWSH = 'SET-BY-INSTALL-PS1\pwsh.exe' # 同上
19
+ $TRAY_VERSION = '1.0.14' # 托盘版本 (与 npm 包版本同步, 用于日志/排查)
16
20
  $TRAY_PKG = 'dsh-windows-tray' # npm 包名 (托盘自更新)
17
21
  $TRAY_SCRIPT = Join-Path $PSScriptRoot 'dsh-tray.ps1'
18
22
 
23
+ # ---- 原生命令输出解码:钉成 UTF-8 ----
24
+ # 隐藏窗口里 `[Console]::OutputEncoding` 可能退回 ANSI(本机 GBK),此时捕获 node 的 UTF-8 输出会先被
25
+ # GBK 解码再写进日志 ⇒ 中文变乱码(2026-09-22 实测:`✔ data: 204 文件` 记成 `鉁?data: 204 鏂囦欢`,
26
+ # 而 exit 码与数字仍对)。三个消费者(托盘/动作/boot)都点源本文件 ⇒ 在这里一处钉住。
27
+ try {
28
+ $utf8NoBom = [System.Text.UTF8Encoding]::new($false)
29
+ [Console]::OutputEncoding = $utf8NoBom # 解码原生**输出**(我们读 node 的 stdout)
30
+ $OutputEncoding = $utf8NoBom # 编码送往原生命令的**输入**
31
+ } catch { } # 无控制台/受限环境:失败就降级,不阻断主流程
32
+
33
+ # ---- 备份状态显示(M2, 2026-09-22)----
34
+ # 库位置**不写死盘符**(2026-09-22 用户改判):与引擎同口径解析
35
+ # $env:DSH_BACKUP_STORE > 机器配置 <DSH_HOME>\backup-store.txt > 自动发现已有库(<盘>:\dsh-backups\auto\key-salt)
36
+ # 三者都没有 ⇒ $null(状态行显示"未配置";首次「立即备份」时引擎会自己探测并记下配置)
37
+ function Resolve-BackupStore {
38
+ $envv = [string]$env:DSH_BACKUP_STORE
39
+ if ($envv.Trim()) { return $envv.Trim() }
40
+ $cfg = Join-Path ($(if ($env:DSH_HOME) { $env:DSH_HOME } else { Join-Path $env:USERPROFILE '.dsh' })) 'backup-store.txt' # 与引擎同口径:<DSH_HOME>\backup-store.txt(非默认数据目录机器此前读错位,2026-09-22 LJS 实测)
41
+ if (Test-Path -LiteralPath $cfg) {
42
+ $line = Get-Content -LiteralPath $cfg -TotalCount 1 -ErrorAction SilentlyContinue
43
+ if ($line -and ([string]$line).Trim()) { return ([string]$line).Trim() }
44
+ }
45
+ foreach ($c in 67..90) { # C..Z:找已有库(与引擎 discovered 口径一致)
46
+ if (-not (Get-PSDrive -Name ([char]$c) -PSProvider FileSystem -ErrorAction SilentlyContinue)) { continue }
47
+ # ↑ 盘符不存在(I..Z 等):Test-Path 直碰会抛 DriveNotFound(脚本顶 Stop ⇒ 托盘起不来,09-22 LJS 实测)——先跳过
48
+ $p = "$([char]$c):\dsh-backups\auto"
49
+ if (Test-Path -LiteralPath (Join-Path $p 'key-salt')) { return $p }
50
+ }
51
+ return $null
52
+ }
53
+ # 档位判定与库解析**放在文件末尾**(落点约束:晚于日志设施 Write-DshLog:163/Initialize-LogDir:189、
54
+ # 早于托盘菜单构建 dsh-tray.ps1:192;依据 = 方案定稿 §2/§3 + 方案审 F-05)
55
+
56
+ # 读库里的 status.json 并解析成状态对象(**只缓存解析结果**,age 每次现算 —— 缓存整行会把陈旧告警冻死)。
57
+ # 缓存命中判据 = **内容本体相等**(status.json 仅数百字节,串比较可忽略):原键 (mtime.Ticks, size) 在 NTFS 上有
58
+ # 「同秒连续写 mtime 短暂不刷新」的陈旧读竞态(2026-09-22 LJS 实测:同长不同内容连续写 ⇒ 读到上一份,用例随机红)
59
+ # ⇒ 换内容本体后竞态从根上消掉;缓存防的仍是重复解析(实现审 B-13 语义不变)。
60
+ $script:backupStatusCache = $null
61
+ function Read-BackupStatus {
62
+ param([string]$StorePath = $BACKUP_STORE)
63
+ if (-not $StorePath) { return [pscustomobject]@{ Kind = 'Missing' } }
64
+ $p = Join-Path $StorePath 'status.json'
65
+ try { $raw = Get-Content -LiteralPath $p -Raw -Encoding UTF8 -ErrorAction Stop }
66
+ catch { $script:backupStatusCache = $null; return [pscustomobject]@{ Kind = 'Missing' } }
67
+ $c = $script:backupStatusCache
68
+ if ($c -and $c.Store -eq $StorePath -and $c.Raw -eq $raw) { return $c.State }
69
+ $result = [pscustomobject]@{ Kind = 'Corrupt'; Never = @(); Items = @(); LastFailedAt = $null }
70
+ do {
71
+ if ([string]::IsNullOrWhiteSpace($raw)) { break }
72
+ # -DateKind String(PS 7.5+)保留原始串:不加时 ConvertFrom-Json 会把 ISO 串**自动转成 [datetime]**,
73
+ # 于是「lastOkAt 必须是对象」的守卫被绕过(2026-09-22 单测 ②f 实测踩到);老版本走 fallback 兼容。
74
+ $s = $null
75
+ try { $s = $raw | ConvertFrom-Json -DateKind String } catch { try { $s = $raw | ConvertFrom-Json } catch { break } }
76
+ if ($null -eq $s -or $s -is [array] -or $s -isnot [System.Management.Automation.PSCustomObject]) { break }
77
+ # lastOkAt 必须存在且为「对象」(DateKind 不可用的老版本里 JSON 串会变 [datetime] ⇒ 一并算损坏)
78
+ if ($s.lastOkAt -isnot [System.Management.Automation.PSCustomObject]) { break }
79
+ $wanted = @('data', 'runtime', 'vault') # 关注面(与引擎的三对象一致)
80
+ $never = @(); $items = @(); $bad = $false
81
+ foreach ($k in $wanted) {
82
+ $v = $s.lastOkAt.$k
83
+ if ($null -eq $v -or ($v -is [string] -and [string]::IsNullOrWhiteSpace($v))) { $never += $k; continue }
84
+ if ($v -is [datetime]) { $items += [pscustomobject]@{ Key = $k; Utc = $v.ToUniversalTime() }; continue }
85
+ $t = [datetime]::MinValue
86
+ $styles = [Globalization.DateTimeStyles]::AdjustToUniversal -bor [Globalization.DateTimeStyles]::AssumeUniversal
87
+ if ($v -is [string] -and [datetime]::TryParse($v, [Globalization.CultureInfo]::InvariantCulture, $styles, [ref]$t)) {
88
+ $items += [pscustomobject]@{ Key = $k; Utc = $t }; continue
89
+ }
90
+ $bad = $true; break # 键在但值不是可解析时间 ⇒ 文件坏了,不许静默当成"从未成功"
91
+ }
92
+ if ($bad) { break }
93
+ if ($never.Count -gt 0) { $result = [pscustomobject]@{ Kind = 'Never'; Never = $never; Items = $items; LastFailedAt = $null }; break }
94
+ if ($items.Count -eq 0) { break }
95
+ # 失败可见(B-07/B-12 的反面:备份静默停摆时托盘要看得出来)
96
+ $lf = $null
97
+ if ($s.lastFailedAt) {
98
+ $t2 = [datetime]::MinValue
99
+ $styles2 = [Globalization.DateTimeStyles]::AdjustToUniversal -bor [Globalization.DateTimeStyles]::AssumeUniversal
100
+ if ([datetime]::TryParse([string]$s.lastFailedAt, [Globalization.CultureInfo]::InvariantCulture, $styles2, [ref]$t2)) { $lf = $t2 }
101
+ }
102
+ $result = [pscustomobject]@{ Kind = 'Ok'; Never = @(); Items = $items; LastFailedAt = $lf }
103
+ } while ($false)
104
+ $script:backupStatusCache = @{ Store = $StorePath; Raw = $raw; State = $result }
105
+ return $result
106
+ }
107
+
108
+ # 返回一行给托盘顶部状态区。纯函数(除解析缓存)、可单测(tests/backup-status.test.ps1)。
109
+ # 显示的是**最旧**那个对象 ⇒ 任一对象陈旧都不会被掩盖;同刻按 data>runtime>vault。
110
+ # 引擎是否在位。**托盘不耦合备份引擎**(引擎由 personal-docs 的 config/tools 分发,不随 npm 包走)
111
+ # ⇒ 别人只装本包时,备份必须显式说"未就绪 + 怎么补",**不能**显示成"失败/坏了"(2026-09-22 用户问)
112
+ function Test-BackupEngine([string]$DshHomePath = $null) {
113
+ $h = if ($DshHomePath) { $DshHomePath } elseif ($env:DSH_HOME) { $env:DSH_HOME } else { Join-Path $env:USERPROFILE '.dsh' }
114
+ return (Test-Path -LiteralPath (Join-Path $h 'tools\dsh-backup.mjs'))
115
+ }
116
+ function Get-BackupStatusLine {
117
+ param(
118
+ [string]$StorePath = $BACKUP_STORE,
119
+ [int]$WarnHours = $BACKUP_WARN_HOURS,
120
+ [string]$EnginePath = $null # 仅供单测注入"引擎文件路径",默认走 Test-BackupEngine
121
+ )
122
+ if (-not $StorePath) {
123
+ $engineOk = if ($EnginePath) { Test-Path -LiteralPath $EnginePath } else { Test-BackupEngine }
124
+ if (-not $engineOk) { return '备份: 未就绪(缺备份引擎,见 README)⚠' }
125
+ return '备份: 未配置(首次「立即备份」会自动挑盘建库)'
126
+ }
127
+ $st = Read-BackupStatus -StorePath $StorePath
128
+ switch ($st.Kind) {
129
+ 'Missing' { return '备份: 未配置' }
130
+ 'Corrupt' { return '备份: 状态文件损坏' }
131
+ 'Never' { return ('备份: 从未成功(' + ($st.Never -join '/') + ')⚠') }
132
+ }
133
+ $nowUtc = (Get-Date).ToUniversalTime()
134
+ # 未来时间:**任一**对象在未来即报时钟异常(此前只判"最旧"那个 ⇒ data 未来会被新鲜的 runtime 掩盖)
135
+ $fut = $st.Items | Where-Object { $_.Utc -gt $nowUtc.AddMinutes(5) } | Sort-Object Utc -Descending | Select-Object -First 1
136
+ if ($fut) { return ('备份: 时钟异常 {0} +{1}h ⚠' -f $fut.Key, [math]::Round(($fut.Utc - $nowUtc).TotalHours, 1)) }
137
+ # 取最旧;**同刻时按关注面顺序取 data > runtime > vault**(此前同刻会随属性序取到 vault)
138
+ $wanted = @('data', 'runtime', 'vault')
139
+ $worst = $st.Items | Sort-Object Utc, @{ Expression = { [array]::IndexOf($wanted, $_.Key) } } | Select-Object -First 1
140
+ $age = [math]::Round(($nowUtc - $worst.Utc).TotalHours, 1)
141
+ $mark = if ($age -gt $WarnHours) { '⚠' } else { '✅' }
142
+ # 最近一次**失败**晚于最近一次**成功** ⇒ 先说失败(否则"1h 前 ✅"会掩盖"刚刚失败")
143
+ # ⚠ 比对基准 = **最近成功**(不是上面用于显示陈旧度的"最旧成功";证伪 FB-F9:文档与代码口径曾不一致)
144
+ $newest = $st.Items | Sort-Object Utc -Descending | Select-Object -First 1
145
+ if ($st.LastFailedAt -and $st.LastFailedAt -gt $newest.Utc) {
146
+ $fage = [math]::Round(($nowUtc - $st.LastFailedAt).TotalHours, 1)
147
+ return ('备份: 上次失败 {0}h 前 ⚠(状态停在 {1}h 前)' -f $fage, $age)
148
+ }
149
+ return ('备份: {0}h 前 {1}({2})' -f $age, $mark, $worst.Key)
150
+ }
151
+
19
152
  function Test-Port([int]$port) {
20
153
  $client = New-Object System.Net.Sockets.TcpClient
21
154
  try {
@@ -74,9 +207,81 @@ function Initialize-LogDir {
74
207
  }
75
208
  }
76
209
 
210
+ # ================= 插件配置与功能开关(plugin-config 统一模式,2026-09-23 立)=================
211
+ # 目录规范:<DSH_HOME>\plugin-config\<包名>.json(供后续插件沿用;`~/.dsh` 根已有 46 条目,配置不丢根)
212
+ # 读取纪律(方案定稿 §2):缺失 ⇒ 内置默认且**不创建文件**;损坏 / 类型不对 / version 高于已知 ⇒ 默认值,
213
+ # **绝不抛错**(任何配置问题都不得阻断托盘/动作/boot 的启动)。
214
+ # 落点(实现审 R4-A17 更正):本块在**函数定义之后**(Write-DshLog:163 / Initialize-LogDir:189 已定义),
215
+ # 但"设施就绪"要自己保证 ⇒ 记日志前先调一次 Initialize-LogDir(幂等)。否则在**没有日志目录**的机器上
216
+ # "坏配置 ⇒ 记一行"会静默丢失(实现审实测:全新 home ⇒ 零日志文件)。菜单构建在 dsh-tray.ps1:192(点源本文件之后)。
217
+ function Get-DshHomeDir {
218
+ if ($env:DSH_HOME) { return $env:DSH_HOME }
219
+ return (Join-Path $env:USERPROFILE '.dsh')
220
+ }
221
+ # 配置/写通道一律用本函数解析(与引擎、与 backup-store.txt 同口径)——写读不同口径会让
222
+ # 非默认数据目录的机器(DSH_HOME=D:\dsh 这类)开关**静默失效**(方案审 R1-07)
223
+ function Get-PluginConfigPath([string]$PluginName = $TRAY_PKG) {
224
+ return (Join-Path (Join-Path (Get-DshHomeDir) 'plugin-config') "$PluginName.json")
225
+ }
226
+ function Read-PluginConfig([string]$PluginName = $TRAY_PKG) {
227
+ $f = Get-PluginConfigPath $PluginName
228
+ if (-not (Test-Path -LiteralPath $f)) { return $null }
229
+ try {
230
+ $raw = Get-Content -LiteralPath $f -Raw
231
+ # ⚠ 顶层**数组**必须在这里挡住:`return (ConvertFrom-Json '[{…}]')` 会被 PowerShell 的
232
+ # 单元素数组解包成那个对象 ⇒ 下游 `-is [PSCustomObject]` 检查会漏(实现审 R4-P3-10 的形态之一,
233
+ # 2026-09-23 整改时实测:顶层数组曾误判为 v1 并采信 features.backup)
234
+ if ($raw.TrimStart().StartsWith('[')) { return $null }
235
+ return (ConvertFrom-Json -InputObject $raw)
236
+ } catch { return $null }
237
+ }
238
+ function Write-SwitchLog([string]$Message) {
239
+ # ⚠ 只建目录、**不做遗留迁移**(实现审 R5-N9:旧写法调 Initialize-LogDir 会把"启动清扫/日志迁移"
240
+ # 提前到点源期 —— 点源一个库就可能顺带搬动真机 logs 根的文件;迁移留给托盘/动作的显式启动点)
241
+ try { New-Item -ItemType Directory -Force -Path $LOG_DIR | Out-Null } catch { }
242
+ try { Write-DshLog -Path (Join-Path $LOG_DIR 'dsh-tray.log') -Message $Message } catch { }
243
+ }
244
+ # 单总开关 features.backup(默认关)。优先级:env(临时覆盖、不落盘)> 配置文件 > 默认
245
+ $script:BackupEnabled = $false
246
+ $script:BackupSwitchSource = 'default'
247
+ $switchRaw = ([string]$env:DSH_TRAY_BACKUP).Trim() # Trim:Windows `set VAR=1 ` 尾随空格是常见形态(R4-P3-11)
248
+ if ($switchRaw -match '^(?i)(1|true|on)$') { $script:BackupEnabled = $true; $script:BackupSwitchSource = 'env' }
249
+ elseif ($switchRaw -match '^(?i)(0|false|off)$') { $script:BackupEnabled = $false; $script:BackupSwitchSource = 'env' }
250
+ else {
251
+ $cfgObj = Read-PluginConfig
252
+ if ($cfgObj -and ($cfgObj -is [System.Management.Automation.PSCustomObject])) {
253
+ # version:**只接受整数类型**(`[int]`/`[long]`);**数字形字符串("1")也算未知 schema ⇒ 关**
254
+ # (实现审 R5-N2:旧实现接受 `^\d+$` 字符串 ⇒ 与"version 存在但非整数 ⇒ 关"的口径自相矛盾,
255
+ # 且未来 schema 用字符串写版本号时会被误当 v1 采信);无 version 键则按骨架允许(视作 v1)
256
+ $hasVer = ($null -ne $cfgObj.PSObject.Properties['version'])
257
+ $cfgVer = 0; $verOk = -not $hasVer; $verUnknown = $false
258
+ if ($hasVer) {
259
+ $vRaw = $cfgObj.version
260
+ if ($vRaw -is [int] -or $vRaw -is [long]) { $cfgVer = [int]$vRaw; $verOk = $true }
261
+ if (-not $verOk) { $verUnknown = $true; Write-SwitchLog "plugin-config: version='$vRaw' 不是整数 ⇒ 视为未知 schema ⇒ 按默认关闭" }
262
+ }
263
+ if ($verUnknown) { }
264
+ elseif ($cfgVer -gt 1) { Write-SwitchLog "plugin-config: version=$cfgVer 高于已知(1) ⇒ 按默认关闭(前向兼容,不猜新语义)" }
265
+ elseif ($cfgObj.features -and ($cfgObj.features.backup -is [bool])) {
266
+ $script:BackupEnabled = [bool]$cfgObj.features.backup
267
+ $script:BackupSwitchSource = 'config'
268
+ } else { Write-SwitchLog 'plugin-config: 未含 features.backup(bool) ⇒ 按默认关闭' }
269
+ } elseif ($cfgObj) {
270
+ Write-SwitchLog 'plugin-config: 根节点不是对象 ⇒ 按默认关闭'
271
+ } elseif (Test-Path -LiteralPath (Get-PluginConfigPath)) {
272
+ Write-SwitchLog 'plugin-config: 解析失败 ⇒ 按默认关闭'
273
+ }
274
+ }
275
+ # 库解析**随开关**:OFF ⇒ 不解析(对他人机器零盘符扫描;实现审 R4 插桩实测:OFF 三态调用数=0、ON 态=1)
276
+ $BACKUP_STORE = if ($script:BackupEnabled) { Resolve-BackupStore } else { $null }
277
+ $BACKUP_WARN_HOURS = 36
278
+
77
279
  function Get-WebPid {
78
280
  # 优化(1.0.12): netstat 解析优先 (原 Get-NetTCPConnection 在本机 727 进程下 avg 1.3s,
79
281
  # 直接拖慢右键菜单转态路径); 解析失败回退 CIM 查询
282
+ # 守卫 (2026-09-23 独审 P1-1 纵深防御): 源码态 $PORT=0 未回填时直接空返回,
283
+ # 不依赖"netstat 恰好匹配不到"这一隐式安全 — 宁可"未在运行"也不猜端口
284
+ if ($PORT -lt 1) { return @() }
80
285
  try {
81
286
  $pids = @()
82
287
  foreach ($row in (& netstat -ano -p tcp 2>$null)) {
@@ -91,11 +296,17 @@ function Start-Dsh {
91
296
  param([switch]$OpenBrowser = $true)
92
297
  $trayStarted = -not (Ensure-DshTray) # 先保证系统托盘在(幂等)
93
298
  $pids = @(Get-WebPid)
94
- if ($pids.Count -gt 0) {
95
- Write-Host "[dsh] 已在运行: $URL (PID $($pids -join ','))" -ForegroundColor Green
299
+ $dshPids = @($pids | Where-Object { Test-WebIdentity $_ })
300
+ if ($dshPids.Count -gt 0) {
301
+ Write-Host "[dsh] 已在运行: $URL (PID $($dshPids -join ','))" -ForegroundColor Green
96
302
  if ($trayStarted) { Write-Host '[tray] 系统托盘已自动启动(右键可控制 DSH)' -ForegroundColor DarkGray }
97
303
  if ($OpenBrowser) { Open-DshWeb }
98
- return
304
+ return $true
305
+ }
306
+ if ($pids.Count -gt 0) {
307
+ # 端口被非 dsh 进程占用(R2-P2#1 对侧补齐):不谎报"已在运行",明说并失败
308
+ Write-Host "[dsh] 端口 $PORT 被非 dsh 进程占用 (PID $($pids -join ',')), 无法启动 dsh" -ForegroundColor Yellow
309
+ return $false
99
310
  }
100
311
  Write-Host "[dsh] 启动中 (端口 $PORT)..."
101
312
  $out = Join-Path $LOG_DIR 'web-menu.out.log'
@@ -112,16 +323,33 @@ function Start-Dsh {
112
323
  if ($trayStarted) { Write-Host '[tray] 系统托盘已自动启动(右键可控制 DSH)' -ForegroundColor DarkGray }
113
324
  Start-Sleep -Seconds 2
114
325
  if ($OpenBrowser) { Open-DshWeb }
326
+ return $true
115
327
  } else {
116
328
  Write-Host "[dsh] 启动失败: 12 秒内端口 $PORT 未监听" -ForegroundColor Red
117
329
  Write-Host "[dsh] 错误日志: $err(末尾 10 行)"
118
330
  Get-Content $err -Tail 10 -ErrorAction SilentlyContinue
331
+ return $false
119
332
  }
120
333
  }
121
334
 
335
+ function Test-WebIdentity([int]$ProcessId) {
336
+ # 身份核对(2026-09-23 独审 P2-5 / R2-P2#1):监听 $PORT 的 PID 命令行必须含 bin.js(dsh web 入口特征)
337
+ # 才算 dsh —— 端口被其它服务占用时"不误杀、不谎报",Stop/Start/Status/boot 共用
338
+ # ⚠ 参数不能叫 $Pid(与只读自动变量 $PID 大小写不敏感冲突 ⇒ 调用即炸,2026-09-23 实况抓到)
339
+ $proc = Get-CimInstance Win32_Process -Filter "ProcessId=$ProcessId" -ErrorAction SilentlyContinue
340
+ return ($null -ne $proc -and $proc.CommandLine -match 'bin\.js')
341
+ }
342
+
122
343
  function Stop-Dsh {
123
344
  $pids = @(Get-WebPid)
124
- if ($pids.Count -eq 0) { Write-Host '[dsh] 未在运行'; return }
345
+ if ($pids.Count -eq 0) { Write-Host '[dsh] 未在运行'; return $true }
346
+ # 身份核对(P2-5):非 dsh 进程占用端口只报不杀
347
+ $foreign = @($pids | Where-Object { -not (Test-WebIdentity $_) })
348
+ if ($foreign.Count -gt 0) {
349
+ Write-Host "[dsh] 端口 $PORT 被非 dsh 进程占用 (PID $($foreign -join ',')), 不操作" -ForegroundColor Yellow
350
+ $pids = @($pids | Where-Object { $_ -notin $foreign })
351
+ if ($pids.Count -eq 0) { return $true }
352
+ }
125
353
  foreach ($p in $pids) {
126
354
  Write-Host "[dsh] 停止进程 $p"
127
355
  & taskkill /PID $p /T /F 2>$null | Out-Null
@@ -130,21 +358,21 @@ function Stop-Dsh {
130
358
  Start-Sleep -Seconds 1
131
359
  if (-not (Test-Port $PORT)) { break }
132
360
  }
133
- if (Test-Port $PORT) { Write-Host '[dsh] 端口未释放,请稍后重试' -ForegroundColor Red }
134
- else { Write-Host '[dsh] 已停止' -ForegroundColor Yellow }
361
+ if (Test-Port $PORT) { Write-Host '[dsh] 端口未释放,请稍后重试' -ForegroundColor Red; return $false }
362
+ else { Write-Host '[dsh] 已停止' -ForegroundColor Yellow; return $true }
135
363
  }
136
364
 
137
365
  function Restart-Dsh {
138
366
  Stop-Dsh
139
367
  Start-Sleep -Seconds 1
140
- Start-Dsh
368
+ return (Start-Dsh)
141
369
  }
142
370
 
143
371
  function Start-Tui {
144
372
  $existing = @(Get-TuiProcesses)
145
373
  if ($existing.Count -gt 0) {
146
374
  Write-Host "[tui] TUI 已在运行 (PID $($existing.ProcessId -join ',')), 不重复启动" -ForegroundColor Yellow
147
- return
375
+ return $true
148
376
  }
149
377
  # 清理上次强杀残留的外壳 (node 已死但 pwsh -NoExit 窗口还在)
150
378
  $stale = @(Get-TuiTree)
@@ -165,6 +393,14 @@ function Start-Tui {
165
393
  Start-Process 'powershell' -ArgumentList '-NoExit','-Command',$cmdLine
166
394
  Write-Host '[tui] 已在新 PowerShell 窗口启动 DSH TUI' -ForegroundColor Green
167
395
  }
396
+ # 验活(2026-09-23 独审 P2-3:外壳开出来但 dsh.cmd 起不来也报"已启动"= 谎报)——
397
+ # 轮询至多 6s 等 TUI 主进程 node 出现,出现才算成功
398
+ for ($i = 0; $i -lt 6; $i++) {
399
+ Start-Sleep -Seconds 1
400
+ if (@(Get-TuiProcesses).Count -gt 0) { Write-Host '[tui] TUI 主进程已在运行' -ForegroundColor Green; return $true }
401
+ }
402
+ Write-Host '[tui] TUI 主进程 6 秒内未出现,可能启动失败(看 TUI 窗口报错)' -ForegroundColor Red
403
+ return $false
168
404
  }
169
405
 
170
406
  # TUI 进程树: 外壳 pwsh(-NoExit) → cmd(dsh.cmd) → node(bin.js), 三层命令行都含 dsh-tui
@@ -172,9 +408,33 @@ function Start-Tui {
172
408
  # 优化(1.0.12): WQL 先按 Name 窄集 (原全量枚举会拉全部进程的 CommandLine);
173
409
  # ⚠️ 本机实测任何 Win32_Process 查询固定 ~450-570ms (WinRM 不可用, DCOM/持久会话均无效)——
174
410
  # **调用方 (托盘状态刷新) 必须在 ThreadJob 里异步跑, 禁止在 UI/菜单路径同步调用** (见 dsh-tray.ps1)
411
+ # ⚠️ 2026-09-23 误认修复(独审 P2-1 补全):web 的本地子进程执行器 dsh-subprocess-local/runner.js 命令行携带
412
+ # **整段工具调用文本**,任何命令提到 dsh-tui 就会被误认成 TUI。排除口径 = **自含 + 祖先链**:
413
+ # runner 自身命令行恒含该串;其派生的 pwsh/cmd/node 子孙(命令行带 dsh-tui 但不带该串)靠祖先链剔除
414
+ # (只挡自身会漏子孙 —— 独审复现:子进程视角仍命中 runner 派生的 pwsh)。
415
+ function Test-DshWorkerAncestor {
416
+ param([int]$Id, $Index)
417
+ $cur = $Id
418
+ for ($i = 0; $i -lt 32 -and $cur -gt 0; $i++) {
419
+ $n = $Index[[int]$cur]
420
+ if (-not $n) { return $false } # 父超出快照(进程刚退/系统根)⇒ 无法归因,保守保留
421
+ if ($n.CommandLine -and $n.CommandLine -match 'dsh-subprocess-local') { return $true }
422
+ $cur = [int]$n.ParentProcessId
423
+ }
424
+ return $false
425
+ }
175
426
  function Get-TuiTree {
176
- Get-CimInstance Win32_Process -Filter "Name='node.exe' OR Name='cmd.exe' OR Name='pwsh.exe' OR Name='powershell.exe'" -ErrorAction SilentlyContinue | Where-Object {
177
- $_.ProcessId -ne $PID -and $_.CommandLine -and $_.CommandLine -match 'dsh-tui'
427
+ # 单次窄集快照(锁⑨:禁全量枚举;锁⑫:查询管道带 $PID 自排除)+ 祖先链剔除(P2-1/P3-1:无跨查询竞态)。
428
+ # 匹配收紧为 `--profile\s+dsh-tui`(R2-P3-3:裸 'dsh-tui' 在工具文本里太常见;真 TUI 三层命令行恒为该形态)。
429
+ $snap = @(Get-CimInstance Win32_Process -Filter "Name='node.exe' OR Name='cmd.exe' OR Name='pwsh.exe' OR Name='powershell.exe'" -ErrorAction SilentlyContinue |
430
+ Where-Object { $_.ProcessId -ne $PID })
431
+ $byId = @{}
432
+ foreach ($p in $snap) { $byId[[int]$p.ProcessId] = $p }
433
+ # 补调用者自身入索引(祖先链走到自己时不断链;按 PID 单查、快、无竞态)
434
+ $me = Get-CimInstance Win32_Process -Filter "ProcessId=$PID" -ErrorAction SilentlyContinue
435
+ if ($me) { $byId[[int]$PID] = $me }
436
+ $snap | Where-Object {
437
+ $_.CommandLine -and $_.CommandLine -match '--profile\s+dsh-tui' -and -not (Test-DshWorkerAncestor -Id ([int]$_.ProcessId) -Index $byId)
178
438
  }
179
439
  }
180
440
 
@@ -185,12 +445,23 @@ function Get-TuiProcesses {
185
445
 
186
446
  function Stop-Tui {
187
447
  $tree = @(Get-TuiTree)
188
- if ($tree.Count -eq 0) { Write-Host '[tui] TUI: 未在运行'; return }
448
+ if ($tree.Count -eq 0) { Write-Host '[tui] TUI: 未在运行'; return $true }
449
+ # 诊断(2026-09-23 LJS 疑案:tui-stop 后 web 掉线):动手前把匹配到的树**全部落动作日志**
450
+ # (PID/名/父PID/命令行)。Get-TuiTree 是 'dsh-tui' 子串匹配,web 的工具执行器
451
+ # (dsh-subprocess-local/runner.js,命令行携带整段工具调用文本)可能被误认 ⇒ 日志直接钉死真凶。
452
+ $actLog = Join-Path $LOG_DIR 'dsh-tray-actions.log'
453
+ Write-DshLog -Path $actLog -Message "[tui] Stop-Tui 匹配 $($tree.Count) 个进程,动手前快照:"
454
+ foreach ($p in $tree) {
455
+ $cmd = ($p.CommandLine -replace "`r?`n", ' ').Trim()
456
+ if ($cmd.Length -gt 400) { $cmd = $cmd.Substring(0, 400) + '…' }
457
+ Write-DshLog -Path $actLog -Message (" PID {0} {1} 父 {2} :: {3}" -f $p.ProcessId, $p.Name, $p.ParentProcessId, $cmd)
458
+ }
189
459
  # 从树根整树终止 (根 = 父进程不在树中的进程, 即 pwsh -NoExit 外壳)。
190
460
  # 只杀 node 会导致外壳与窗口残留, 终端停在 raw mode 刷乱码且关不掉
191
461
  $ids = @($tree | ForEach-Object { $_.ProcessId })
192
462
  $roots = @($tree | Where-Object { $ids -notcontains $_.ParentProcessId })
193
463
  foreach ($r in $roots) {
464
+ Write-DshLog -Path $actLog -Message "[tui] 终止进程树 根 PID $($r.ProcessId) $($r.Name)"
194
465
  Write-Host "[tui] 停止 TUI 进程树 (根 PID $($r.ProcessId) $($r.Name))"
195
466
  & taskkill /PID $r.ProcessId /T /F 2>$null | Out-Null
196
467
  }
@@ -198,7 +469,7 @@ function Stop-Tui {
198
469
  Start-Sleep -Milliseconds 600
199
470
  if (@(Get-TuiTree).Count -eq 0) { break }
200
471
  }
201
- if (@(Get-TuiTree).Count -gt 0) { Write-Host '[tui] TUI 进程未完全退出,请稍后重试' -ForegroundColor Red }
472
+ if (@(Get-TuiTree).Count -gt 0) { Write-Host '[tui] TUI 进程未完全退出,请稍后重试' -ForegroundColor Red; return $false }
202
473
  else {
203
474
  # 收尾: 关闭专为 TUI 开的 Windows Terminal 窗口。
204
475
  # 强杀外壳退出码非 0, WT 默认 closeOnExit=graceful 不自动关标签, 会留死面板;
@@ -208,13 +479,16 @@ function Stop-Tui {
208
479
  foreach ($w in $wts) {
209
480
  $kids = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { $_.ParentProcessId -eq $w.ProcessId })
210
481
  if ($kids.Count -eq 0) {
482
+ Write-DshLog -Path $actLog -Message "[tui] 关闭 TUI 终端窗口 (PID $($w.ProcessId))"
211
483
  Write-Host "[tui] 关闭 TUI 终端窗口 (PID $($w.ProcessId))"
212
484
  & taskkill /PID $w.ProcessId /F 2>$null | Out-Null
213
485
  } else {
486
+ Write-DshLog -Path $actLog -Message "[tui] 终端窗口 (PID $($w.ProcessId)) 还有其他标签页, 保留"
214
487
  Write-Host "[tui] 终端窗口 (PID $($w.ProcessId)) 还有其他标签页, 保留" -ForegroundColor DarkGray
215
488
  }
216
489
  }
217
490
  Write-Host '[tui] TUI 已停止' -ForegroundColor Yellow
491
+ return $true
218
492
  }
219
493
  }
220
494
 
@@ -353,10 +627,18 @@ function Show-Status {
353
627
  # ---- 系统托盘 ----
354
628
 
355
629
  function Get-TrayProcesses {
356
- Get-CimInstance Win32_Process -Filter "Name='pwsh.exe' OR Name='powershell.exe'" -ErrorAction SilentlyContinue | Where-Object {
357
- $_.CommandLine -and $_.CommandLine -match 'dsh-tray\.ps1' -and
358
- $_.ProcessId -ne $PID
359
- }
630
+ # 与 Get-TuiTree 同构:单次窄集快照(锁⑨/⑫)+ 祖先链剔除 runner 子孙(R2-P2#3:
631
+ # 'dsh-tray.ps1' 子串同会被 web 工具执行器子孙误命中 ⇒ 漏启托盘/谎报运行中)
632
+ $snap = @(Get-CimInstance Win32_Process -Filter "Name='node.exe' OR Name='cmd.exe' OR Name='pwsh.exe' OR Name='powershell.exe'" -ErrorAction SilentlyContinue |
633
+ Where-Object { $_.ProcessId -ne $PID })
634
+ $byId = @{}
635
+ foreach ($p in $snap) { $byId[[int]$p.ProcessId] = $p }
636
+ $me = Get-CimInstance Win32_Process -Filter "ProcessId=$PID" -ErrorAction SilentlyContinue
637
+ if ($me) { $byId[[int]$PID] = $me }
638
+ @($snap | Where-Object {
639
+ $_.Name -in @('pwsh.exe', 'powershell.exe') -and $_.CommandLine -and $_.CommandLine -match 'dsh-tray\.ps1' -and
640
+ -not (Test-DshWorkerAncestor -Id ([int]$_.ProcessId) -Index $byId)
641
+ })
360
642
  }
361
643
 
362
644
  # 确保托盘在运行(幂等): 已在运行返回 $true; 刚拉起返回 $false
@@ -364,10 +646,13 @@ function Ensure-DshTray {
364
646
  $existing = @(Get-TrayProcesses)
365
647
  if ($existing.Count -gt 0) { return $true }
366
648
  $args = @('-NoProfile','-ExecutionPolicy','Bypass','-WindowStyle','Hidden','-File',$TRAY_SCRIPT)
367
- if (Test-Path $PWSH) {
368
- Start-Process -FilePath $PWSH -ArgumentList $args -WindowStyle Hidden | Out-Null
369
- } else {
370
- Start-Process -FilePath 'powershell.exe' -ArgumentList $args -WindowStyle Hidden | Out-Null
649
+ try {
650
+ # R2-P3-5:托盘只是可选件,$PWSH 失效(升级/换路径)不该阻断 web 启动 —— 起不来降级为 $false 并留痕
651
+ if (Test-Path $PWSH) { Start-Process -FilePath $PWSH -ArgumentList $args -WindowStyle Hidden | Out-Null }
652
+ else { Start-Process -FilePath 'powershell.exe' -ArgumentList $args -WindowStyle Hidden | Out-Null }
653
+ } catch {
654
+ Write-Host "[tray] 托盘启动失败(不影响 dsh): $($_.Exception.Message)" -ForegroundColor DarkGray
655
+ return $false
371
656
  }
372
657
  return $false
373
658
  }
@@ -1,4 +1,4 @@
1
- # dsh (DeepSeek Harness) 启动器 —— 公司机部署 2026-08-14
1
+ # dsh (DeepSeek Harness) 启动器 —— 公司机部署 2026-08-14
2
2
  # 用法:
3
3
  # 交互菜单: 双击 dsh-menu.bat
4
4
  # 非交互: dsh-menu.ps1 -Cmd start|stop|status
@@ -9,9 +9,12 @@ param(
9
9
  )
10
10
  $ErrorActionPreference = 'Stop'
11
11
 
12
- $DSH_CMD = 'D:\Program Files (x86)\dsh\dsh.cmd'
13
- $PORT = 3080
14
- $URL = 'http://127.0.0.1:3080'
12
+ # 路径/端口不写死(release-standards §7;2026-09-22 去 `D:\Program Files (x86)\...` + 3080 字面量):
13
+ # 点源同目录 dsh-lib.ps1,用 install.ps1 [6/7] 回填的 $DSH_CMD / $PORT(源码侧 = SET-BY-INSTALL-PS1 占位)。
14
+ $lib = Join-Path $PSScriptRoot 'dsh-lib.ps1'
15
+ if (-not (Test-Path -LiteralPath $lib)) { throw "dsh-lib.ps1 not found next to dsh-menu.ps1: $lib" }
16
+ . $lib
17
+ $URL = "http://127.0.0.1:$PORT"
15
18
 
16
19
  function Test-Port([int]$port) {
17
20
  $client = New-Object System.Net.Sockets.TcpClient