jiangchen e00d73be82 fix(host): 根因——spawn 的 detached:true 让 PowerShell 起来即退;feat: 三种跟随方式(默认跟随前台)
根因(对照实验):spawn('powershell', [...], {detached:true}) 在 Windows 上 → 子进程 exit 0、
脚本一行没跑(日志里连 start 都没有),表现就是『点打开没反应』;detached:false 完全正常。
现在 spawnShell:绝对路径优先 + 不 detached + 等心跳确认,失败把原因写进 status notes。

跟随方式(用户需求:切到哪个会话就跟哪个,且可配置):
- front(默认)跟随前台:客户端半订阅 uiSession.current,会话一换就上报 /window{action:front}
- latest 跟随最新事件;pinned 固定在 ⇄ 里选中的会话
设置入口:设置→悬浮窗(模式三选一);浮窗 ⇄ 里点会话=切到 pinned
快照新增 follow/front 字段;/window 支持 action=follow|front
2026-09-27 17:39:15 +08:00

dsh-float-window

DeepSeek Harness(DSH)悬浮窗:始终置顶的会话镜像小窗。把主界面切到后台,Agent 照样在跑,你在前面用一个小窗盯着它——而且 Agent 抓屏时这个浮窗自动隐身,完全不影响它的屏幕操控。

仅支持 Windows。


它长什么样

┌──────────────────────────────────────────┐
│ ● DSH 悬浮窗  未连接      📌 🔒 ⬆ ⛶ ⚙ ✕ │  ← 拖动这里移动窗口
├──────────────────────────────────────────┤
│                                          │
│   会话镜像区(消息流 / 工具调用 / 状态)   │
│                                          │
├──────────────────────────────────────────┤
│ [ 给会话发消息 …………………… ]        ↑    │  ← 底部聊天框
├──────────────────────────────────────────┤
│ 轮 12 · 步 34 · 18.2 tok/s · 0.43 M tok  │  ← 计费/状态条
│ 缓存 62% · 上下文 ▓▓▓▓░░░░ 41%           │
└──────────────────────────────────────────┘

功能

需求 实现
悬浮窗模式(不全屏打开) 独立原生顶层窗口,无边框,默认贴在屏幕右侧
始终置顶 TopMost,标题栏 ⬆ 一键开关
固定位置 标题栏 📌(图钉)锁定/解锁拖动
锁定尺寸 标题栏 🔒(小锁)锁定/解锁缩放
自适应调整 布局随窗口尺寸重排;拖动任意边缘和四角都能缩放(系统级 WM_NCHITTEST)
全屏切换 标题栏 ⛶ 或 F11,返回时还原原来的位置与尺寸
抓屏隐身 SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE),Agent 截屏里彻底看不到这个窗
底部聊天框 直接发消息进当前会话(浮窗 POST 给插件,插件调宿主网关:0.1.7 走 ctx.sessionController.prompt,0.1.0 走 ctx.apiProxy.sessions.prompt,与浏览器 UI 同一条路)
计费/状态条 轮数、步数、tok/s、累计 MTOK、缓存命中率、上下文用量进度条
外观自定义 背景效果:不透明 / 毛玻璃(亚克力)/ 云母 / 模糊 / 半透明;不透明度、界面缩放
按钮触发 运行 shell\run-float.cmd,或 DSH 里点插件入口
Agent 自动打开 注册 float_window 工具,模型可以自己 open / close / pin / fullscreen / hide

快速开始

git clone <repo> dsh-float-window
cd dsh-float-window

# 1) 拉 WebView2 SDK(首次一次即可)
powershell -ExecutionPolicy Bypass -File tools\setup-webview2.ps1

# 2) 直接跑浮窗(不依赖 DSH)
shell\run-float.cmd

# 3) 装进 DSH(获得会话镜像 + Agent 触发能力),然后重启 DSH
dsh plugin --profile desktop add link:.

重启后浮窗会自动连上宿主,标题栏的“未连接”变成 127.0.0.1:<port>,镜像区开始滚动会话内容。

命令行

shell\run-float.cmd                                  # 打开(已开着则前置)
shell\run-float.cmd -NoExclude                       # 调试:允许被截图(否则你截不到它)
shell\run-float.cmd -Backdrop acrylic -Opacity 0.8   # 毛玻璃 + 80% 霜化
shell\run-float.cmd -Action pin                      # 给正在运行的实例下发动作

-Action 支持 pin sizeLock topmost fullscreen hide show close; 已运行的实例每 700ms 读一次 %DSH_HOME%\float-window\command.json,所以插件也能这样遥控它。

