DeepSeek Harness 一键后台启停:关掉终端也能继续跑
DeepSeek Harness 一键启动脚本
最近 DeepSeek Harness(dsh)热度不低,官方给的启动方式很简单:
npx @deepseek-ai/dsh web

命令本身没毛病,但它必须前台运行:终端窗口一关,dsh 就跟着停了。想让 dsh 像服务一样常驻后台,官方没给现成方案,于是就有了下面这套 PowerShell 一键脚本。配好之后,在任意目录敲 dsh start、dsh stop、dsh restart、dsh status 就能管理它,浏览器访问 http://127.0.0.1:3080 直接进 Web UI。
一、解决了什么问题
| 痛点 | 脚本的处理方式 |
|---|---|
| 前台运行,关终端就停 | 用 Start-Process 后台拉起,-WindowStyle Hidden 隐藏窗口,彻底脱离终端 |
| 不知道是否已在运行、PID 是多少 | start / status 自动检测,PID 记录在独立文件,随时可查 |
| 停止时要自己翻进程列表 | stop 优先按记录的 PID 精确停止;记录丢了还能按 3080 端口反查兜底 |
| 命令难记 | 封装成 dsh start / dsh stop / dsh restart / dsh status / dsh 五个短命令 |
二、环境要求
不需要额外装什么,下面三样确认能用就行:
| 工具 | 检查命令 | 说明 |
|---|---|---|
| Node.js | node --version |
必需,dsh 通过 npx 运行 |
| npm | npm --version |
随 Node.js 一起安装 |
| PowerShell | 打开即用 | Windows 自带(脚本针对 Windows PowerShell 5.1 编写) |
注意:不用单独装 pnpm。这套方案走 npx 路线,绕开了源码构建对 pnpm 的依赖。
三、配置步骤
步骤 1:创建辅助目录
在用户主目录下建一个放脚本的目录。别用 ~/.dsh,那个目录 dsh 项目自己要用(存 credentials、profiles 等数据):
New-Item -ItemType Directory -Force "$HOME\.dsh-launcher"
步骤 2:创建主脚本 dsh.ps1
在 C:\Users\<你的用户名>\.dsh-launcher\dsh.ps1 创建脚本文件,完整源码见文末附录 A。
这里有个最大的坑:脚本必须保存为 UTF-8 with BOM 编码,否则 Windows PowerShell 5.1 会把中文按 GBK 误读,报一堆 UnexpectedToken 语法错误。用 VS Code 的话,右下角编码选「Save with Encoding」→「UTF-8 with BOM」即可。
如果文件已经存成了无 BOM 的 UTF-8,可以用下面命令就地转换(路径按需替换):
$c = Get-Content "$HOME\.dsh-launcher\dsh.ps1" -Raw -Encoding UTF8
[System.IO.File]::WriteAllText("$HOME\.dsh-launcher\dsh.ps1", $c, (New-Object System.Text.UTF8Encoding $true))
步骤 3:把 dsh 命令注册进 Profile
打开用户级 PowerShell Profile:
notepad $PROFILE.CurrentUserAllHosts
路径通常是 C:\Users\<你的用户名>\Documents\WindowsPowerShell\profile.ps1,不存在会提示新建。在文件末尾追加下面内容(原有内容比如 conda 初始化块,保留别动):
# dsh launcher - DeepSeek Harness 后台管理
function dsh {
& (Join-Path $HOME '.dsh-launcher\dsh.ps1') @args
}
保存后新开一个 PowerShell 窗口(或者先执行 . $PROFILE),dsh 命令就生效了。
四、使用方法
任意目录打开 PowerShell:
| 命令 | 作用 | 典型输出 |
|---|---|---|
dsh start |
后台启动 dsh(已在运行则提示,不重复启动) | dsh 已启动 (PID 28072),访问 http://127.0.0.1:3080 |
dsh stop |
停止 dsh(优先 PID 记录,否则按端口反查) | 正在停止 dsh (通过PID 记录找到 PID 28072) ... |
dsh restart |
重启 dsh(先停止再启动,未运行则直接启动) | 正在重启 dsh ... → dsh 已停止。 → dsh 已启动 (PID 28072),访问 http://127.0.0.1:3080 |
dsh status |
查看运行状态 | dsh 正在运行。 PID : 28072 (来自 PID 记录) |
dsh |
显示帮助 | 列出所有用法 |
下面是我本机实际跑一遍的效果(依次执行 start → status → stop):

