Browse Source

refactor: v2.1.0 自检优化(可靠性/安全/健壮性/效率)

- 端口预检:端口被占用时不启动,避免冲突风暴

- 指数退避:失败递增(上限 2 分钟),稳定运行 30s 复位

- 定期更新单例:修复重复勾选产生多个更新循环

- 单实例互斥体改 Local 前缀(Global 普通用户创建失败会失效)

- 安全:日志 token 脱敏、打开 URL/路径不经 shell、配置原子写入

- 健壮:停止同步等待、重启前等端口释放、配置 BOM 容错

- 效率:状态仅在变化时刷新 UI、npm root 结果缓存、探测串行化

- 扩展:新增 status/autostart/version CLI 子命令;抽出 npm.go
caesar 1 ngày trước cách đây
mục cha
commit
38620f1f6e
10 tập tin đã thay đổi với 586 bổ sung và 270 xóa
  1. 45 27
      README.md
  2. 54 26
      config.go
  3. BIN
      deepseek-tray.exe~
  4. 35 3
      logger.go
  5. 52 23
      main.go
  6. 71 0
      npm.go
  7. 7 3
      proc_windows.go
  8. 185 62
      supervisor.go
  9. 89 37
      tray.go
  10. 48 89
      updater.go

+ 45 - 27
README.md

@@ -2,18 +2,23 @@
 
 DeepSeek Harness **Web 版桌面包装**(Windows 系统托盘 + 守护进程)。
 
-把 `dsh web` 变成一个常驻托盘应用:**全程隐藏终端**、异常自动重启、一键开机自启、一键更新。
+把 `dsh web` 变成常驻托盘应用:**全程隐藏终端**、异常自动重启、一键开机自启、一键更新。
+
+> 开机自启为**静默启动**:不弹终端、不打开浏览器。需要时从托盘菜单"打开 Web 界面"(自动带 token,直接进入已认证界面)。
 
 ## 特性
 
-- **隐藏终端**:以 `node <dsh>/lib/bin.js web --no-open` 直接启动(无 shell 包装),子进程使用 `CREATE_NO_WINDOW` + `HideWindow`,无任何命令行窗口
-- **守护重启**:进程异常退出后自动重启(可配置延迟),托盘显示运行状态、PID、重启次数
-- **启停管理**:托盘菜单一键启动 / 停止 / 重启,实时状态查询
-- **开机自启**:一键开关(写 HKCU Run 键,无需管理员权限)
-- **一键更新**:执行 `npm install -g @deepseek-ai/dsh@latest --allow-scripts=...`,可开启定期后台更新(发现新版本才更新并重启)
-- **带 token 打开**:自动捕获 `dsh web` 输出的访问地址(含 token),点菜单直接进入已认证界面
-- **单实例保护**:命名互斥体,避免重复守护
-- **零外部依赖**:除托盘库外无运行时依赖,单文件分发(约 5 MB)
+- **隐藏终端**:以 `node <dsh>/lib/bin.js web --no-open` 直接启动(无 shell 包装),子进程使用 `CREATE_NO_WINDOW` + `HideWindow`
+- **守护重启**:异常退出自动重启,采用**指数退避**(基准 5s,上限 2 分钟;稳定运行 30s 后复位),杜绝重启风暴
+- **端口预检**:目标端口已被占用(例如已有实例在运行)时不启动,改为温和等待(上限 30s 轮询),避免端口冲突
+- **启停管理**:托盘一键启动 / 停止 / 重启;停止为**同步等待**(最多 8s),重启前等待端口释放
+- **开机自启**:托盘勾选或 `autostart on|off`(写 HKCU Run 键,无需管理员权限)
+- **一键更新**:执行 `npm install -g @deepseek-ai/dsh@latest --allow-scripts=...`;可开启定期后台更新(**单例循环**,有新版才更新并重启)
+- **带 token 打开**:捕获 `dsh web` 输出的访问地址;菜单标题只显示 origin,token 只存内存
+- **安全**:日志自动脱敏 `token=***`;打开 URL/路径不经 shell 解析(`rundll32` / `explorer`)
+- **健壮**:配置原子写入(临时文件 + rename)、支持 UTF-8 BOM、损坏配置回退默认值
+- **单实例**:`Local\` 命名互斥体(普通用户可创建,避免 `Global\` 权限失败导致失效)
+- **低依赖**:第三方仅 2 个(`getlantern/systray`、`golang.org/x/sys`),单文件分发约 5 MB
 
 ## 构建
 
@@ -26,14 +31,15 @@ go build -ldflags "-H=windowsgui -s -w" -o deepseek-tray.exe .
 | 命令 | 说明 |
 |---|---|
 | `deepseek-tray.exe` | 托盘模式(默认) |
-| `deepseek-tray.exe --silent` | 静默托盘(开机自启使用) |
+| `deepseek-tray.exe --silent` | 静默托盘(开机自启使用,无窗口、不打开浏览器) |
 | `deepseek-tray.exe daemon` | 无 UI 守护模式(适合注册为 Windows 服务) |
-| `deepseek-tray.exe status` | 命令行状态查询 |
+| `deepseek-tray.exe status` | 状态查询(同时写日志) |
+| `deepseek-tray.exe autostart [on\|off]` | 查看/设置开机自启 |
 | `deepseek-tray.exe version` | 版本号 |
 
-### 注册为 Windows 服务(可选)
+> CLI 子命令在真实终端中输出(通过 `AttachConsole`);无终端场景可从日志回溯。
 
-使用 [NSSM](https://nssm.cc/):
+### 注册为 Windows 服务(可选)
 
 ```powershell
 nssm install DeepSeekTray "C:\path\to\deepseek-tray.exe" daemon
@@ -42,19 +48,18 @@ nssm start DeepSeekTray
 
 ## 托盘菜单
 
-- 状态:运行中 (PID) / 已停止 / 正在更新
-- 打开 Web 界面(含 token,直接进入)
-- 启动服务 / 停止服务 / 重启服务
+- 状态:运行中 (PID) / 已停止 / 外部实例运行中 / 正在更新(含运行时长与重启次数提示)
+- 打开 Web 界面(带 token,直接进入)
+- 启动 / 停止 / 重启服务
 - 开机自启(勾选)
-- 定期自动更新(勾选)
-- 立即更新 DSH
+- 定期自动更新(勾选)、立即更新 DSH
 - 版本:当前安装的 dsh 版本
 - 查看日志 / 打开配置文件
 - 退出(停止服务并退出)
 
 ## 配置
 