架构

┌──────────────────────────────┐        ┌───────────────────────────────┐
│ shell/float-window.ps1       │        │ lib/host.js  (DSH 插件)        │
│  WinForms + WebView2 原生壳   │  HTTP  │  · 写 endpoint.json 给壳       │
│  · 置顶 / 锁定 / 全屏 / 缩放   │◀──────▶│  · SSE /events 会话+指标快照    │
│  · 截图排除 0x11             │  SSE   │  · /cmd 控制通道               │
│  · 位置尺寸记忆 / 单实例      │        │  · float_window 工具(Agent)  │
└──────────────┬───────────────┘        └───────────────────────────────┘
               │ postMessage 桥                    ▲
┌──────────────▼───────────────┐                  │ session/event
│ shell/ui.html (全部界面)     │        ┌─────────┴─────────────────────┐
│  工具栏 / 镜像区 / 聊天框 / 状态条 │        │ DSH 宿主:sessions / tokenMeter │
└──────────────────────────────┘        └───────────────────────────────┘
  • 界面完全在 shell/ui.html:原生壳只负责窗口行为,页面通过 WebView2 的 postMessage 桥发 drag / resize / topmost / posLock / sizeLock / fullscreen / opacity / scale / backdrop 等命令。
  • 数据来自插件:壳把 endpoint.json 里的地址交给页面,页面用 EventSource 订阅 /dsh-float-window/events,拿到 {messages, metrics} 快照。
  • 插件是"探测式"的:ctx.get('sessions' | 'tokenMeter' | 'sessionProjection' | 'tools') 全部 try/catch,缺哪个服务就少几个数字,绝不让插件把 DSH 拖垮。

已实测的关键结论

这台机器(Windows 11 / 150% 缩放 / WebView2 154)上的实测记录:

  1. 截图排除确实可行:GDI 抓屏下
    • WDA_NONE → 抓到窗口本身(对照组)
    • WDA_MONITOR (0x1) → 抓到一个纯黑方块(窗口还在,只是变黑,观感很差)
    • WDA_EXCLUDEFROMCAPTURE (0x11) → 窗口完全消失,抓到的是它背后的画面
    • 只对本进程自己的顶层窗口生效(跨进程调用返回 False),所以浮窗必须是我们自己起的进程。
  2. 毛玻璃模式顺带也是"抓屏隐身"的:DWMWA_SYSTEMBACKDROP_TYPE + DwmExtendFrameIntoClientArea 的窗口,GDI 抓屏同样拿不到它(抓到的是底下内容)。
  3. WebView2 SDK 用 Assembly.LoadFrom 会报 0x80131515(本机 PowerShell 5.1),改用 [Reflection.Assembly]::Load(bytes) 加载托管 DLL,再把原生 WebView2Loader.dll 放进 PATH 就好。
  4. 初始化不能阻塞 UI 线程:EnsureCoreWebView2Async(...).GetAwaiter().GetResult() 会死锁,必须用 CoreWebView2InitializationCompleted 事件。
  5. CoreWebView2Environment.CreateAsync 的参数顺序是 (browserExecutableFolder, userDataFolder, options),传错会得到 "Couldn't find a compatible Webview2 Runtime"。
  6. 高 DPI:WinForms 会把逻辑尺寸按缩放放大,位置要按真实 Width/Height 夹回屏幕内。
  7. .ps1 里出现中文时必须存成 UTF-8 with BOM,否则 PowerShell 5.1 按 GBK 读会把脚本解析坏(tools/fix-bom.ps1 专门干这个)。
  8. cordis:ctx.set 之前必须 ctx.provide。漏了会抛 cannot set property "floatWindow" without provide,后果不是"少个功能"而是整个插件树加载失败、DSH 进程直接退出。本轮就是在隔离实例里实测复现后修掉的。
  9. PowerShell 事件处理器里参数不能叫 $args:它是自动变量,会把整个事件参数数组占住,于是 $e.TryGetWebMessageAsString() 变成"数组没有这个方法"——表现为页面所有按钮(含右下角缩放、置顶、锁定、全屏、设置)全部失效。正确写法是 param($s, $e)。
  10. profile 的 package.json 不能带 BOM:Node 读 JSON 会报 SyntaxError: Unexpected token '锘?。用 [IO.File]::WriteAllText(..., UTF8Encoding($false)) 写。
  11. 宿主里可能返回 Promise 的 API,必须 .catch():ctx.get('llm').resolveModelInfo() 在没注册 provider 时会抛错,而且它返回 Promise——只 try/catch 同步部分会留下 unhandled rejection,直接把 DSH 进程 fatal load failure 干掉(我在隔离实例里真把 DSH 搞崩过一次,1 秒一次的 SSE tick 是触发器)。
  12. 会话事件载荷在 event.data 下,不在顶层:user/message → data.content[{type:'text',text}]、assistant/chunk → data.chunk{type,text,reason}、session/title → data.title、tool/call → data.name/args。另外要过滤 data.source.kind === 'plugin' 的消息(系统提示快照),否则镜像里会混入一大段内部注入文本。
  13. 宿主 RPC 的信封是 { rpcId, payload }(0.1.0 的 ctx.apiProxy.sessions.*):把字段平铺在顶层会得到 Cannot read properties of undefined (reading 'sessionId')。
  14. 0.1.7 起宿主面换成了 ctx.sessionController(ctx.apiProxy 不再存在),并且 Remote 签名是 (request, signal)——漏了 signal 会报 Cannot read properties of undefined (reading 'throwIfAborted');create 还必须带一个合法的 agentPreset(否则 Unknown agent preset: xxx)。插件对两套 API 都自动适配(见 hostCall)。
  15. 手工拼工具定义会打断每一轮对话(真机事故):0.1.7 的 ctx.tools.register() 要求模型侧看到的就是 JSON Schema,参数必须经 defineTool()(内部 parameterSchemaSpecToJsonSchema)转换。直接塞 DSL 对象会让 provider 收到 parameters.type = null,于是每次请求都被拒: Invalid schema for function 'float_window': schema must be a JSON Schema of 'type: "object"', got 'type: null'。 现在的三层防护:解析不到 defineTool 就根本不注册;注册前自检 parameters.type === 'object';配置项 tool: false 可一键关掉。
  16. link: 安装时优先返回 default 会吞掉具名导出:@deepseek-ai/dsh-tools 的 default 是 ToolRuntime 类,defineTool 是具名导出——宿主模块加载器必须返回模块命名空间本身。
  17. 上下文窗口不在 model 配置里,而在会话事件 request/context.contextWindow(实测 deepseek-flash = 1000000),用它算上下文用量百分比最可靠;状态文件里的 toolSchemas 是从真实 request/header 抓下来的"模型视角工具 schema",用来一眼确认 schema 合法。

