Pārlūkot izejas kodu

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

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

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

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

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

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

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

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

- 扩展:新增 status/autostart/version CLI 子命令;抽出 npm.go
caesar 1 dienu atpakaļ
vecāks
revīzija
38620f1f6e
10 mainītis faili ar 586 papildinājumiem un 270 dzēšanām
  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()
+}