下面是 v2.2 版本后的运行效果图:

五、脚本在干什么
脚本在 ~\.dsh-launcher 下维护几个辅助文件:
| 文件 | 作用 |
|---|---|
dsh.ps1 |
主脚本 |
dsh.pid |
记录当前 dsh 进程的 PID(start 写入,stop 删除,restart 先删后写) |
dsh.log |
标准输出日志 |
dsh.err.log |
标准错误日志 |
四个核心逻辑:
- 怎么判断「是不是 dsh 进程」:
npx @deepseek-ai/dsh web最终是由一个 node.exe 跑的。脚本通过 Win32_Process 拿到进程命令行,同时满足「进程名是 node.exe」+「命令行含 dsh」才认定是 dsh,避免误杀其它 node 程序。 - start 幂等:先查 PID 文件,失效就清理;再按 3080 端口兜底查一次,已经在跑就直接提示;确认没跑才拉起,然后轮询端口(最多 30 秒)拿到真实 PID 写入文件。
- stop 兜底:有有效 PID 记录就按记录杀;没有(比如 dsh 是别人起的、或旧版本脚本起的)就按 3080 端口反查,找到一样能停;都找不到才提示「未运行」。
- restart = stop + start:先按 stop 的兜底逻辑查杀(没在运行也只提示不报错),等 1 秒让端口释放,再按 start 的幂等逻辑拉起。改完配置想快速生效,一条
dsh restart搞定。
六、踩过的坑
都是实际配的过程中踩出来又解决的,换电脑重配时注意:
- 编码必须 UTF-8 with BOM:最大的坑。无 BOM 的中文脚本在 PowerShell 5.1 下必报 UnexpectedToken,存文件时务必带 BOM。
$Pid是 PowerShell 只读自动变量:它永远等于当前 PowerShell 进程的 PID,不能当普通变量或参数名用。脚本里函数参数改叫$TargetPid来规避(早前因此报过 VariableNotWritable)。- Start-Process 的 stdout/stderr 不能重定向到同一个文件:会报 InvalidOperationException,所以拆成了 dsh.log 和 dsh.err.log 两个文件。
- 改完 profile 要新开窗口:已经打开的 PowerShell 窗口不会自动加载新 Profile。
- 改过脚本记得重新存 BOM:有些编辑器编辑完会把 BOM 丢掉,中文解析错误就回来了。改完重跑上面那条转换命令最保险。
- 别占
~/.dsh目录:那是 dsh 项目自己的数据目录,这套启动器用的是独立的~/.dsh-launcher。
附录 A:主脚本 dsh.ps1(完整源码)
文件路径:C:\Users\<你的用户名>\.dsh-launcher\dsh.ps1
点击查看代码
# dsh launcher - DeepSeek Harness (dsh) 后台管理
# 版本: v2.1 (2026-08-22)
# 相比 v2.0 的改进:
# * 修复 npm 11.x 的 peer 依赖解析死循环 bug(npx / npm install 在下载阶段
# 永久卡死,对 2026-08-20 之后发布的 dsh 依赖树必然复现)
# * 下载/升级统一改用 pnpm(解析器独立,实测正常;无 pnpm 时自动经 npx 拉取)
# * 启动一律走缓存离线运行,不再使用 npx 在线启动
# 相比 dsh.ps1 的改进:
# * 不再盲目用 `npx -y @deepseek-ai/dsh`(会去在线解析/下载,网络卡或缓存锁损坏时启动失败)
# * 默认改为直接运行 npx 缓存里已存在的旧版 bin.js,全程离线、秒起
# * 启动前先检查网络是否有更新版,有则询问是否更新,避免悄悄装上不兼容的新版
# * 新增 `dsh update`: 手动检查更新,确认后预下载最新版到 npx 缓存
#
# 用法: dsh start | stop | restart | status | update
# 在 PowerShell profile 中定义函数后,任意目录下均可直接调用:
# function dsh { & (Join-Path $HOME '.dsh-launcher\dsh.ps1') @args }
#
# 前提条件(换新机器使用前请确认):
# 1. 已安装 Node.js LTS(含 npm),且 node / npm 均在 PATH 中
# 2. PowerShell 执行策略为 Restricted 时,profile 与脚本都无法加载,
# 请先执行: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
$ErrorActionPreference = 'Stop'
$LauncherDir = Join-Path $HOME '.dsh-launcher'
$PidFile = Join-Path $LauncherDir 'dsh.pid'
$LogFile = Join-Path $LauncherDir 'dsh.log'
$ErrLogFile = Join-Path $LauncherDir 'dsh.err.log'
$Port = 3080
$WebUrl = "http://127.0.0.1:$Port"
# 指定 dsh 包名(registry 与 npx 缓存里均以此为名)
$PkgName = '@deepseek-ai/dsh'
try { $NodeCacheRoot = Join-Path (npm config get cache) '_npx' } catch { $NodeCacheRoot = $null }
# 超时(毫秒): 版本检查 8 秒;更新下载 3 分钟(可按网络情况调整)
$RegistryTimeoutMs = 8000
$DownloadTimeoutMs = 180000
# 确保辅助目录存在(首次运行 / 换机器时)
if (-not (Test-Path $LauncherDir)) {
New-Item -ItemType Directory -Path $LauncherDir -Force | Out-Null
}
# ---------------------------------------------------------------------------
# semver 解析: 返回 [Major, Minor, Patch, Pre] 结构; Pre 为 $null 表示正式版; 解析失败返回 $null
# ---------------------------------------------------------------------------
function ConvertTo-SemVerParts([string]$v) {
$m = [regex]::Match($v, '^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$')
if (-not $m.Success) { return $null }
return [PSCustomObject]@{
Major = [int]$m.Groups[1].Value
Minor = [int]$m.Groups[2].Value
Patch = [int]$m.Groups[3].Value
Pre = if ($m.Groups[4].Success) { $m.Groups[4].Value } else { $null }
}
}
# ---------------------------------------------------------------------------
# 判断 a 是否"严格新于" b(按 npm semver 规则,正式版 > 同版本预发布版,如 0.1.1 > 0.1.1-rc.2)
# 原实现用字符串比较会把 rc 版误判为比正式版新,这里改为真正的 semver 比较。
# ---------------------------------------------------------------------------
function Test-IsNewer([string]$a, [string]$b) {
if (-not $a -or -not $b) { return $false }
$pa = ConvertTo-SemVerParts $a
$pb = ConvertTo-SemVerParts $b
if (-not $pa -or -not $pb) { return $false } # 无法解析的版本一律视为"不更新"
foreach ($k in 'Major','Minor','Patch') {
if ($pa.$k -gt $pb.$k) { return $true }
if ($pa.$k -lt $pb.$k) { return $false }
}
# 主版本相同: 无预发布标识(正式版) > 有预发布标识(rc/beta 等)
$aPre = $pa.Pre; $bPre = $pb.Pre
if (-not $aPre -and -not $bPre) { return $false } # 完全相同
if (-not $aPre) { return $true } # a 正式版、b 预发布 -> a 新
if (-not $bPre) { return $false } # a 预发布、b 正式版 -> a 旧
# 同为预发布: 按段比较(数字段按数值、字母段按字典序;数字段 < 字母段;段数多者新)
$aseg = $aPre.Split('.')
$bseg = $bPre.Split('.')
$n = [Math]::Min($aseg.Count, $bseg.Count)
for ($i = 0; $i -lt $n; $i++) {
$xn = 0; $yn = 0
$xIsNum = [int]::TryParse($aseg[$i], [ref]$xn)
$yIsNum = [int]::TryParse($bseg[$i], [ref]$yn)
if ($xIsNum -and $yIsNum) {
if ($xn -ne $yn) { return $xn -gt $yn }
} elseif ($xIsNum) {
return $false # 数字段 < 字母段(semver 规则)
} elseif ($yIsNum) {
return $true
} else {
$c = [string]::CompareOrdinal($aseg[$i], $bseg[$i])
if ($c -ne 0) { return $c -gt 0 }
}
}
return $aseg.Count -gt $bseg.Count # 段数多者新
}
# ---------------------------------------------------------------------------
# 查找缓存里已经存在的 dsh 可执行文件(lib/bin.js)。
# 遍历 npx 缓存目录 <hash>/node_modules/@deepseek-ai/dsh/package.json,
# 取版本号最高且 bin 真实存在的那个,返回 PSCustomObject[Path,Version];找不到返回 $null。
# 这是"用当前缓存旧版启动"的核心——不联网、不触碰 registry。
# ---------------------------------------------------------------------------
function Find-CachedDshBin {
if (-not $NodeCacheRoot -or -not (Test-Path $NodeCacheRoot)) { return $null }
$best = $null
Get-ChildItem -Path $NodeCacheRoot -Directory -ErrorAction SilentlyContinue | ForEach-Object {
$pkgJson = Join-Path $_.FullName "node_modules\$PkgName\package.json"
if (-not (Test-Path $pkgJson)) { return }
try {
$pkg = Get-Content $pkgJson -Raw | ConvertFrom-Json
# bin 可能是字符串(单 bin 包)或对象 {dsh: path}
$binRel = $null
if ($pkg.bin -is [string]) { $binRel = $pkg.bin }
elseif ($pkg.bin) { $binRel = $pkg.bin.dsh }
if (-not $binRel) { return }
$binPath = Join-Path $_.FullName "node_modules\$PkgName\$binRel"
# 必须是真实文件(PathType Leaf 防止误把目录当 bin)
if ((Test-Path $binPath -PathType Leaf) -and (-not $best -or (Test-IsNewer $pkg.version $best.Version))) {
$best = [PSCustomObject]@{ Path = $binPath; Version = $pkg.version }
}
} catch { }
}
return $best
}
# ---------------------------------------------------------------------------
# 短超时地获取 registry 上的最新版本号。返回 string;无法确定(网络不通/超时/解析失败)返回 $null。
# 用 cmd.exe /c 包一层,避免直接 Start-Process *.cmd 在部分环境下的兼容问题;
# 超时后用 taskkill /T 连子进程(node)一起终止,避免残留。
# ---------------------------------------------------------------------------
function Get-LatestVersionCore {
$tmpOut = Join-Path $env:TEMP ("dshview_" + [guid]::NewGuid().ToString('N') + ".out")
$tmpErr = Join-Path $env:TEMP ("dshview_" + [guid]::NewGuid().ToString('N') + ".err")
$p = Start-Process -FilePath $env:ComSpec `
-ArgumentList @('/c', "npm view $PkgName version") `
-NoNewWindow `
-RedirectStandardOutput $tmpOut `
-RedirectStandardError $tmpErr `
-PassThru
if (-not $p.WaitForExit($RegistryTimeoutMs)) {
taskkill /PID $p.Id /T /F 2>$null | Out-Null # 连子进程一起终止
Remove-Item $tmpOut,$tmpErr -Force -ErrorAction SilentlyContinue
return $null
}
$v = ''
if (Test-Path $tmpOut) { $v = (Get-Content $tmpOut -Raw -ErrorAction SilentlyContinue).Trim() }
Remove-Item $tmpOut,$tmpErr -Force -ErrorAction SilentlyContinue
if ($v -match '^\d+\.\d+\.\d+') { return $v }
return $null
}
# ---------------------------------------------------------------------------
# 【核心】启动逻辑:
# 0) 若已在运行 -> 提示并返回
# 1) 查找缓存里的 dsh bin
# 2) 网络检查最新版;有更新则询问是否升级
# - 用户选 Y -> 用 npx 在线安装最新版启动(提示兼容风险)
# - 用户选 N / 回车 / 无法确定 -> 用缓存旧版启动(离线)
# 3) 找不到任何缓存 bin 时,回退到 npx 在线启动
# ---------------------------------------------------------------------------
function Start-Dsh {
# 0. 已在运行检查(复用与 dsh.ps1 相同的 PID 记录与端口反查逻辑)
$recorded = Get-RecordedPid
if ($recorded -and (Test-IsDshProcess $recorded)) {
Write-Host "dsh 已在运行 (PID $recorded),访问 $WebUrl" -ForegroundColor Yellow
return
}
$portPid = Get-DshPidByPort
if ($portPid -and (Test-IsDshProcess $portPid)) {
Write-Host "dsh 已在运行 (PID $portPid),访问 $WebUrl" -ForegroundColor Yellow
return
}
# 1. 查找缓存里的旧版
$cached = Find-CachedDshBin
if ($cached) {
Write-Host "在 npx 缓存中找到 dsh 旧版: $($cached.Version)" -ForegroundColor Cyan
} else {
Write-Host "未在本机 npx 缓存中找到 dsh,将尝试在线安装最新版启动。" -ForegroundColor Yellow
}
# 2. 网络检查 + 询问是否升级(升级/下载统一走 pnpm -> 缓存 -> 离线启动,
# 不再使用 npx:npm 11.x 对 dsh 依赖树的 peer 依赖解析存在死循环 bug)
if ($cached) {
Write-Host "正在检查是否有新版本 ..." -ForegroundColor Cyan
$latest = Get-LatestVersionCore
if ($latest) {
Write-Host "registry 最新版: $latest | 本地缓存版: $($cached.Version)" -ForegroundColor Cyan
if (Test-IsNewer $latest $cached.Version) {
$ans = Read-Host "检测到新版本 $latest。是否升级?`n(Y=在线升级并启动 / N 或回车=继续用缓存旧版启动)"
if ($ans -match '^[Yy]$') {
Write-Host "选择升级到 $latest。注意:新版本可能存在数据兼容性问题,请自行评估。" -ForegroundColor Yellow
if (Install-DshToCache $latest) {
$cached = Find-CachedDshBin
} else {
Write-Host "升级下载失败,继续使用缓存旧版启动。" -ForegroundColor Yellow
}
} else {
Write-Host "保持使用缓存旧版 $($cached.Version)。" -ForegroundColor Green
}
} else {
Write-Host "当前缓存已是最新版本,直接使用缓存启动。" -ForegroundColor Green
}
} else {
Write-Host "(无法连通 registry 确认版本,跳过检查) 直接使用缓存旧版启动。" -ForegroundColor DarkYellow
}
} else {
# 无缓存 -> 尝试下载最新版后启动
$latest = Get-LatestVersionCore
}
# 3. 启动(始终从缓存启动:先按需下载到缓存,再离线运行 bin.js)
if (-not $cached) {
if (-not $latest) {
Write-Host "未找到任何缓存版本且无法获取最新版本,请检查网络后重试 dsh update。" -ForegroundColor Red
return
}
if (-not (Install-DshToCache $latest)) {
Write-Host "下载失败,无法启动。请检查网络后重试 dsh update。" -ForegroundColor Red
return
}
$cached = Find-CachedDshBin
if (-not $cached) {
Write-Host "下载完成但未在缓存中找到 dsh,请手动检查。" -ForegroundColor Red
return
}
}
$nodeExe = (Get-Command node.exe -ErrorAction SilentlyContinue).Source
if (-not $nodeExe) {
Write-Host "未找到 node.exe,请确认已安装 Node.js 且已加入 PATH。" -ForegroundColor Red
return
}
if ($PSVersionTable.PSVersion.Major -ge 7) {
$argList = @($cached.Path, 'web') # PS7: 按元素自动转义,勿手动加引号
} else {
$argList = @('"' + $cached.Path + '"', 'web') # PS5.1: 手动加引号以支持含空格的路径
}
$proc = Start-Process -FilePath $nodeExe `
-ArgumentList $argList `
-WorkingDirectory $HOME `
-WindowStyle Hidden `
-RedirectStandardOutput $LogFile `
-RedirectStandardError $ErrLogFile `
-PassThru
# 等端口起来
$pidFound = $null
for ($i = 0; $i -lt 30; $i++) {
Start-Sleep -Seconds 1
$pidFound = Get-DshPidByPort
if ($pidFound -and (Test-IsDshProcess $pidFound)) { break }
}
if ($pidFound) {
$pidFound | Out-File -FilePath $PidFile -Encoding ascii
Write-Host "dsh 已启动 (PID $pidFound),访问 $WebUrl" -ForegroundColor Green
} else {
Write-Host "dsh 启动可能失败,请查看日志: $ErrLogFile" -ForegroundColor Red
if (Test-Path $ErrLogFile) { Get-Content $ErrLogFile -Tail 20 }
}
}
# ---------------------------------------------------------------------------
# dsh update: 手动检查 registry 最新版,确认后预下载到 npx 缓存(不启动服务)。
# 下载通过 pnpm 完成(npm 11.x 对 dsh 依赖树的 peer 依赖解析存在死循环 bug,
# 会导致 npx / npm install 在下载阶段永久卡死;pnpm 解析器独立,实测正常)。
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# 用 pnpm 把指定版本的 dsh 安装到 npx 缓存目录($NodeCacheRoot\manual-<version>,
# 注:$NodeCacheRoot 已含 _npx,即 <cache>\_npx\manual-<version>),
# 目录布局与 npx 安装一致,Find-CachedDshBin 可直接发现。
# 本机无 pnpm 时回退到 npx 临时拉取 pnpm(pnpm 自身依赖树很小,不会触发该 bug)。
# 返回 $true/$false 表示安装是否成功。
# ---------------------------------------------------------------------------
function Install-DshToCache([string]$version) {
$targetDir = Join-Path $NodeCacheRoot "manual-$version"
$storeDir = Join-Path $LauncherDir 'pnpm-store'
if (Test-Path (Join-Path $targetDir "node_modules\$PkgName\package.json")) {
Write-Host "缓存中已存在 $version,跳过下载。" -ForegroundColor Green
return $true
}
New-Item -ItemType Directory -Path $targetDir -Force | Out-Null
if (Get-Command pnpm -ErrorAction SilentlyContinue) {
$pnpmRunner = "pnpm"
} else {
Write-Host "未找到 pnpm,将通过 npx 临时拉取 pnpm(pnpm 依赖树小,不会触发 npm 的解析问题)..." -ForegroundColor DarkYellow
$pnpmRunner = "npx -y pnpm@latest"
}
# 命令末尾追加 echo ERRORLEVEL,从输出流解析退出码:
# PS 5.1 部分环境下 Start-Process 的 ExitCode 可能读取不到,此写法跨版本可靠
$installCmd = "$pnpmRunner add --dir `"$targetDir`" --store-dir `"$storeDir`" `"${PkgName}@${version}`" & echo DSHUPD_EXIT=%ERRORLEVEL%"
Write-Host "正在下载并安装 $version(pnpm,视网速可能需要几分钟)..." -ForegroundColor Cyan
$dlOut = Join-Path $env:TEMP ("dshupd_" + [guid]::NewGuid().ToString('N') + ".out")
$dlErr = Join-Path $env:TEMP ("dshupd_" + [guid]::NewGuid().ToString('N') + ".err")
$p = Start-Process -FilePath $env:ComSpec `
-ArgumentList @('/c', $installCmd) `
-NoNewWindow `
-RedirectStandardOutput $dlOut `
-RedirectStandardError $dlErr `
-PassThru
if (-not $p.WaitForExit($DownloadTimeoutMs)) {
taskkill /PID $p.Id /T /F 2>$null | Out-Null
Remove-Item $dlOut,$dlErr -Force -ErrorAction SilentlyContinue
Write-Host "下载超时(已等待 3 分钟),已终止。请检查网络后重试。" -ForegroundColor Red
return $false
}
$p.WaitForExit() # 等重定向输出完全写入文件
$rawOut = ''
if (Test-Path $dlOut) {
$rawOut = Get-Content $dlOut -Raw -ErrorAction SilentlyContinue
if (-not $rawOut) { $rawOut = '' }
}
$exitMatch = [regex]::Match($rawOut, 'DSHUPD_EXIT=(\d+)')
$exitCode = if ($exitMatch.Success) { [int]$exitMatch.Groups[1].Value } else { -1 }
$errText = ''
if (Test-Path $dlErr) {
$raw = Get-Content $dlErr -Raw -ErrorAction SilentlyContinue
if ($raw) { $errText = $raw.Trim() }
}
Remove-Item $dlOut,$dlErr -Force -ErrorAction SilentlyContinue
$installedOk = $exitCode -eq 0 -and (Test-Path (Join-Path $targetDir "node_modules\$PkgName\package.json"))
if (-not $installedOk) {
Write-Host "pnpm 安装失败(退出码: $exitCode)。" -ForegroundColor Red
if ($errText) { Write-Host $errText }
return $false
}
Write-Host "安装完成。" -ForegroundColor Green
return $true
}
function Update-Dsh {
# 若 dsh 正在运行,先提示更新生效时机
$recorded = Get-RecordedPid
if ($recorded -and (Test-IsDshProcess $recorded)) {
Write-Host "提示: dsh 正在运行 (PID $recorded)。本次更新只写入缓存,执行 'dsh restart' 后新版本才会生效。" -ForegroundColor Yellow
}
# 1. 获取 registry 最新版
$latest = Get-LatestVersionCore
if (-not $latest) {
Write-Host "无法获取最新版本(网络不通或 registry 超时),请稍后重试。" -ForegroundColor Red
return
}
# 2. 查找本地缓存版本
$cached = Find-CachedDshBin
$current = if ($cached) { $cached.Version } else { $null }
Write-Host "当前缓存版本: $(if ($current) { $current } else { '未安装' })" -ForegroundColor Cyan
Write-Host "registry 最新 : $latest" -ForegroundColor Cyan
# 3. 判断是否有更新
if ($current -and -not (Test-IsNewer $latest $current)) {
Write-Host "当前已是最新版本,无需更新。" -ForegroundColor Green
return
}
# 4. 询问用户
$msg = if ($current) { "检测到新版本 $latest(当前 $current)。是否更新?" } else { "检测到最新版本 $latest,是否安装?" }
$ans = Read-Host "$msg (Y=更新 / N=取消)"
if ($ans -notmatch '^[Yy]$') {
Write-Host "已取消更新。" -ForegroundColor Yellow
return
}
# 5. 下载到 npx 缓存(用 pnpm 绕过 npm 的 peer 解析死循环 bug;带超时)
if (-not (Install-DshToCache $latest)) {
Write-Host "可手动重试: pnpm add --dir `"$(Join-Path $NodeCacheRoot "manual-$latest")`" ${PkgName}@${latest}" -ForegroundColor Yellow
return
}
# 6. 验证:重新查找缓存,确认新版本已就位
$newCached = Find-CachedDshBin
if ($newCached -and ($null -eq $current -or (Test-IsNewer $newCached.Version $current))) {
Write-Host "更新完成,当前缓存版本: $($newCached.Version)" -ForegroundColor Green
Write-Host "执行 'dsh start' 即可用新版本启动。" -ForegroundColor Cyan
} else {
Write-Host "更新未生效,请检查上面的错误信息。" -ForegroundColor Red
}
}
# ---------------------------------------------------------------------------
# stop / restart / status / help
# ---------------------------------------------------------------------------
function Get-DshPidByPort {
$conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue |
Sort-Object -Property OwningProcess -Unique
if ($conn) { return $conn[0].OwningProcess }
return $null
}
function Test-IsDshProcess([int]$TargetPid) {
try {
$proc = Get-CimInstance Win32_Process -Filter "ProcessId = $TargetPid" -ErrorAction Stop
if (-not $proc) { return $false }
$name = $proc.Name
$cmdline = $proc.CommandLine
# 精确匹配 deepseek-ai 包路径,避免误杀其他 node 进程
return ($name -eq 'node.exe' -and $cmdline -like '*deepseek-ai*dsh*')
} catch {
return $false
}
}
function Get-RecordedPid {
if (-not (Test-Path $PidFile)) { return $null }
$raw = (Get-Content $PidFile -ErrorAction SilentlyContinue | Select-Object -First 1).Trim()
if ($raw -match '^\d+$') { return [int]$raw }
return $null
}
function Stop-Dsh {
$recorded = Get-RecordedPid
$target = $null
$source = ''
if ($recorded -and (Test-IsDshProcess $recorded)) {
$target = $recorded
$source = 'PID 记录'
} else {
$portPid = Get-DshPidByPort
if ($portPid -and (Test-IsDshProcess $portPid)) {
$target = $portPid
$source = '端口反查'
}
}
if (-not $target) {
if ($recorded) {
Write-Host "PID 文件记录了 $recorded,但该进程已不存在或不是 dsh,未执行任何操作。" -ForegroundColor Yellow
} else {
Write-Host "未找到正在运行的 dsh 进程,无需停止。" -ForegroundColor Yellow
}
Remove-Item $PidFile -Force -ErrorAction SilentlyContinue
return
}
Write-Host "正在停止 dsh (通过${source}找到 PID $target) ..." -ForegroundColor Cyan
Stop-Process -Id $target -Force -ErrorAction SilentlyContinue
Remove-Item $PidFile -Force -ErrorAction SilentlyContinue
Write-Host "dsh 已停止。" -ForegroundColor Green
}
function Restart-Dsh {
Write-Host "正在重启 dsh ..." -ForegroundColor Cyan
Stop-Dsh
Start-Sleep -Seconds 1
Start-Dsh
}
function Show-DshStatus {
$recorded = Get-RecordedPid
if ($recorded -and (Test-IsDshProcess $recorded)) {
Write-Host "dsh 正在运行。" -ForegroundColor Green
Write-Host " PID : $recorded (来自 PID 记录)"
Write-Host " 地址 : $WebUrl"
return
}
$portPid = Get-DshPidByPort
if ($portPid -and (Test-IsDshProcess $portPid)) {
Write-Host "dsh 正在运行。" -ForegroundColor Green
Write-Host " PID : $portPid (来自端口反查,PID 记录缺失/失效)"
Write-Host " 地址 : $WebUrl"
return
}
Write-Host "dsh 未在运行。" -ForegroundColor Yellow
if ($recorded) { Write-Host " (存在失效的 PID 记录: $recorded)" -ForegroundColor DarkYellow }
}
function Show-DshHelp {
Write-Host @"
dsh launcher - DeepSeek Harness 后台管理 v2.1 (2026-08-22)
用法:
dsh start 检查更新后,后台启动 dsh Web UI (${WebUrl})
dsh stop 停止 dsh(优先用 PID 记录,否则按端口反查)
dsh restart 重启 dsh(先停止,再后台启动)
dsh status 查看 dsh 运行状态
dsh update 检查 registry 是否有新版本,确认后预下载到缓存
dsh 显示本帮助
特点:
- 优先使用 npx 缓存中的版本直接启动,不联网,秒起
- 启动前检查 registry 是否有新版,提示是否升级(默认不升级)
- 新机器使用前提: 已安装 Node.js LTS 且 node/npm 在 PATH
辅助文件目录: ${LauncherDir}
"@
}
# 入口
switch ($args[0]) {
'start' { Start-Dsh }
'stop' { Stop-Dsh }
'restart' { Restart-Dsh }
'status' { Show-DshStatus }
'update' { Update-Dsh }
$null { Show-DshHelp }
default { Write-Host "未知命令: $($args[0])" -ForegroundColor Red; Show-DshHelp }
}
附录 B:Profile 注入片段
追加到 $PROFILE.CurrentUserAllHosts(即 C:\Users\<你的用户名>\Documents\WindowsPowerShell\profile.ps1)末尾:
# dsh launcher - DeepSeek Harness 后台管理
function dsh {
& (Join-Path $HOME '.dsh-launcher\dsh.ps1') @args
}
文件里如果已经有别的内容(比如 conda 初始化块),保留原样,只在末尾追加。
整套配下来五分钟,之后 dsh 就随开随用了。如果还遇到别的坑,欢迎评论区一起聊。

浙公网安备 33010602011771号