版本支持

本插件在两种真实宿主上都实测跑通过(同一份代码,运行时自适应):

宿主 来源 会话 API 结果
dsh 0.1.0-rc.6 npx 缓存(dsh web) ctx.apiProxy.sessions.*({rpcId,payload} 信封) 插件加载、工具注册、路由、镜像、发消息全部通过
dsh 0.1.7-rc.2 DSH Desktop.exe 内置 ctx.sessionController.*((request, signal)) 同上全部通过(settings 服务在 0.1.7 存在,设置入口走 profile 配置模型)

怎么验证(可复现)

不需要动你正在用的 DSH:

# 1) 造一个隔离的 DSH_HOME,profile 只装 base + web-app + 本插件
$t = '.runtime\testhome'
New-Item -ItemType Directory -Force -Path "$t\profiles\web\node_modules" | Out-Null
# package.json(bundles 只留三个) + cordis.yml/cordis.patch.yml 见 README 上文
cmd /c mklink /J "$t\profiles\web\node_modules\dsh-float-window" "$PWD"

# 2) 起第二个实例(8099),观察插件是否被加载
$env:DSH_HOME = (Resolve-Path $t)
node <dsh bin.js> web --port 8099
#   -> 出现 endpoint.json 且无 "plugin tree failed to load" 即通过

# 3) 直接打插件路由
curl http://127.0.0.1:8099/dsh-float-window/health
curl http://127.0.0.1:8099/dsh-float-window/state
curl -N http://127.0.0.1:8099/dsh-float-window/events     # SSE,每秒一帧

# 4) 让浮窗连到这个测试实例(看视觉效果时再加 -NoExclude)
shell\run-float.cmd -DshHome $t -NoExclude
#   标题栏应变成绿点 + 127.0.0.1:8099

# 5) 造一个真实会话并灌一条消息,验证镜像与指标(不需要浏览器)
curl -X POST http://127.0.0.1:8099/dsh-float-window/session -H "content-type: application/json" -d "{\"action\":\"create\",\"cwd\":\"D:/project\"}"
curl -X POST http://127.0.0.1:8099/dsh-float-window/chat    -H "content-type: application/json" -d "{\"text\":\"你好\"}"
curl http://127.0.0.1:8099/dsh-float-window/state
#   -> messages 里出现 user 行;metrics 里 turns/steps/tokens 有数(没有 API key 时
#      assistant 行会是一条 ⚠️ 报错,这正是 LLM 分支被正确映射的证据)