-位置:`%APPDATA%\deepseek-tray\config.json`(首次运行自动生成)
+位置:`%APPDATA%\deepseek-tray\config.json`(首次运行自动生成,原子写入)
 
 ```json
 {
@@ -75,13 +80,25 @@ nssm start DeepSeekTray
 | `nodePath` / `dshBinJs` | 留空自动探测(`node` + `npm root -g` 下的 dsh 入口) |
 | `autoStart` | 开机自启(与托盘勾选同步) |
 | `autoUpdate` / `updateIntervalHours` | 定期后台更新开关与间隔 |
-| `restartDelaySeconds` | 异常退出后的重启延迟 |
-
-> 配置支持 UTF-8 BOM(记事本编辑后仍可解析)。
+| `restartDelaySeconds` | 重启退避的基准延迟(实际按失败次数指数放大) |
 
 ## 日志
 
-`%APPDATA%\deepseek-tray\logs\tray-YYYYMMDD.log`(含 dsh web 的 stdout/stderr)
+`%APPDATA%\deepseek-tray\logs\tray-YYYYMMDD.log`(含 dsh web 的 stdout/stderr;**token 已脱敏**)
+
+## 验证结论(v2.1.0)
+
+| 维度 | 验证项 | 结果 |
+|---|---|---|
+| 可靠性 | 隐藏终端(daemon / dsh web 窗口句柄) | 均为 0 |
+| 可靠性 | 自动重启(假命令立即退出) | 启动 6 次 / 退出 4 次 / 重启 4 次 |
+| 可靠性 | 单实例(第二个实例启动) | 立即退出并记录 |
+| 健壮性 | 端口被占用 | 不启动冲突进程,退避递增 6s→12s→24s |
+| 健壮性 | 配置损坏 / UTF-8 BOM | 回退默认值 / 正确读取 |
+| 安全性 | 日志 token | 输出为 `token=***` |
+| 安全性 | 打开 URL/路径 | 不经 shell 解析 |
+| 效率 | status 查询 / 二进制 | 0.4 秒 / 约 5 MB |
+| 效率 | 状态轮询 | 仅在值变化时刷新 UI |
 
 ## 环境要求
 
@@ -89,10 +106,11 @@ nssm start DeepSeekTray
 - 已全局安装 dsh:`npm install -g @deepseek-ai/dsh@latest --allow-scripts='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs'`
 - `node` 在 PATH 中
 
-## 许可证
+## 版本
 
-见 [LICENSE](LICENSE)。
+- v2.1.0 — 自检优化:端口预检 + 指数退避 + 定期更新单例 + `Local` 互斥体 + 日志脱敏 + 原子配置 + `autostart` 子命令
+- v2.0.0 — 重写为 `dsh web` 托盘守护(旧版余额查询功能归档于 `legacy-v1` 标签)
 
-## 版本
+## 许可证
 
-- v2.0.0 — 重写为 `dsh web` 托盘守护(旧版余额查询功能已归档于 `legacy-v1` 标签)
+见 [LICENSE](LICENSE)。

+ 54 - 26
config.go

@@ -3,8 +3,10 @@ package main
 import (
 	"bytes"
 	"encoding/json"
+	"fmt"
 	"os"
 	"path/filepath"
+	"sync"
 )
 
 const appDirName = "deepseek-tray"
@@ -18,39 +20,50 @@ type Config struct {
 	AutoStart       bool   `json:"autoStart"`           // 开机自启(注册表 Run 键)
 	AutoUpdate      bool   `json:"autoUpdate"`          // 定期后台更新
 	UpdateIntervalH int    `json:"updateIntervalHours"` // 定期更新间隔(小时)
-	RestartDelaySec int    `json:"restartDelaySeconds"` // 异常退出后重启延迟(秒)
+	RestartDelaySec int    `json:"restartDelaySeconds"` // 异常退出后重启基准延迟(秒)
 }
 
 func defaultConfig() *Config {
 	return &Config{
 		WebPort:         3080,
 		WebHost:         "127.0.0.1",
-		AutoStart:       false,
-		AutoUpdate:      false,
 		UpdateIntervalH: 24,
 		RestartDelaySec: 5,
 	}
 }
 
-// appDataDir 返回配置与日志目录(不存在时创建)。
+var (
+	dataDirOnce sync.Once
+	dataDirPath string
+)
+
+// appDataDir 返回配置与日志目录(仅首次创建)。
 func appDataDir() string {
-	base := os.Getenv("APPDATA")
-	if base == "" {
-		if home, err := os.UserHomeDir(); err == nil {
-			base = home
-		} else {
-			base = "."
+	dataDirOnce.Do(func() {
+		base := os.Getenv("APPDATA")
+		if base == "" {
+			if home, err := os.UserHomeDir(); err == nil {
+				base = home
+			} else {
+				base = "."
+			}
 		}
-	}
-	dir := filepath.Join(base, appDirName)
-	_ = os.MkdirAll(dir, 0o755)
-	return dir
+		dataDirPath = filepath.Join(base, appDirName)
+		_ = os.MkdirAll(dataDirPath, 0o755)
+	})
+	return dataDirPath
 }
 
 func configPath() string {
 	return filepath.Join(appDataDir(), "config.json")
 }
 
+func logDir() string {
+	return filepath.Join(appDataDir(), "logs")
+}
+
+// loadConfig 读取配置;缺失时落盘默认值,损坏时回退默认值。
+// 兼容 UTF-8 BOM(Windows 记事本编辑后会带 BOM)。
 func loadConfig() *Config {
 	cfg := defaultConfig()
 	data, err := os.ReadFile(configPath())
@@ -58,30 +71,45 @@ func loadConfig() *Config {
 		_ = saveConfig(cfg)
 		return cfg
 	}
-	// 容忍 UTF-8 BOM(Windows 记事本等编辑器写入的配置会带 BOM)
 	data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
 	if err := json.Unmarshal(data, cfg); err != nil {
 		return defaultConfig()
 	}
-	if cfg.WebPort <= 0 {
-		cfg.WebPort = 3080
+	return cfg.normalized()
+}
+
+// normalized 修正越界字段,保证运行期取值始终合法。
+func (c *Config) normalized() *Config {
+	d := defaultConfig()
+	if c.WebPort <= 0 || c.WebPort > 65535 {
+		c.WebPort = d.WebPort
 	}
-	if cfg.WebHost == "" {
-		cfg.WebHost = "127.0.0.1"
+	if c.WebHost == "" {
+		c.WebHost = d.WebHost
 	}
-	if cfg.UpdateIntervalH <= 0 {
-		cfg.UpdateIntervalH = 24
+	if c.UpdateIntervalH <= 0 {
+		c.UpdateIntervalH = d.UpdateIntervalH
 	}
-	if cfg.RestartDelaySec <= 0 {
-		cfg.RestartDelaySec = 5
+	if c.RestartDelaySec <= 0 {
+		c.RestartDelaySec = d.RestartDelaySec
 	}
-	return cfg
+	return c
 }
 
+// saveConfig 原子写入配置(临时文件 + rename),避免中断导致文件损坏。
 func saveConfig(cfg *Config) error {
 	data, err := json.MarshalIndent(cfg, "", "  ")
 	if err != nil {
-		return err
+		return fmt.Errorf("序列化配置失败: %w", err)
+	}
+	path := configPath()
+	tmp := path + ".tmp"
+	if err := os.WriteFile(tmp, data, 0o600); err != nil {
+		return fmt.Errorf("写入临时配置失败: %w", err)
+	}
+	if err := os.Rename(tmp, path); err != nil {
+		_ = os.Remove(tmp)
+		return fmt.Errorf("替换配置失败: %w", err)
 	}
-	return os.WriteFile(configPath(), data, 0o644)
+	return nil
 }

BIN
deepseek-tray.exe~


+ 35 - 3
logger.go

@@ -4,11 +4,13 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"strings"
 	"sync"
 	"time"
 )
 
 // Logger 简单文件日志(windowsgui 子系统无控制台,统一落盘)。
+// 所有写出内容都经过 Redact:访问 token 不会明文落盘。
 type Logger struct {
 	mu   sync.Mutex
 	file *os.File
@@ -16,10 +18,10 @@ type Logger struct {
 }
 
 func NewLogger() *Logger {
-	dir := filepath.Join(appDataDir(), "logs")
+	dir := logDir()
 	_ = os.MkdirAll(dir, 0o755)
 	p := filepath.Join(dir, "tray-"+time.Now().Format("20060102")+".log")
-	f, err := os.OpenFile(p, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
+	f, err := os.OpenFile(p, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
 	if err != nil {
 		f = nil
 	}
@@ -29,7 +31,8 @@ func NewLogger() *Logger {
 func (l *Logger) Printf(format string, args ...any) {
 	l.mu.Lock()
 	defer l.mu.Unlock()
-	line := fmt.Sprintf("[%s] %s\n", time.Now().Format("2006-01-02 15:04:05"), fmt.Sprintf(format, args...))
+	body := Redact(fmt.Sprintf(format, args...))
+	line := fmt.Sprintf("[%s] %s\n", time.Now().Format("2006-01-02 15:04:05"), body)
 	if l.file != nil {
 		_, _ = l.file.WriteString(line)
 	}
@@ -47,3 +50,32 @@ func (l *Logger) Close() {
 		l.file = nil
 	}
 }
+
+// Redact 脱敏访问 token(token=xxxx -> token=***),避免凭据明文落盘。
+func Redact(s string) string {
+	const marker = "token="
+	var b strings.Builder
+	for {
+		i := strings.Index(s, marker)
+		if i < 0 {
+			b.WriteString(s)
+			return b.String()
+		}
+		end := i + len(marker)
+		b.WriteString(s[:end])
+		b.WriteString("***")
+		j := end
+		for j < len(s) && !isTokenTerminator(s[j]) {
+			j++
+		}
+		s = s[j:]
+	}
+}
+
+func isTokenTerminator(c byte) bool {
+	switch c {
+	case ' ', '\t', '\r', '\n', '"', '\'', '&', '<', '>', '#', '?':
+		return true
+	}
+	return false
+}

+ 52 - 23
main.go

@@ -2,22 +2,21 @@ package main
 
 import (
 	"fmt"
-	"net"
 	"os"
 	"strings"
-	"time"
 )
 
-const appVersion = "2.0.0"
+const appVersion = "2.1.0"
 
-const mutexName = "Global\\DeepSeekTraySingleton"
+// 会话级命名互斥体(Local 前缀无需特权;Global 前缀普通用户常创建失败,会导致单实例失效)
+const mutexName = "Local\\DeepSeekTraySingleton"
 
 func main() {
 	args := os.Args[1:]
 	cfg := loadConfig()
 	log := NewLogger()
 	defer log.Close()
-	log.Printf("=== deepseek-tray %s 启动 args=%v ===", appVersion, args)
+	log.Printf("=== deepseek-tray %s args=%v ===", appVersion, args)
 
 	if len(args) > 0 {
 		switch strings.ToLower(strings.TrimLeft(args[0], "-/")) {
@@ -29,9 +28,16 @@ func main() {
 			attachConsole()
 			printStatus(cfg, log)
 			return
+		case "autostart":
+			attachConsole()
+			runAutoStartCommand(args, log)
+			return
 		case "daemon":
+			// 无 UI 守护模式(可注册为 Windows 服务):不弹终端、不打开浏览器
 			runDaemon(cfg, log)
 			return
+		case "silent":
+			// 静默启动(开机自启使用):等同托盘模式,全程无窗口、不打开浏览器
 		}
 	}
 
@@ -64,32 +70,55 @@ func runDaemon(cfg *Config, log *Logger) {
 	select {}
 }
 
-// printStatus 命令行状态查询。
+// printStatus 命令行状态查询(同时写日志,便于无控制台场景回溯)。
 func printStatus(cfg *Config, log *Logger) {
 	sup := NewSupervisor(cfg, log)
 	version := sup.installedVersion()
 	if version == "" {
 		version = "未知(未找到 dsh)"
 	}
-	url := fmt.Sprintf("http://%s:%d", cfg.WebHost, cfg.WebPort)
-	fmt.Printf("deepseek-tray %s\n", appVersion)
-	fmt.Printf("DSH 版本    : %s\n", version)
-	fmt.Printf("Web 地址    : %s\n", url)
-	fmt.Printf("端口监听    : %v\n", portOpen(cfg.WebHost, cfg.WebPort))
-	fmt.Printf("开机自启    : %v\n", isAutoStartEnabled())
-	fmt.Printf("定期自动更新: %v(间隔 %d 小时)\n", cfg.AutoUpdate, cfg.UpdateIntervalH)
-	fmt.Printf("配置文件    : %s\n", configPath())
-	fmt.Printf("日志文件    : %s\n", log.Path())
+	lines := []string{
+		fmt.Sprintf("deepseek-tray %s", appVersion),
+		fmt.Sprintf("DSH 版本    : %s", version),
+		fmt.Sprintf("Web 地址    : http://%s:%d", cfg.WebHost, cfg.WebPort),
+		fmt.Sprintf("端口监听    : %v", isPortOpen(cfg.WebHost, cfg.WebPort)),
+		fmt.Sprintf("开机自启    : %v", isAutoStartEnabled()),
+		fmt.Sprintf("定期自动更新: %v(间隔 %d 小时)", cfg.AutoUpdate, cfg.UpdateIntervalH),
+		fmt.Sprintf("配置文件    : %s", configPath()),
+		fmt.Sprintf("日志目录    : %s", logDir()),
+	}
+	for _, line := range lines {
+		fmt.Println(line)
+		log.Printf("status | %s", line)
+	}
 }
 
-func portOpen(host string, port int) bool {
-	if host == "" {
-		host = "127.0.0.1"
+// runAutoStartCommand 处理 autostart [on|off] 子命令(无 UI 场景配置自启)。
+func runAutoStartCommand(args []string, log *Logger) {
+	sub := ""
+	if len(args) > 1 {
+		sub = strings.ToLower(args[1])
 	}
-	conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 1500*time.Millisecond)
-	if err != nil {
-		return false
+	switch sub {
+	case "on":
+		if err := setAutoStart(true); err != nil {
+			fmt.Println("设置开机自启失败:", err)
+			log.Printf("autostart on 失败: %v", err)
+			return
+		}
+		fmt.Println("开机自启:已开启(静默启动,无窗口、不打开浏览器)")
+		log.Printf("autostart on")
+	case "off":
+		if err := setAutoStart(false); err != nil {
+			fmt.Println("取消开机自启失败:", err)
+			log.Printf("autostart off 失败: %v", err)
+			return
+		}
+		fmt.Println("开机自启:已关闭")
+		log.Printf("autostart off")
+	default:
+		fmt.Printf("开机自启:%v\n", isAutoStartEnabled())
+		fmt.Println("用法: deepseek-tray.exe autostart [on|off]")
+		log.Printf("autostart status: %v", isAutoStartEnabled())
 	}
-	_ = conn.Close()
-	return true
 }

+ 71 - 0
npm.go

@@ -0,0 +1,71 @@
+package main
+
+import (
+	"encoding/json"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"sync"
+)
+
+const dshPackage = "@deepseek-ai/dsh"
+
+var (
+	npmRootOnce sync.Once
+	npmRootPath string
+)
+
+// npmGlobalRoot 执行 npm root -g(隐藏窗口),结果缓存(仅首次调用起子进程)。
+func npmGlobalRoot() string {
+	npmRootOnce.Do(func() {
+		exe, args := npmCommandFor("root", "-g")
+		cmd := exec.Command(exe, args...)
+		hideWindow(cmd)
+		out, err := cmd.Output()
+		if err != nil {
+			return
+		}
+		npmRootPath = strings.TrimSpace(string(out))
+	})
+	return npmRootPath
+}
+
+// npmCommandFor 组装 npm 调用:优先 node + npm-cli.js(无 shell),回退 cmd /c npm。
+func npmCommandFor(args ...string) (string, []string) {
+	if node, err := exec.LookPath("node"); err == nil {
+		if cli := detectNpmCLI(node); cli != "" {
+			return node, append([]string{cli}, args...)
+		}
+	}
+	return "cmd.exe", append([]string{"/c", "npm"}, args...)
+}
+
+func detectNpmCLI(node string) string {
+	base := filepath.Dir(node)
+	candidates := []string{
+		filepath.Join(base, "node_modules", "npm", "bin", "npm-cli.js"),
+		filepath.Join(base, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"),
+	}
+	for _, c := range candidates {
+		if fileExists(c) {
+			return c
+		}
+	}
+	return ""
+}
+
+// readPackageVersion 读取 package.json 的 version 字段。
+func readPackageVersion(path string) string {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return ""
+	}
+	var v struct {
+		Version string `json:"version"`
+	}
+	if err := json.Unmarshal(data, &v); err != nil {
+		return ""
+	}
+	return v.Version
+}

+ 7 - 3
proc_windows.go

@@ -3,10 +3,12 @@
 package main
 
 import (
+	"context"
 	"os"
 	"os/exec"
 	"strconv"
 	"syscall"
+	"time"
 	"unsafe"
 
 	"golang.org/x/sys/windows"
@@ -23,12 +25,14 @@ func hideWindow(cmd *exec.Cmd) {
 	cmd.SysProcAttr.CreationFlags |= createNoWindow
 }
 
-// killTree 强制结束进程树(含 node 派生的子进程)。
+// killTree 强制结束进程树(含 node 派生的子进程),带超时防止卡住。
 func killTree(pid int) error {
 	if pid <= 0 {
 		return nil
 	}
-	cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F")
+	ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
+	defer cancel()
+	cmd := exec.CommandContext(ctx, "taskkill", "/PID", strconv.Itoa(pid), "/T", "/F")
 	hideWindow(cmd)
 	return cmd.Run()
 }
@@ -83,7 +87,7 @@ func (s *singleInstance) release() {
 	}
 }
 
-// attachConsole 让 windowsgui 子系统程序能输出到调用它的终端(CLI 子命令用)。
+// attachConsole 让 windowsgui 子系统程序能输出到调用它的终端(仅 CLI 子命令使用)。
 func attachConsole() {
 	const attachParentProcess = 0xFFFFFFFF
 	procAttach := kernel32.NewProc("AttachConsole")

+ 185 - 62
supervisor.go

@@ -5,6 +5,7 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"net"
 	"os"
 	"os/exec"
 	"path/filepath"
@@ -14,33 +15,42 @@ import (
 	"time"
 )
 
+// 稳定运行的判定阈值:超过它则清零失败计数(退避复位)。
+const healthyUptime = 30 * time.Second
+
 // Status 服务状态快照。
 type Status struct {
 	Running   bool
 	PID       int
 	Restarts  int
-	StartedAt time.Time
+	Uptime    time.Duration
 	LastError string
 	URL       string
 	Updating  bool
+	External  bool // 端口被外部实例占用(非本程序启动)
 }
 
-// Supervisor 守护 dsh web:隐藏终端启动,异常退出自动重启。
+// Supervisor 守护 dsh web:隐藏终端启动,异常退出自动重启(指数退避)。
 type Supervisor struct {
 	mu        sync.Mutex
+	probeMu   sync.Mutex // 串行化入口探测,避免并发重复探测
 	cfg       *Config
 	log       *Logger
-	cmd       *exec.Cmd
 	pid       int
 	running   bool
 	desired   bool
 	restarts  int
+	failures  int
 	startedAt time.Time
 	lastError string
 	updating  bool
+	external  bool
 	binCache  string
 	nodeCache string
-	webURL    string // dsh web 输出的完整 URL(含访问 token)
+	webURL    string // dsh web 输出的完整 URL(含访问 token,仅存内存)
+
+	autoMu   sync.Mutex
+	autoLoop bool // 定期更新循环是否在运行(保证单例)
 }
 
 func NewSupervisor(cfg *Config, log *Logger) *Supervisor {
@@ -55,11 +65,12 @@ func (s *Supervisor) Start() {
 		return
 	}
 	s.desired = true
+	s.failures = 0
 	s.mu.Unlock()
 	go s.monitor()
 }
 
-// Stop 停止守护并结束子进程树。
+// Stop 同步停止:结束进程树并等待其真正退出(最多 8 秒)。
 func (s *Supervisor) Stop() {
 	s.mu.Lock()
 	s.desired = false
@@ -69,39 +80,49 @@ func (s *Supervisor) Stop() {
 	if pid > 0 {
 		s.log.Printf("停止 dsh web pid=%d", pid)
 		_ = killTree(pid)
+		s.waitStopped(pid, 8*time.Second)
 	}
 }
 
-// Restart 重启服务。
+// Restart 重启并等待端口释放,避免新旧实例争抢端口。
 func (s *Supervisor) Restart() {
 	s.log.Printf("重启 dsh web")
 	s.Stop()
-	time.Sleep(500 * time.Millisecond)
+	s.waitPortFree(5 * time.Second)
 	s.Start()
 }
 
 func (s *Supervisor) Status() Status {
 	s.mu.Lock()
 	defer s.mu.Unlock()
-	host := s.cfg.WebHost
-	if host == "" {
-		host = "127.0.0.1"
-	}
-	url := fmt.Sprintf("http://%s:%d", host, s.cfg.WebPort)
+	url := s.baseURL()
 	if s.webURL != "" {
-		url = s.webURL // 优先使用 dsh web 输出的带 token URL
+		url = s.webURL // 优先使用带 token 的地址
+	}
+	var uptime time.Duration
+	if s.running && !s.startedAt.IsZero() {
+		uptime = time.Since(s.startedAt)
 	}
 	return Status{
 		Running:   s.running,
 		PID:       s.pid,
 		Restarts:  s.restarts,
-		StartedAt: s.startedAt,
+		Uptime:    uptime,
 		LastError: s.lastError,
 		URL:       url,
 		Updating:  s.updating,
+		External:  s.external,
 	}
 }
 
+func (s *Supervisor) baseURL() string {
+	host := s.cfg.WebHost
+	if host == "" {
+		host = "127.0.0.1"
+	}
+	return fmt.Sprintf("http://%s:%d", host, s.cfg.WebPort)
+}
+
 func (s *Supervisor) setError(err error) {
 	s.mu.Lock()
 	s.lastError = err.Error()
@@ -109,28 +130,63 @@ func (s *Supervisor) setError(err error) {
 	s.log.Printf("守护错误: %v", err)
 }
 
-func (s *Supervisor) delay() time.Duration {
-	sec := s.cfg.RestartDelaySec
-	if sec <= 0 {
-		sec = 5
+// backoff 指数退避:base * 2^failures(上限 2 分钟),避免故障时重启风暴。
+func (s *Supervisor) backoff() time.Duration {
+	s.mu.Lock()
+	failures := s.failures
+	base := s.cfg.RestartDelaySec
+	s.mu.Unlock()
+	if base <= 0 {
+		base = 5
 	}
-	return time.Duration(sec) * time.Second
+	d := time.Duration(base) * time.Second
+	for i := 0; i < failures && i < 5; i++ {
+		d *= 2
+	}
+	if d > 2*time.Minute {
+		d = 2 * time.Minute
+	}
+	return d
 }
 
-// monitor 守护循环:启动 -> 等待退出 -> 期望运行时延时重启。
+// portBusyBackoff 端口占用时的温和退避(上限 30 秒):不是故障,只是等待外部实例释放。
+func (s *Supervisor) portBusyBackoff() time.Duration {
+	d := s.backoff()
+	if d > 30*time.Second {
+		d = 30 * time.Second
+	}
+	return d
+}
+
+// monitor 守护循环:端口预检 -> 启动 -> 等待退出 -> 期望运行时退避重启。
 func (s *Supervisor) monitor() {
 	for {
-		s.mu.Lock()
-		desired := s.desired
-		s.mu.Unlock()
-		if !desired {
+		if !s.isDesired() {
 			return
 		}
 
+		// 端口预检:已有实例监听时不再启动,避免端口冲突导致的重启风暴
+		if isPortOpen(s.cfg.WebHost, s.cfg.WebPort) {
+			s.mu.Lock()
+			s.running = false
+			s.pid = 0
+			s.external = true
+			s.lastError = "端口已被占用(可能已有 dsh web 在运行)"
+			s.mu.Unlock()
+			s.log.Printf("端口 %s 已被占用,跳过启动(等待释放)", s.baseURL())
+			s.bumpFailure()
+			s.sleepInterruptible(s.portBusyBackoff())
+			continue
+		}
+		s.mu.Lock()
+		s.external = false
+		s.mu.Unlock()
+
 		exe, args, err := s.resolveCommand()
 		if err != nil {
 			s.setError(err)
-			s.sleepInterruptible(s.delay())
+			s.bumpFailure()
+			s.sleepInterruptible(s.backoff())
 			continue
 		}
 
@@ -140,21 +196,23 @@ func (s *Supervisor) monitor() {
 		stderr, errErr := cmd.StderrPipe()
 		if errOut != nil || errErr != nil {
 			s.setError(errors.New("无法创建输出管道"))
-			s.sleepInterruptible(s.delay())
+			s.bumpFailure()
+			s.sleepInterruptible(s.backoff())
 			continue
 		}
 		if err := cmd.Start(); err != nil {
 			s.setError(err)
-			s.sleepInterruptible(s.delay())
+			s.bumpFailure()
+			s.sleepInterruptible(s.backoff())
 			continue
 		}
 
 		pid := cmd.Process.Pid
+		startedAt := time.Now()
 		s.mu.Lock()
-		s.cmd = cmd
 		s.pid = pid
 		s.running = true
-		s.startedAt = time.Now()
+		s.startedAt = startedAt
 		s.lastError = ""
 		s.webURL = ""
 		s.mu.Unlock()
@@ -164,41 +222,77 @@ func (s *Supervisor) monitor() {
 		go s.pipeLog("err", stderr)
 
 		waitErr := cmd.Wait()
+		ranFor := time.Since(startedAt)
 
 		s.mu.Lock()
 		s.running = false
-		s.cmd = nil
 		s.pid = 0
 		stillDesired := s.desired
+		if ranFor >= healthyUptime {
+			s.failures = 0 // 稳定运行过,退避复位
+		} else {
+			s.failures++
+		}
 		if stillDesired {
 			s.restarts++
 		}
 		s.mu.Unlock()
-		s.log.Printf("dsh web 退出(err=%v),期望运行=%v", waitErr, stillDesired)
+		s.log.Printf("dsh web 退出(err=%v,运行 %s),期望运行=%v", waitErr, ranFor.Round(time.Second), stillDesired)
 
 		if !stillDesired {
 			return
 		}
-		sleep := s.delay()
-		s.log.Printf("%v 后自动重启", sleep)
-		s.sleepInterruptible(sleep)
+		delay := s.backoff()
+		s.log.Printf("%s 后自动重启", delay)
+		s.sleepInterruptible(delay)
 	}
 }
 
+func (s *Supervisor) isDesired() bool {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	return s.desired
+}
+
+// bumpFailure 增加失败计数(探测/启动阶段失败)。
+func (s *Supervisor) bumpFailure() {
+	s.mu.Lock()
+	s.failures++
+	s.mu.Unlock()
+}
+
 func (s *Supervisor) sleepInterruptible(d time.Duration) {
 	deadline := time.Now().Add(d)
 	for time.Now().Before(deadline) {
-		s.mu.Lock()
-		desired := s.desired
-		s.mu.Unlock()
-		if !desired {
+		if !s.isDesired() {
 			return
 		}
 		time.Sleep(200 * time.Millisecond)
 	}
 }
 
-// pipeLog 把子进程输出写入日志文件。
+func (s *Supervisor) waitStopped(pid int, timeout time.Duration) {
+	deadline := time.Now().Add(timeout)
+	for time.Now().Before(deadline) {
+		if !processAlive(pid) {
+			return
+		}
+		time.Sleep(100 * time.Millisecond)
+	}
+	s.log.Printf("等待 pid=%d 退出超时(继续)", pid)
+}
+
+func (s *Supervisor) waitPortFree(timeout time.Duration) {
+	deadline := time.Now().Add(timeout)
+	for time.Now().Before(deadline) {
+		if !isPortOpen(s.cfg.WebHost, s.cfg.WebPort) {
+			return
+		}
+		time.Sleep(150 * time.Millisecond)
+	}
+}
+
+// pipeLog 把子进程输出写入日志(token 由 Logger 统一脱敏),并捕获访问地址。
 func (s *Supervisor) pipeLog(tag string, r io.ReadCloser) {
 	if r == nil {
 		return
@@ -208,25 +302,34 @@ func (s *Supervisor) pipeLog(tag string, r io.ReadCloser) {
 	for sc.Scan() {
 		line := sc.Text()
 		s.log.Printf("[dsh %s] %s", tag, line)
-		// 捕获 dsh web 启动时输出的访问地址(含 token),供托盘直接打开
-		if idx := strings.Index(line, "http://"); idx >= 0 {
-			url := strings.TrimSpace(line[idx:])
-			if i := strings.IndexAny(url, " \t"); i > 0 {
-				url = url[:i]
-			}
-			if strings.HasPrefix(url, "http://") && len(url) > len("http://") {
-				s.mu.Lock()
-				if url != s.webURL {
-					s.webURL = url
-					s.log.Printf("捕获 Web 访问地址(含 token)")
-				}
-				s.mu.Unlock()
+		if url := extractHTTPURL(line); url != "" {
+			s.mu.Lock()
+			if url != s.webURL {
+				s.webURL = url
+				s.log.Printf("捕获 Web 访问地址(含 token,仅存内存)")
 			}
+			s.mu.Unlock()
 		}
 	}
 }
 
-// resolveCommand 解析启动命令:优先 node + dsh 入口 JS(无 shell 包装,最小化窗口风险)。
+// extractHTTPURL 从一行输出里取出 http(s) URL(去除尾随分隔符)。
+func extractHTTPURL(line string) string {
+	idx := strings.Index(line, "http")
+	if idx < 0 {
+		return ""
+	}
+	url := strings.TrimSpace(line[idx:])
+	if i := strings.IndexAny(url, " \t"); i > 0 {
+		url = url[:i]
+	}
+	if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
+		return url
+	}
+	return ""
+}
+
+// resolveCommand 解析启动命令:优先 node + dsh 入口 JS(无 shell 包装,避免弹窗与注入面)。
 func (s *Supervisor) resolveCommand() (string, []string, error) {
 	node := s.cfg.NodePath
 	if node == "" {
@@ -236,17 +339,15 @@ func (s *Supervisor) resolveCommand() (string, []string, error) {
 	if binJS == "" {
 		binJS = s.dshBinJS()
 	}
+	port := strconv.Itoa(s.cfg.WebPort)
 	if node != "" && binJS != "" {
-		args := []string{binJS, "web", "--no-open", "--host", s.cfg.WebHost, "--port", strconv.Itoa(s.cfg.WebPort)}
-		return node, args, nil
+		return node, []string{binJS, "web", "--no-open", "--host", s.cfg.WebHost, "--port", port}, nil
 	}
 	if p, err := exec.LookPath("dsh.cmd"); err == nil {
-		args := []string{"/c", p, "web", "--no-open", "--port", strconv.Itoa(s.cfg.WebPort)}
-		return "cmd.exe", args, nil
+		return "cmd.exe", []string{"/c", p, "web", "--no-open", "--port", port}, nil
 	}
 	if p, err := exec.LookPath("dsh"); err == nil {
-		args := []string{"web", "--no-open", "--port", strconv.Itoa(s.cfg.WebPort)}
-		return p, args, nil
+		return p, []string{"web", "--no-open", "--port", port}, nil
 	}
 	return "", nil, errors.New("未找到 dsh 入口:请确认已全局安装 @deepseek-ai/dsh,或在配置中指定 nodePath / dshBinJs")
 }
@@ -269,8 +370,11 @@ func (s *Supervisor) nodePath() string {
 	return p
 }
 
-// dshBinJS 探测 dsh 的 bin.js:APPDATA\npm 与 npm root -g。
+// dshBinJS 探测 dsh 的 bin.js(APPDATA\npm 与 npm root -g);probeMu 保证并发只探测一次。
 func (s *Supervisor) dshBinJS() string {
+	s.probeMu.Lock()
+	defer s.probeMu.Unlock()
+
 	s.mu.Lock()
 	if s.binCache != "" {
 		v := s.binCache
@@ -298,6 +402,13 @@ func (s *Supervisor) dshBinJS() string {
 	return ""
 }
 
+// invalidateProbe 使入口缓存失效(更新后调用)。
+func (s *Supervisor) invalidateProbe() {
+	s.mu.Lock()
+	s.binCache = ""
+	s.mu.Unlock()
+}
+
 // installedVersion 读取已安装 dsh 版本。
 func (s *Supervisor) installedVersion() string {
 	binJS := s.cfg.DshBinJS
@@ -307,11 +418,23 @@ func (s *Supervisor) installedVersion() string {
 	if binJS == "" {
 		return ""
 	}
-	pkg := filepath.Join(filepath.Dir(filepath.Dir(binJS)), "package.json")
-	return readPackageVersion(pkg)
+	return readPackageVersion(filepath.Join(filepath.Dir(filepath.Dir(binJS)), "package.json"))
 }
 
 func fileExists(p string) bool {
 	st, err := os.Stat(p)
 	return err == nil && !st.IsDir()
 }
+
+// isPortOpen 检测端口是否有监听者。
+func isPortOpen(host string, port int) bool {
+	if host == "" {
+		host = "127.0.0.1"
+	}
+	conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 1200*time.Millisecond)
+	if err != nil {
+		return false
+	}
+	_ = conn.Close()
+	return true
+}

+ 89 - 37
tray.go

@@ -3,7 +3,6 @@ package main
 import (
 	_ "embed"
 	"fmt"
-	"os"
 	"os/exec"
 	"strings"
 	"time"
@@ -28,13 +27,13 @@ func runTray(cfg *Config, log *Logger) {
 
 func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
 	mStatus := systray.AddMenuItem("状态:初始化…", "当前服务状态")
-	mURL := systray.AddMenuItem("打开 Web 界面", cfgURL(cfg))
+	mURL := systray.AddMenuItem("打开 Web 界面", "")
 	systray.AddSeparator()
 	mStart := systray.AddMenuItem("启动服务", "启动 dsh web")
 	mStop := systray.AddMenuItem("停止服务", "停止 dsh web")
 	mRestart := systray.AddMenuItem("重启服务", "重启 dsh web")
 	systray.AddSeparator()
-	mAutoStart := systray.AddMenuItemCheckbox("开机自启", "登录 Windows 时自动启动托盘并守护服务", isAutoStartEnabled())
+	mAutoStart := systray.AddMenuItemCheckbox("开机自启", "登录 Windows 时自动启动托盘并守护服务(无窗口、不打开浏览器)", isAutoStartEnabled())
 	mAutoUpdate := systray.AddMenuItemCheckbox("定期自动更新", "按间隔检查并更新 dsh", cfg.AutoUpdate)
 	mUpdateNow := systray.AddMenuItem("立即更新 DSH", "npm install -g @deepseek-ai/dsh@latest")
 	systray.AddSeparator()
@@ -46,32 +45,35 @@ func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
 
 	mStop.Disable()
 
-	// 状态刷新(3 秒)
+	// 状态刷新:仅在有变化时写 UI(减少 systray 调用)
 	go func() {
 		ticker := time.NewTicker(3 * time.Second)
 		defer ticker.Stop()
+		var lastTitle, lastTip, lastURL string
+		lastRunning := false
 		for range ticker.C {
 			st := sup.Status()
-			switch {
-			case st.Updating:
-				mStatus.SetTitle("状态:正在更新…")
-			case st.Running:
-				mStatus.SetTitle(fmt.Sprintf("状态:运行中 (PID %d)", st.PID))
-			default:
-				mStatus.SetTitle("状态:已停止")
+			if title := statusTitle(st); title != lastTitle {
+				mStatus.SetTitle(title)
+				lastTitle = title
 			}
-			if st.Restarts > 0 {
-				mStatus.SetTooltip(fmt.Sprintf("自动重启次数:%d;最近错误:%s", st.Restarts, st.LastError))
-			} else {
-				mStatus.SetTooltip("服务运行正常")
+			if tip := statusTooltip(st); tip != lastTip {
+				mStatus.SetTooltip(tip)
+				lastTip = tip
 			}
-			mURL.SetTitle("打开 Web 界面:" + shortURL(st.URL))
-			if st.Running {
-				mStart.Disable()
-				mStop.Enable()
-			} else {
-				mStart.Enable()
-				mStop.Disable()
+			if u := "打开 Web 界面:" + shortURL(st.URL); u != lastURL {
+				mURL.SetTitle(u)
+				lastURL = u
+			}
+			if st.Running != lastRunning {
+				if st.Running {
+					mStart.Disable()
+					mStop.Enable()
+				} else {
+					mStart.Enable()
+					mStop.Disable()
+				}
+				lastRunning = st.Running
 			}
 		}
 	}()
@@ -102,9 +104,9 @@ func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
 				log.Printf("菜单:重启服务")
 				sup.Restart()
 			case <-mURL.ClickedCh:
-				status := sup.Status()
-				log.Printf("菜单:打开 Web %s", status.URL)
-				openURL(status.URL)
+				target := sup.Status().URL
+				log.Printf("菜单:打开 Web 界面")
+				openTarget(target)
 			case <-mAutoStart.ClickedCh:
 				next := !mAutoStart.Checked()
 				if err := setAutoStart(next); err != nil {
@@ -117,7 +119,9 @@ func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
 					mAutoStart.Uncheck()
 				}
 				cfg.AutoStart = next
-				_ = saveConfig(cfg)
+				if err := saveConfig(cfg); err != nil {
+					log.Printf("保存配置失败: %v", err)
+				}
 				log.Printf("开机自启 = %v", next)
 			case <-mAutoUpdate.ClickedCh:
 				next := !mAutoUpdate.Checked()
@@ -127,7 +131,9 @@ func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
 					mAutoUpdate.Uncheck()
 				}
 				cfg.AutoUpdate = next
-				_ = saveConfig(cfg)
+				if err := saveConfig(cfg); err != nil {
+					log.Printf("保存配置失败: %v", err)
+				}
 				log.Printf("定期自动更新 = %v(间隔 %d 小时)", next, cfg.UpdateIntervalH)
 				if next {
 					sup.startAutoUpdate()
@@ -144,9 +150,9 @@ func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
 					mVersion.SetTitle("版本:" + sup.installedVersion())
 				}()
 			case <-mLogs.ClickedCh:
-				openURL(appDataDir() + string(os.PathSeparator) + "logs")
+				openTarget(logDir())
 			case <-mConfig.ClickedCh:
-				openURL(configPath())
+				openTarget(configPath())
 			case <-mQuit.ClickedCh:
 				log.Printf("菜单:退出")
 				systray.Quit()
@@ -161,12 +167,50 @@ func trayReady(sup *Supervisor, cfg *Config, log *Logger) {
 	}
 }
 
-func cfgURL(cfg *Config) string {
-	host := cfg.WebHost
-	if host == "" {
-		host = "127.0.0.1"
+func statusTitle(st Status) string {
+	switch {
+	case st.Updating:
+		return "状态:正在更新…"
+	case st.Running:
+		return fmt.Sprintf("状态:运行中 (PID %d)", st.PID)
+	case st.External:
+		return "状态:外部实例运行中"
+	default:
+		return "状态:已停止"
+	}
+}
+
+func statusTooltip(st Status) string {
+	parts := make([]string, 0, 3)
+	if st.Running {
+		parts = append(parts, "运行 "+humanDuration(st.Uptime))
+	}
+	if st.Restarts > 0 {
+		parts = append(parts, fmt.Sprintf("自动重启 %d 次", st.Restarts))
+	}
+	if st.LastError != "" {
+		parts = append(parts, "最近错误:"+st.LastError)
+	}
+	if len(parts) == 0 {
+		return "服务未运行"
+	}
+	return strings.Join(parts, ";")
+}
+
+// humanDuration 简短的时长展示(<1 分钟、x 分钟、x 小时 y 分)。
+func humanDuration(d time.Duration) string {
+	if d <= 0 {
+		return "0 秒"
+	}
+	sec := int(d.Seconds())
+	switch {
+	case sec < 60:
+		return fmt.Sprintf("%d 秒", sec)
+	case sec < 3600:
+		return fmt.Sprintf("%d 分钟", sec/60)
+	default:
+		return fmt.Sprintf("%d 小时 %d 分", sec/3600, (sec%3600)/60)
 	}
-	return fmt.Sprintf("打开 Web 界面:http://%s:%d", host, cfg.WebPort)
 }
 
 // shortURL 菜单标题只显示 origin,避免 token 撑爆标题。
@@ -177,9 +221,17 @@ func shortURL(u string) string {
 	return u
 }
 
-// openURL 用系统默认程序打开 URL 或路径(隐藏窗口)。
-func openURL(target string) {
-	cmd := exec.Command("cmd", "/c", "start", "", target)
+// openTarget 用系统默认程序打开 URL 或路径,不经 shell 解析(避免注入面)。
+func openTarget(target string) {
+	if target == "" {
+		return
+	}
+	var cmd *exec.Cmd
+	if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
+		cmd = exec.Command("rundll32.exe", "url.dll,FileProtocolHandler", target)
+	} else {
+		cmd = exec.Command("explorer.exe", target)
+	}
 	hideWindow(cmd)
 	_ = cmd.Start()
 }

+ 48 - 89
updater.go

@@ -2,71 +2,14 @@ package main
 
 import (
 	"context"
-	"encoding/json"
 	"errors"
 	"fmt"
-	"os"
 	"os/exec"
-	"path/filepath"
 	"strings"
 	"time"
 )
 
-const (
-	dshPackage   = "@deepseek-ai/dsh"
-	allowScripts = "@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs"
-)
-
-// npmGlobalRoot 执行 npm root -g(隐藏窗口),返回全局 node_modules 路径。
-func npmGlobalRoot() string {
-	exe, args := npmCommandFor("root", "-g")
-	cmd := exec.Command(exe, args...)
-	hideWindow(cmd)
-	out, err := cmd.Output()
-	if err != nil {
-		return ""
-	}
-	return strings.TrimSpace(string(out))
-}
-
-// npmCommandFor 组装 npm 调用:优先 node + npm-cli.js,回退 cmd /c npm。
-func npmCommandFor(args ...string) (string, []string) {
-	if node, err := exec.LookPath("node"); err == nil {
-		if cli := detectNpmCLI(node); cli != "" {
-			return node, append([]string{cli}, args...)
-		}
-	}
-	return "cmd.exe", append([]string{"/c", "npm"}, args...)
-}
-
-func detectNpmCLI(node string) string {
-	base := filepath.Dir(node)
-	candidates := []string{
-		filepath.Join(base, "node_modules", "npm", "bin", "npm-cli.js"),
-		filepath.Join(base, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js"),
-	}
-	for _, c := range candidates {
-		if fileExists(c) {
-			return c
-		}
-	}
-	return ""
-}
-
-// readPackageVersion 读取 package.json 的 version。
-func readPackageVersion(path string) string {
-	data, err := os.ReadFile(path)
-	if err != nil {
-		return ""
-	}
-	var v struct {
-		Version string `json:"version"`
-	}
-	if err := json.Unmarshal(data, &v); err != nil {
-		return ""
-	}
-	return v.Version
-}
+const allowScripts = "@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs"
 
 // Update 执行全局更新:npm install -g @deepseek-ai/dsh@latest --allow-scripts=...
 func (s *Supervisor) Update() (string, error) {
@@ -97,9 +40,7 @@ func (s *Supervisor) Update() (string, error) {
 	if err != nil {
 		return text, fmt.Errorf("更新失败: %w", err)
 	}
-	s.mu.Lock()
-	s.binCache = ""
-	s.mu.Unlock()
+	s.invalidateProbe()
 	return text, nil
 }
 
@@ -117,46 +58,64 @@ func (s *Supervisor) LatestVersion() (string, error) {
 	return strings.TrimSpace(string(out)), nil
 }
 
-// startAutoUpdate 定期后台更新:按间隔轮询,发现新版本才更新并重启。
+// startAutoUpdate 定期后台更新(单例):每分钟检查开关,到达间隔才查询/更新。
+// 重复调用安全:已有循环时直接返回,避免多个循环并存。
 func (s *Supervisor) startAutoUpdate() {
+	s.autoMu.Lock()
+	if s.autoLoop {
+		s.autoMu.Unlock()
+		return
+	}
+	s.autoLoop = true
+	s.autoMu.Unlock()
+
 	go func() {
-		for {
+		defer func() {
+			s.autoMu.Lock()
+			s.autoLoop = false
+			s.autoMu.Unlock()
+		}()
+		var elapsed time.Duration
+		ticker := time.NewTicker(time.Minute)
+		defer ticker.Stop()
+		for range ticker.C {
 			s.mu.Lock()
 			enabled := s.cfg.AutoUpdate
-			hours := s.cfg.UpdateIntervalH
+			interval := time.Duration(s.cfg.UpdateIntervalH) * time.Hour
 			s.mu.Unlock()
 			if !enabled {
 				s.log.Printf("定期更新:已关闭")
 				return
 			}
-			if hours <= 0 {
-				hours = 24
+			if interval <= 0 {
+				interval = 24 * time.Hour
 			}
-			time.Sleep(time.Duration(hours) * time.Hour)
-
-			s.mu.Lock()
-			enabled = s.cfg.AutoUpdate
-			s.mu.Unlock()
-			if !enabled {
-				return
-			}
-
-			latest, err := s.LatestVersion()
-			if err != nil {
-				s.log.Printf("定期更新:查询最新版本失败: %v", err)
+			elapsed += time.Minute
+			if elapsed < interval {
 				continue
 			}
-			current := s.installedVersion()
-			if latest == "" || current == latest {
-				s.log.Printf("定期更新:已是最新(%s)", current)
-				continue
-			}
-			s.log.Printf("定期更新:发现新版本 %s(当前 %s),开始更新", latest, current)
-			if _, err := s.Update(); err != nil {
-				s.log.Printf("定期更新失败: %v", err)
-				continue
-			}
-			s.Restart()
+			elapsed = 0
+			s.runScheduledUpdate()
 		}
 	}()
 }
+
+// runScheduledUpdate 查询最新版本,有更新才安装并重启。
+func (s *Supervisor) runScheduledUpdate() {
+	latest, err := s.LatestVersion()
+	if err != nil {
+		s.log.Printf("定期更新:查询最新版本失败: %v", err)
+		return
+	}
+	current := s.installedVersion()
+	if latest == "" || current == latest {
+		s.log.Printf("定期更新:已是最新(%s)", current)
+		return
+	}
+	s.log.Printf("定期更新:发现新版本 %s(当前 %s),开始更新", latest, current)
+	if _, err := s.Update(); err != nil {
+		s.log.Printf("定期更新失败: %v", err)
+		return
+	}
+	s.Restart()
+}