dsh-projection-persist
English | 简体中文
三个修复的集合(宿主半边 + 浏览器半边,不修改 DSH 安装目录里的任何文件):
- 会话标题在一次连接重建(Host generation reset)中不再掉成「未命名」;
- 会话列表不再被控制帧洪水逐帧全量重建(合帧到 rAF,实测约 6.3× 降幅);
- 运行中的转圈动画恢复旋转(本机 WebView2 恒报
prefers-reduced-motion: reduce)。
症状:会话列表里的标题会短暂变成「未命名」(英文界面是 Untitled),随后自行恢复。
这是一个 DeepSeek Harness(DSH)插件,包含宿主半边与浏览器半边。
症状与根因
会话列表闪一下、标题短暂变成「未命名」,本质是连接被重建时投影(projection)被清空:
- 宿主
dsh-api-gateway对/api/remote.mux的 WebSocket 心跳默认 2000 ms,且MAX_MISSED_HEARTBEATS硬编码为 2。 - 客户端主线程只要卡顿 > 4 s,宿主即判定连接已死并
socket.terminate()。 - 浏览器侧收到 close 1006,
ConnectionController重建 connection generation。 SessionManager.handleConnected()对每一个常驻投影 store 调用store.clear()。ProjectionValueStore#clear()的实现是删除每一行,于是标题丢失,回落到t("session.untitled")= 「未命名」,直到refreshList()返回才恢复。
关键代码锚点(版本 0.2.0-rc.2):
@deepseek-ai/dsh-api-gateway/lib/index.js—startHeartbeat()、MAX_MISSED_HEARTBEATS = 2@deepseek-ai/dsh-api-session-controller/lib/types/client/sessions/manager.js—handleConnected()里的store.clear()循环@deepseek-ai/dsh-api-session-controller/lib/client.js—ProjectionValueStore#clear()
这个插件做了什么
替换 ProjectionValueStore.prototype.clear,保留每一行的值,只丢弃序号水位:
function retainValues() {
for (const [key, row] of this.rows) {
if (row.kind === "sequenced") this.rows.set(key, { kind: "cached", value: row.value });
this.changed(key);
}
}
- 值保留 ⇒ 标题仍可读,
buildListSnapshot()不再回落到「未命名」。 - 水位丢弃 ⇒ 紧随其后的
refreshList()送来的 cached 块可以照常覆盖。 - 每条
sequenced行原地降级为cached行。
为什么不能简单地改成空操作(no-op):
apply()会拒绝 seq 不更新的帧 (if (row?.kind === "sequenced" && seq <= row.seq) return;), 保留旧水位会把新一代连接的低 seq 帧永久挡住 —— 所以必须保留「重置水位」的语义。
同时它也会覆盖懒创建的 store(包装 manager.projectionStore),并在卸载时完整回滚。
安装
从 GitHub 安装(推荐)
在 DSH profile 目录里用 pnpm 添加本仓库:
dsh plugin --profile <你的 profile> add github:iuuuuuuuu/dsh-projection-persist
或者直接进 profile 目录手工装:
cd "$DSH_HOME/profiles/<你的 profile>"
pnpm add github:iuuuuuuuu/dsh-projection-persist
安装后把包名加进 profile 的 package.json → dsh.profile.bundles:
{
"dsh": {
"profile": {
"bundles": [
"dsh-projection-persist"
]
}
}
}
改 dsh.profile.bundles 会触发热重载,通常不需要重启。
手工安装(不经过 pnpm)
把本仓库复制成 $DSH_HOME/profiles/<你的 profile>/node_modules/dsh-projection-persist/,
再把 "dsh-projection-persist" 追加进同一 profile package.json 的 dsh.profile.bundles。
验证
插件生效时,宿主半边会在 <DSH_HOME>/logs/projection-persist.log 追加一行安装记录:
{"kind":"install","ok":true,"revision":9,"installed":"instance","wrapped":true,"swept":42,"prototypePatched":true,"stores":42,"framePatch":"patched","spinner":"installed","watch":"watching"}
也可以直接查诊断端点(GET 返回当前 revision):
curl http://127.0.0.1:3080/dsh-projection-persist
# {"ok":true,"name":"projection-persist","revision":9}
回滚
两种方式都是热生效,任选其一:
- 删掉
node_modules/dsh-projection-persist/cordis.patch.yml里的- insert:行; - 从 profile 的
dsh.profile.bundles里移除"dsh-projection-persist",或直接删掉整个包目录。
插件卸载时会自动恢复原 clear() 与 manager.projectionStore。
兼容性
- 只依赖
sessions服务,不声明任何 peerDependencies,因此不会被兼容性检查判为不兼容。 - 不修改 DSH 安装目录,DSH 升级不会覆盖它。
- 已验证版本:
@deepseek-ai/dsh-api-session-controller0.2.0-rc.2。 - 只影响管理器持有的投影 store;会话自身持有的 store 不在此列(该 store 的
seqOf有其它消费者)。
另外两个修复(同一个客户端半边)
1. 会话列表整表重建合帧
SessionManager 的列表 Notifier 原本用 markDirty()(microtask 合帧)。控制帧是一条条独立的 socket task,
microtask 根本合不掉它们,于是 8–15 个控制帧 ⇒ 8–15 次 projectList() 全量重建 + 无条件 list.set()。
插件把列表 notifier 的 markDirty 换成上游预留但从未启用的 markFrameDirty()(rAF 合帧,last-write-wins 不变,
ensureFresh() 保证同步读仍然新鲜)。Session.notifier 不动 —— 它的同步契约由 beginSubmission/finishSubmission 依赖。
实测(交错 A/B × 3 轮,8 s/窗):
| 触发次数 | 整表重建 | list.set |
每次触发重建 | |
|---|---|---|---|---|
| 打补丁 | 176 | 14 | 34 | 0.080 |
| 未打补丁 | 288 | 145 | 165 | 0.503 |
约 6.3×(按观测帧率折算约 18/s → 约 1.8/s)。
2. 运行中的转圈动画
在 WebView2 宿主里 matchMedia("(prefers-reduced-motion: reduce)") 恒为 true,
命中 StateDot.module.css 的 @media (prefers-reduced-motion: reduce) { .spinnerMotion, .spinnerArc { animation: none } },
于是会话正在跑的时候不转圈。插件注入一段自有 <style>,在同一个 media 块内用 !important 重声明关键帧
(选择器限定 svg[data-state="ongoing"] > g 与 circle[class*="spinnerArc"],不误伤另外 9 个合法的 reduced-motion 块),
并在 <head> 上挂一个 childList MutationObserver,元素一旦被谁删掉就立刻重装(实测 healedAtMs: 83)。
单独关掉转圈修复:localStorage["dsh-projection-persist.spinner"] = "off"。
心跳阈值(另一条链路,本插件不负责)
把 websocketHeartbeatIntervalMs 调大只会降低重连发生频率,不能消除重连本身。两者叠加才是完整防线:
- 调大心跳间隔 ⇒ 减少重连频率;
- 本插件 ⇒ 重连真的发生时标题不再丢。
License
MIT
No comments yet. Be the first to write one.