tools/smoke-host.mjs 则是更轻的一层:假 ctx 直接调 apply(),验证路由/SSE/工具注册不抛异常。

配置

持久化在 %DSH_HOME%\float-window\window.json(不可写时自动降到 %LOCALAPPDATA%\dsh-float-window 或仓库内 .runtime):

{ "x": 1895, "y": 36, "w": 420, "h": 720, "topmost": true,
  "posLock": false, "sizeLock": false, "opacity": 0.92,
  "exclude": true, "scale": 1.0, "backdrop": "opaque" }

日志:%DSH_HOME%\float-window\float-window.log(每次启动都会记,排障先看它)。

设置在哪里改

入口 适用版本 说明
浮窗内 ⚙ 齿轮 全部 不透明度 / 界面缩放 / 背景效果(不透明·毛玻璃·云母·模糊·半透明)/ 三个开关,即时生效,记在 window.json
DSH 设置 → 插件 → dsh-float-window dsh 0.1.7+ 插件导出 schemastery Config,设置页把它渲染成一张卡片(topmost / posLock / sizeLock / exclude / opacity / scale / backdrop)。改动写进 profile 的 cordis patch,插件启动时读到并通过 SSE 下发给浮窗(首次连接时应用一次,之后以浮窗自己的开关为准,避免来回打架)
ctx.settings.register('dsh-float-window', schema) dsh 0.1.0 老版本的设置服务路径,自动降级

link: 安装的插件自身目录没有 node_modules,import '@deepseek-ai/schemastery' 会失败; 插件用 createRequire(process.argv[1] ?? <profile>/package.json) 去宿主/profile 那边解析, 解析不到就干脆不导出 Config(不再报错)。

目录

dsh-float-window/
├─ package.json          DSH bundle 插件元数据(dsh.bundle.patch)
├─ cordis.patch.yml      挂载声明
├─ lib/host.js           插件 Host 半(路由 + SSE + float_window 工具)
├─ shell/float-window.ps1 原生壳(窗口行为 + 互操作)
├─ shell/ui.html         浮窗界面(自适应、工具栏、聊天框、状态条、设置)
├─ shell/run-float.cmd   启动器(用户双击 / 插件拉起)
├─ tools/setup-webview2.ps1  拉取 WebView2 SDK
├─ tools/fix-bom.ps1     给所有 .ps1 补 BOM(中文脚本必备)
└─ tools/smoke-host.mjs  插件 Host 半的假 ctx 冒烟测试

已知限制 / 路线图

  • 仅 Windows(macOS 的排除要换 NSWindow.sharingType,且 ScreenCaptureKit 是否遵守需要单独实测)。
  • 从浮窗新建会话还没打通:session.create 在宿主里要工作区上下文,直接从插件调会被拒(/session 路由已留好,等接上 workspace)。
  • 背景效果里的“半透明”依赖整窗 alpha,WebView2 子窗口在这种模式下的观感不如毛玻璃,建议优先用亚克力/云母。
  • 还没做托盘图标与全局热键。

排障:插件自检文件

插件每次启动会写 %DSH_HOME%\float-window\plugin-status.json:

{ "version": "0.1.0", "tool": true, "settings": false,
  "notes": ["06:24:39 tool float_window registered", "..."],
  "eventTypes": { "user/message": 3, "assistant/message": 2, "tool/call": 1 },
  "lastRow": { "type": "assistant/message", "row": { "role": "assistant", "text": "…" } } }
  • tool —— Agent 工具是否注册成功(false 说明 tools 服务不在);
  • settings —— 设置命名空间是否注册成功(link: 安装时可能拿不到 schemastery,属已知降级);
  • notes —— 启动自检日志(服务探测、endpoint 路径、错误);
  • eventTypes / lastRow —— 会话事件直方图与最近一次映射结果,用于核对不同 DSH 版本的事件字段名。

License

MIT

S
Description
DSH 悬浮窗:始终置顶、可固定位置/尺寸、支持毛玻璃的会话镜像小窗;Agent 抓屏时自动隐身,不干扰屏幕操控(Windows)
Readme MIT
464 KiB
Languages
JavaScript 52.6%
PowerShell 23.9%
HTML 23.3%
Batchfile 0.2%