DeepSeek Harness 的跨机器会话同步。
把本机选中的会话发布到一台同步服务器,并在服务器上按机器、按它们所在的目录浏览 每一台连上来的机器的会话。在那里打开的会话可以被接管:在控制台里输入的 prompt 会被转发给拥有该会话的机器,由它送进自己的 agent 循环,结果再流回来。
dsh plugin --profile web add github:cczzyy-cn/dsh-session-sync构建好的 Host 半边与浏览器半边是有意提交在本仓库里的。从 git 安装的插件无法
在这里自己构建:浏览器半边必须先作为 client/client.js 存在,客户端模块注册表才会
提供它;而 pnpm 在消费者把它加进 allowlist 之前会拒绝执行 git 依赖的 prepare
脚本——vision 与 dshmarket 正是基于同一条理由从 git 安装的。所以源码改动在提交
之前需要先跑 scripts/build-and-install.ps1。
每一项贡献都走官方自带的插槽。更早的一版还往侧边栏的浏览区里挤了一瞥,并为此带了
一个 28 行的补丁去声明 sidebar.region.section;控制台的面板行并不需要这样的座位,
所以那一节、那个补丁,以及它要求的 checkout 改动,现在都已经删掉了。如果你的 DSH
checkout 里还留着已应用的补丁,它现在是惰性的,可以用
git -C <checkout> checkout -- packages/client/ui-sidebar 还原。
| 界面 | 插槽 | 是什么 |
|---|---|---|
| 插件页 | plugins.detail.section(id session-sync) |
本插件的配置:本机名称、服务器域名/IP、服务器开关、连接密码、监听地址/端口、逐会话的发布列表,以及两端各自上报的插件版本。在插件页打开本 bundle 的那张卡,表单就在「包含的组件」下面 |
| 侧边栏面板行 | sidebar.panellist(id session-sync) |
打开控制台的入口,也是折叠成窄栏后仍然留下的那一个 |
| 中央面板 | main(key session-sync) |
控制台:一棵 机器 → 目录 → 会话 树,旁边是所打开会话的对话与接管输入栏 |
三处都是增量的:不替换任何自带单元;侧边栏行与控制台共用一个 id,因为框架要用
已注册的 main key 来校验被选中的面板。
-
列表就是工作区浏览器。 机器是树的第一层,它们的
cwd是第二层,会话是第三层—— 同样的 34px 项目行、同样的 32px 会话行、同样的悬停时变成展开箭头的文件夹字形、 同样的尾部相对时间。远端会话看起来就该和本地会话一模一样,因为区分它们只是"这一行 住在哪里"的细节,而不是这一行是什么。 -
对话列就是那段对话。 居中的阅读列上限 920px,用户 prompt 是右对齐气泡,助手 回答是 Markdown,思考折在一行后面,每个工具调用是一行摘要、展开后变成它的工具调用 所要求的卡片——终端转录、带总计的 diff、限行数的文件读取、搜索命中、抓取的网页—— 以及一张抬起、22px 圆角的输入卡片与圆形发送按钮。仍在运行的一步会在那一行下面随着 到达显示它的思考与回答;折起的思考行在流式时扫动并跟随最新一行;被打断的回答带着 自带的
已停止标签;模型重试显示自带的details行与倒计时;失败或撞上输出上限的 一轮显示自带的提示;一轮收尾的回答带着复制、用量与运行时长这些动作——每轮一行, 外加每个用户 prompt 一行。那一行要等这一轮收尾:一轮还在产出时,它的叙述不带任何 动作(自带页脚自己的规则),所以那一行不会先出现再移动。一旦它在了,就遵守自带的 就近规则:最新一轮的行常驻,更早一轮的行在悬停或键盘聚焦时出现,更早的 prompt 的 时钟与复制行在后一个 prompt 出现后也一样。助手块保持模型写出来的顺序:推理与正文 交错,而把所有推理块提到最上面,就是在改写它实际说过的话。 -
没有机器面板。 机器是树的一层,所以"选一台机器"和"打开一个会话"是同一个手势; 单独一列只会把行里已经写着的东西再说一遍。
-
事件不全的会话会在读者已经在的地方说明。 计数是逐会话的——在它的树行上、 相对时间之前,以及面板头部标题旁——因为设置页里的总数只说了缺多少,没说该重新发布 哪一个会话。打开的正文顶部带着一行安静的
加载更早的消息,理由相同:它只在镜像 不是整段对话时出现,并且向拥有该会话的机器读取它窗口之后的那一页。在画自带面板的 那条路线上,这一行只在该面板滚动到顶部时才显示:自带对话带着它自己的滚动主体, 所以画在它旁边的一行会永远停在对话上方——把历史递给一个离它该在的那一端还很远的 读者——而不是标出取回的那段从哪里开始。那条路线还得自己维持读者的位置:自带聊天 在它自己控件的处理函数里武装分页锚点,而这个控制台通过自己的通道翻页,所以这份补偿 写在这里、写在 layout effect 里,且绝不在读者正跟随尾部时触发。 -
不发明任何用户可见的东西。 两个面板都复用
ui-primitives(DisclosureRow、MarkdownText、Input、StateDot、Button)与自带 token, 所以控制台会跟着产品一起响应主题切换、字号偏好与细线变化。 -
只要构建允许,对话就是自带的那一个。 控制台用产品真正的
conversation.content工厂——它的正文、它的工具卡、它的轮次折叠——来画打开的远端 会话,走的是已发布 DSH 提供的唯一一条路线:路线 需要 给出什么 scopectx.sessions.retainAgentScope+binding自带面板,且完全不需要 Host I/O;控制台自己的接管输入栏顶替自带输入胶囊,自带的统计行与占用率环留在原地做底部 两个页签都是自带的那一页。 自带 shell 从面板传下去的 owner prop 里挑
conversation.view条目(DefaultConversationViews:viewId = view ?? active?.id), 而这个 prop 只能由控制台提供给自带内容工厂的本地位置——所以"对话/轨迹"这两个页签就是 把chat或trajectory递下去。轨迹页因此是ui-trajectory那一页本身:逐请求编号与 累计用量、系统提示词行、调用 schema、JSON 折叠、时间线框选与搜索、台账表,都是它渲染的, 数据来自镜像正在喂的同一个组装器。构建里没有那个条目时(ui-trajectory缺失或被禁用;conversation.view是 list 槽,entries()对未声明的键答空表),页签落回控制台自己那版 台账,和底部一样的兜底形状。自带轨迹页自己的"加载更早"会先翻它持有的窗口、再向 Session 要更多——那是一次到不了源站的 Host 读取,所以那一页上真正能翻镜像的是控制台自己那行
加载更早的消息(它翻的是同一条 链路、同一个组装器)。同样地,自带轨迹页里的图片走的是与自带对话页完全相同的那条 Host 授权读,所以这一点不是轨迹页新带来的。只有当构建没有这条接缝时,控制台才保留上面描述的手绘面板,且原样不变——所以原版 构建失去的是像不像,绝不是能不能用。路线在
src/client/official-session.tsx里做特性探测,判断本身是一个受测试约束的纯函数(src/client/routing.ts里的scopeCapable);sessions有意不放进本插件要求的inject列表,因为控制台必须 在早于这条接缝的构建上也能加载。另外两条路线做过又删了。
adopt(ctx.sessions.adopt)曾是对@deepseek-ai/dsh-api-session-controller的纯客户端增补,以源码补丁形式存在patches/里、对着dsh-v0.1.7-alpha.2;它是唯一能把一条能到达源站的 prompt 递给 自带输入栏的路线,而下一次dsh安装就把它丢了——没有任何已发布构建带它,所以那段 代码是死重。address(用编目的父身份去retain)只需要已发布的界面,但引用方自己 的 Host 历史读取永远无法成功,于是它唯一可见的效果是一行失败信息,随后被面板藏掉。patches/作为历史保留:插件已经没有 adopt 路线,所以应用它什么也不会改变。在这条路线上,自带输入栏只有输入胶囊是隐藏的,因为它的 prompt 会送到一个从未听说过 该会话的 Host。控制台自己的接管输入栏顶替它,而它的座位是自带 composer stack 里的
conversation.input.dock——自带 stack 自己的卡片位,正好在它顶替的那枚胶囊上方——所以 胶囊下面的自带统计行与占用率环留在原地自己渲染,底部因此是产品原件而不是复制品。 输入栏区块注册表(ctx.conversation.blocks)会同步抬起,好让理由在那个区块还存在的地方 显示出来——另一个插件为同一会话发布自己的状态时可能把它清掉,这正是"隐藏胶囊"而不是 "信任胶囊"的原因。自带的那两件读的是会话投影,而投影只有 Host 会算——本控制台的 Host 从未听说过这个 会话,所以它们本来一个数也拿不到(统计行会退回数窗口,占用率环整个不渲染)。控制台因此 把已有的口径发布进同一个投影存储(
src/client/footer-projections.ts):机器报的整份 日志优先,没有就数自己持有的那一段。这是结构探测的一条缝——那座存储的文档写着"host 是唯一 计算点",apply不是公开 API——所以探测不到时什么都不做,官方那两件退回它们原本的样子。 数字算的是哪一份 log,官方两件没有地方写,于是头部的徽标负责说这件事,并在控制台自己数 的时候顺手提供「按整份日志重算」。轨迹页、以及没有这条接缝的老构建没有自带 footer,仍由 控制台自绘输入框与自绘状态行;chat 页上这一份也常驻——自带那个座位能不能挂上取决于 自带内容能否解析出该会话的输入壳,那是面板从外面看不到的规则,所以两份卡片都在,由.officialPane:has([data-sync-composer]) ~ .composerRoot让控制台那一份退场:自带座位在, 它就是屏幕上的那一个;自带座位不在,会话也不会变得没法说话。从面板可以够到更早的历史。镜像提供的是一个尾部窗口,而自带对话自己的"更早一端"控件 会去问那个从未听说过该会话的 Host,所以给它的窗口从不宣称更多;控制台自己的翻页才是 那条路,面板上方一个
加载更早的消息控件每点一次就经同步链路读一页并前插进同一个 窗口,于是读者保住了自己的位置。页从两条路到达——控制台读到的那一页,以及镜像向下 长出之后源站发来的普通重放帧——所以面板会丢弃窗口已经携带的信封,并把比它更低的 当作历史,因为自带对话的组装器要求每个节点的匹配按序号顺序出现,而把一个更早的事件 追加进去会打断它。呈现方式跟随该 DSH 安装自己的设置。工作详情模式(
ui-chat的transcriptView: compact、standard、detailed 或 verbose)是一个由 Host 支撑的聊天设置,经插件自己的configForms作用域读取,而面板就是同一个客户端实例——所以镜像来的会话会和服务器 自己的窗口一样折叠已完成的轮次、归组它的过程行、预览已定稿的推理,包括面板打开期间 做的改动。这个设置不覆盖的是逐行展开:展开某一行推理或工具行是本地点击状态, 在控制台里和在别处一样。
配置的真源是 DSH 的设置层:本插件声明了一份 Host Config,Loader 校验该行的
config: 映射,插件页(plugins.detail.section,id session-sync)据此渲染出表单,改动会
热提交给正在运行的插件,不用重挂载。
插件自己那份文档 $DSH_HOME/dsh-session-sync.json(通常是
~/.dsh/dsh-session-sync.json)仍然存在,但换了两重身份:没有 settings 服务的
组合里它是回退存储(插件必须在那种组合里也能用),而对已有安装它是一次性导入源。
第一次在带设置层的组合里启动时,插件读那份文档、把每个值写进命名空间并确认存储成功,
最后才把它改名成 dsh-session-sync.json.imported-<时间戳>.json——绝不删除。崩在
中间只会重放一次幂等的写入,崩在写入当中则文件原样保留,所以你的设置不会丢。
- 把 本机名称 设成其它机器应当看到的名字。
- 打开 作为服务器。
- 设置 连接密码 —— 每个客户端都必须用同一个值。
- 监听地址 留在
0.0.0.0以接受其它机器,或设为127.0.0.1只接受本机。 - 监听端口 默认
8791;该端口被占用时改掉它。 - 保存。
- 设置 本机名称。
- 作为服务器 保持关闭。
- 把 服务器域名 / IP 设为服务器的主机与端口,例如
192.168.1.10:8791。 默认按明文 HTTP 处理;服务器在 TLS 后面时显式写https://…。 - 设置同一个 连接密码。
- 保存,然后在 会话列表 里勾选要发布的会话。
客户端(源站) 服务器
─────────────── ──────
ctx.sessionController.list() ──POST /publish──▶ SyncHub 索引
follow(sessionId) ──持久事件──POST /frames──▶ SyncHub 事件
follow(sessionId) ──整步文本──POST /stream-delta──▶ 瞬时帧
page(beforeSeq) ──更早历史──POST /frames──▶ SyncHub 事件
prompt(sessionId, text) ◀──SSE /stream── DownstreamCommand
重新打开 follow / 读一页 ◀──SSE /stream── {kind:'resync'} / {kind:'older'}
│ │
└────────POST /ack (ok | reason)─────────────▶│ 命令状态
│
浏览器:/dsh-session-sync/events ◀──SSE───────────┘
/dsh-session-sync/transcript ──分页──▶ 镜像的窗口
- 源站为每个已发布的会话保持一条
ctx.sessionController.follow流,并转发它的持久 事件——每个都带着说明它是追加还是替换的界面位置——外加一步仍在运行时它的助手帧 所携带的流式步骤文本。 - 一批事件在服务器确实拿到之前不算已发布。 follow 把事件交给链路,链路把它们放进 outbox 并重试,直到某次 post 被接受;成员资格负责其余部分,所以一次重放或一次到达 两次的重试不改变任何东西。挂住的 post 在 20 秒后判失败,因为被网络抖动黑洞掉的连接 以前会让 fetch 永远 pending——outbox 停止排空、没有任何重连,而链路看起来健康却在 什么都没发布。
- 镜像用两个不同的读数来数自己缺什么。 每个会话上报
holes——缺失在镜像所持范围 内部的序号,那是清扫欠下的一次修复——以及behind,源站已发布到镜像顶部之上的 事件数,那只是"一个正在跑的会话"的样子。两者之和是missingEvents。控制台把两者 分开显示:只有洞才显示红色缺 N 条,落后则显示安静的落后 N,因为用一个徽标 表示两者会让每个健康会话都看着像坏了。源站在索引里声明自己的范围(lastSeq), 所以什么都没持有的镜像不会被误认成一个本来就没什么可持有的会话。 - 缺什么就去要什么。 服务器请求源站重新打开某一个会话的 follow
(
{kind:'resync'},缺口还在时每 30 秒重试);开场快照被重放进镜像,而由于决定 什么是"新"的是成员资格而不是高水位线,这次重放会填上一个洞,而不会被当成历史丢弃。 重放够不到的洞——远在窗口之下的那种——单独去要,作为一页、瞄准该洞的第一个序号。 - 两端各自声明自己的版本。 每个索引都携带发布方构建的插件版本,
/state携带本 Host 自己的;插件页把两者并排打印,不一致时说明。同一个部署的两个半边是在不同时间 加载的——源站保留它启动时的那个 Host 半边,服务器保留pnpm install最后放进去的 那个——而在这之前,手工读两份 lockfile 是唯一能察觉这件事的办法。 - 窗口之下的历史也去要。 follow 从一个尾部窗口开始,所以长会话的镜像从对话中段
开始。源站说明它自己的日志在已发布内容之下是否还有(
hasOlder,来自开场快照的hasMore),控制台提供镜像所持内容的一页 400 条事件,而想要更早的读者就去要它后面 那一页({kind:'older'},50 条消息)。源站从自己的日志里读出那一页——对着取窗口 时的同一个切口——它再以普通持久事件的形式回来。 - 已发布的一批会被切分以适配线路。 一个长会话上的 follow 开场就是它整个窗口在一帧
里,那是若干 MB:这里实测,一个真实会话的 3,478 条事件序列化成 12.5 MB,而同步
服务器拒绝超过
MAX_BODY_BYTES(4 MB)的请求体。一个过大的 POST 不是一次孤立的 失败——那一批会留在 outbox 头部、每次重连都被重试,于是 follow 永远完不成,它的cursor停在-1,而对着那个 cursor 切的每一次读页都被拒。所以batchEvents(src/shared/protocol.ts)按从同一个上限推导出的字节预算、按顺序切分 一段事件,不丢任何东西;单个事件大于整个预算时独自成行,而不是被丢掉。监听器在 读取之前就用一个具名的 413 回应过大的请求体,因为对着未读的请求体抛错会让 Node 重置连接——而网络错误不是发送方能据以行动的拒绝。 - 取消发布一个会话会把它从索引里移除,镜像及其事件随之被丢掉。
- 接管 prompt 沿着源站自己的 SSE 流下行;源站调用
ctx.sessionController.prompt, 后者会先唤醒一个冷会话,再收下这条消息。
POST /command 用一个 commandId 作答,随后服务器沿浏览器自己的事件流叙述这条命令
后来怎么样了:
| 状态 | 含义 |
|---|---|
queued |
已受理;没有挂上源站流,所以它在等待 |
delivered |
已写入拥有它的机器的流;还没有任何确认 |
accepted |
那台机器把 prompt 收进了它的会话 |
failed |
那台机器拒绝了它,并给出理由 |
expired |
在那台机器确认之前 TTL 已过 |
- 命令携带
expiresAt(COMMAND_TTL_MS,两分钟)。prompt 是一个指向某个可能已经往前 走了的会话的人类动作,所以一条在拥有它的机器休眠期间留在队列里的命令会被作废, 而不是稍后被当成"刚刚输入"收下。两端都执行这条:服务器在自己的 reconcile tick 上 清扫,源站即使那次清扫还没跑也会拒绝一条已过期的命令。 - 每台机器的队列是有界的(
PENDING_LIMIT,32 条命令);装不下的以该理由作废,而不是 去增长服务器的内存。 - 浏览器只叙述它自己发出的命令,按
commandId匹配。
它们全都在 /dsh-session-sync 之下,并且都在 GUI 自己的门禁之后。
| 路由 | 方法 | 是什么 |
|---|---|---|
/config |
GET | 插件的配置加上当前状态 |
/config |
POST | 打补丁修改配置;以新配置、状态与本机会话列表作答 |
/state |
GET | 每个界面都读的状态:角色、链路、镜像、本 Host 自己的 pluginVersion,以及逐会话的计数 |
/sessions |
GET | 本机自己的会话列表,供发布选择器使用 |
/transcript |
GET | 某个镜像会话的一页(machine、session,可选 limit、before);读者到达镜像边缘时向拥有它的机器索取窗口之下的历史 |
/command |
POST | 一条接管 prompt;以其状态被叙述所依据的 commandId 作答 |
/answer |
POST | 本控制台对某台机器转来的一个提问的回答(machineName、questionId、answers);以携带它的 commandId 作答,或用 409 加该提问已不再开放的理由 |
/approval |
POST | 本控制台对某台机器正被阻塞的一个审批的决定(machineName、approvalId、decision 为 allowed-once 或 rejected);以携带它的 commandId 作答,或用 409 加该审批已不再等待的理由。任何别的取值都是 400:cancelled 与 unavailable 描述的是作答方,不是控制台可以做出的决定 |
/events |
GET | SSE 流:状态帧、逐会话的事件帧、转来的提问,以及瞬时的实时文本 |
一个跑在另一台机器上的会话可以在轮次中途停下来问它的人一件事——ask_user,或者一个
审批。上游把这个请求交给一条 waterfall:第一个返回答案的作答方认领它,next()
则委派给它后面的作答方。自带浏览器 UI 就是这样一个作答方,经 Remote waterfall 桥接到
达,而它是原版安装唯一拥有的那一个。
本插件代表被发布的会话,把自己注册在那个作答方之前,然后同时问两边:那台机器自己
的 UI 走 next(),本控制台走同步链路。谁先答谁赢,另一边被告知这件事,而不是留在
那里猜。
机器(提问方) 同步服务器 / 控制台
─────────────────── ─────────────────────
agent 调用 ask_user
│ waterfall: user-questions/request
├─▶ 插件,注册为前置
│ ├─ next() ──▶ 那台机器上的自带浏览器 UI ─┐
│ └─ POST /question/open ──▶ hub ──▶ 控制台里的卡片 │ 竞速
│ │
│ 第一个答案认领该提问 ◀───────────────────────────────┘
├─▶ 本地作答 ⇒ POST /question/close (answered-at-origin)
└─▶ 控制台作答 ⇒ DownstreamCommand {kind:'answer'} ⇒ 被认领,或
以"已被回答"的理由被拒
- 什么都不镜像。 一个提问只在机器等待它的时候存在,源站在自己的人答出答案的那一刻 就撤回它——所以提问永远不会变成正文的一部分,而服务器重启只会丢掉"可以作答"的 机会,绝不会丢掉那次提问。
- 控制台的回答乘 prompt 的生命周期,理由正是这个生命周期存在的理由:机器不在时 留住、只投递一次、有 TTL,以及一个说明机器是否认领了它的 ack。认领是这场竞速的 终点线,而它由那台机器判定——那一方仍然能看到该提问是否开放。
- 输掉的竞速会被报告,不会被重试。 在机器自己的人之后作答的控制台会收到
failed, 带着*"this question was already answered on the machine that asked it"*,卡片照此 说明。那是双边竞速的常规结果,不是错误。 - 只有已发布的会话才会被转达。 其它任何情况都在第一行就委派下去,所以你没有勾选 同步的会话,其行为与没有本插件时完全一致。
- 输的一方会被计数。
state.interactions上报open、answeredLocally、answeredRemotely、lateAnswers与aborted:没有它们,"控制台从没提供过这个 提问"和"控制台提供了、机器先答了"从两端看是同一种观察。 - 提问不是接管 prompt。 prompt 对会话说点什么;回答则决定它正在等待的 某件事。回答会走到提问的那台机器,绝不走到本 Host,而模型把它看作它一直在等的那个 工具结果,而不是一条来自用户的消息。
本插件作答的另一条接缝是 approval/request,它的形状和提问接缝完全一样:一条
waterfall、一个认领请求的首位作答方,以及坐落在本插件之后的自带浏览器 UI。不同的是
认领意味着什么。提问的答案是信息。这里的结局是权限:allowed-once 放行一次
本机自己的权限预设正在拦截的工具调用。所以竞速相同,条款不同,而差别出现在四处:
- 它是一份独立、逐会话的勾选。 发布一段对话是读;决定一个审批不是。
state.config.approveSessions列出其审批可以被某个控制台决定的会话——默认为空、 绝不从syncSessions推断,而插件页只为同时已发布的会话画这个开关(控制台从 镜像解析卡片的参数,所以未发布的会话会把一个读者看不见的东西上的权限递给读者)。 - 机器的权限预设仍然说了算。
never策略由上游审批服务在派发approval/request之前执行,所以没有任何监听者——包括本插件——能把一个被拒的操作 变成被允许的。这里能决定的只有策略留下来的那部分。 - 只有两种决定会传递,且只有两种。 上游的词表里还有
cancelled与unavailable, 它们描述的是作答方而不是一个决定,而unavailable是调用方必须从自己那一侧收到 的 fail-closed 值。控制台可以产出allowed-once或rejected,声称别的取值的帧会在 路由和认领处都被拒,于是请求保持开放、仍能被正确地决定。 - 除了放行,每一种结局都是 fail-closed。 一个 TTL、一次被中止的轮次、一个被判为
过晚而拒绝的决定、一台掉线的机器:它们全都让该操作得不到放行,因为调用方把
allowed-once之外的任何东西都当作拒绝。
卡片显示的是实际将要运行的东西,由镜像正文按该提议所命名的 callId 解析出来——
这条接缝不发放参数,而控制台本来就持有那次调用。当窗口不再携带它时,卡片会照实说明,
而不是显示一个光秃秃的工具名:"允许 bash"和"允许一次本窗口已无法展示给你的 bash 调用"
是要回答的两个不同问题。拒绝是一个真实按钮,因为它是一个真实的决定。
机器(阻塞在一次工具调用上) 同步服务器 / 控制台
──────────────────────────────── ─────────────────────
agent 想进行一次受限调用
│(`never` 预设已经拒绝了它——这里根本不会被派发)
│ waterfall: approval/request
├─▶ 插件,注册为前置,仅对已勾选的会话
│ ├─ next() ──▶ 那台机器上的自带浏览器 UI ─┐
│ └─ POST /approval/open ──▶ hub ──▶ 控制台里的卡片 │ 竞速
│ │
│ 第一个决定认领该审批 ◀───────────────────────────────┘
├─▶ 本地决定 ⇒ POST /approval/close (allowed-|rejected-at-origin)
└─▶ 控制台决定 ⇒ DownstreamCommand {kind:'approval'} ⇒ 被认领,
或以"已被决定"的理由被拒
state.approvalCounts 的计数方式和 state.interactions 一样,外加 offered:
一个根本没被转达的审批(它的会话没有勾选)不能看起来像被提供了却输掉竞速的那一个,
因为前者是配置事实,后者是竞速结果。
更早的一版还把每个镜像会话写进服务器自己的会话存储,让它在那边成为一个真会话—— 列在 DSH 的工作区浏览器里、在 DSH 自己的对话页里打开,由 Host 提供它的历史与翻页。 它被删掉了,理由是结构性的,而不是口味问题:
- DSH 会往它打开的会话里写。 用一个种子恢复会话会追加它自己的
session/end-seed, 而在里面输入一条 prompt 会留下一个轮次骨架——两者都落在源站下一条事件所需的序号上, 而那正是一份副本绝不能有的东西。 - 所以副本只能在冷的时候被推进,这与"被读"正好相反:一个开着这个页面的读者 就是它不能推进的原因。诚实,但这意味着官方页显示的是一个快照,而它有多陈旧 得由插件来解释。
- 让副本走 DSH 自己的会话——保持它活着的最显然办法——试过又回退了:那会让 DSH
成为这段对话的参与者,而不是它的记录者。在已部署的服务器上实测,一个镜像来的
被打断的
tool/result让 Host 在副本里结束了那一轮,而源站的日志里下一步还在。
一个每次失败都需要运维介入的功能——release、停掉 Host、把日志移开、清投影缓存、
再启动 Host——不值当再做一个阅读界面。控制台现在就是那个界面,而且是唯一的:它由本
插件自己的事件流供给,它实时跟随正在跑的会话,它经同步链路翻更早的历史。它放弃的是
DSH 围绕镜像会话自己的外壳,而在构建提供 ctx.sessions.retainAgentScope 的地方,
自带渲染器仍然在面板内部把它画出来。
-
同步传输是本插件自己拥有的一个
node:http监听者(服务器上是0.0.0.0:<listenPort>),由密码握手与一个 bearer token 守卫。它有意不是 GUI web 服务器上的一条路由,所以暴露同步永远不会连会话 GUI 一起暴露。 -
浏览器界面注册在
ctx.webServer上、位于/dsh-session-sync之下,与 GUI 同源,并且采纳 GUI 自己的门禁:每个请求都经过ctx.connection.requestRejection, 那是把组合的浏览器会话与 Host/Origin 围栏放到另一条路由前面的自带接缝。同一个 cookie、同一个启动 token、同一道围栏——没有第二份秘密。这不是自动的,值得直说:前缀路由是在执行前端门禁的那个兜底之前被匹配的, 所以一条没有守卫的插件路由,任何能到达该端口的人都能到达。本插件更早的一版正是如此, 它对
/dsh-session-sync/config——包括同步密码——回了 200 且不要 token。在反向代理后面,部署必须声明自己的 authority,否则围栏会以 403 拒绝它(GUI 自己的
/api也一样):dsh --profile web --port 3080 --trusted-host dsh.example.com
同步传输默认是明文的:serverOrigin()(src/shared/protocol.ts)在配置的
serverUrl 不带 scheme 时补上 http://——这是给局域网与回环部署准备的默认值。所以
一个把同步口绑在公网接口上的部署,会把握手密码与每一段被镜像的正文明文发过公网。
下面这一次就是这么暴露的(服务器 dsh.c-zy.cc / 210.16.120.228,监听 0.0.0.0:8791),
而修法把一个通用问题答成了可复用的形状:插件的监听者退到回环,TLS 交给已经在管
证书的那个反向代理。
| 面 | 值 |
|---|---|
| nginx | listen 8791 ssl(server_name dsh.c-zy.cc),证书用 /www/server/panel/vhost/cert/dsh.c-zy.cc/{fullchain,privkey}.pem |
| 插件监听者 | listenHost: 127.0.0.1、listenPort: 8792 |
| 源站 | serverUrl: https://dsh.c-zy.cc:8791 |
三步,每步都有一件必须知道的事:
-
先把插件的监听地址与端口改成回环。 这两个字段是
.volatile()的 (src/host/config-schema.ts),所以改动经设置层热提交给正在运行的插件、 不需要重启——实测:进程没有重启,ss -lnt里那一行就从0.0.0.0:8791变成127.0.0.1:8792。这一步必须在前面:插件没有先让出那个端口,nginx 就绑不上 8791。 -
再加这个 server block。 它放在
/www/server/panel/vhost/nginx/dsh-session-sync.conf, 而不是面板自己的站点模板里:面板不会重写这个目录之外的文件,而面板的nginx.conf会include /www/server/panel/vhost/nginx/*.conf。server { listen 8791 ssl; server_name dsh.c-zy.cc; ssl_certificate /www/server/panel/vhost/cert/dsh.c-zy.cc/fullchain.pem; ssl_certificate_key /www/server/panel/vhost/cert/dsh.c-zy.cc/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_session_cache shared:SSL:10m; ssl_session_timeout 10m; access_log /www/wwwlogs/dsh.c-zy.cc.sync.log; error_log /www/wwwlogs/dsh.c-zy.cc.sync.error.log; client_max_body_size 8m; location / { proxy_pass http://127.0.0.1:8792; proxy_http_version 1.1; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_buffering off; proxy_cache off; proxy_connect_timeout 30s; proxy_send_timeout 3600s; proxy_read_timeout 3600s; } }
-
最后把源站的
serverUrl改成https://dsh.c-zy.cc:8791。serverOrigin()见到显式 scheme 就原样保留,所以这一改同时换了协议与端口。
两条指令是承重的,各自都能从本插件自己的设计推出来:
client_max_body_size 8m。 发送方按FRAMES_BODY_BYTES(src/shared/protocol.ts,=MAX_BODY_BYTES4 MiB 的一半)切分一批已发布事件, 所以单批最大约 2 MiB;而服务器量的是整个 JSON 信封,它比事件数组更大。nginx 默认的 1 MiB 装不下:413 之后发送方会永远重试同一批,表现成"镜像再也不前进", 而不是一次孤立的失败。8 MiB 留的是信封与估算偏差的余量,不是"一次可以发 8 MiB"。proxy_buffering off加长超时。 命令通道是一条单条长连接 SSE (GET /stream,每KEEPALIVE_MS= 15 秒一个 keepalive)。缓冲会把每一条下行命令 压到缓冲满或连接关闭才放行;而一个短的读超时会掐断这条本就长期空闲的连接。两者一起, 才让"接管 prompt"保持即时。
验证读数(在这台服务器上实测):
| 检查 | 结果 |
|---|---|
curl -sk https://127.0.0.1:8791/ |
401 —— TLS 通、插件被到达、未认证 |
curl -s http://127.0.0.1:8791/ |
400 —— nginx 的 "plain HTTP request was sent to HTTPS port" |
从外部 curl -s https://dsh.c-zy.cc:8791/ |
401 |
nginx -T |
这个 server block 在已加载的配置里 |
ss -lnt |
0.0.0.0:8791 由 nginx 持有,插件在 127.0.0.1:8792 |
/www/wwwlogs/dsh.c-zy.cc.sync.log |
出现 POST /frames 200 与 POST /publish 200,client 为 node —— 源站的上传确实穿过 TLS 监听者被接受(也顺带证明没有 413) |
| 源站 | 重新连上,镜像回到 holes 0 / behind 0 |
安全效果是这一层最直接的好处:插件不再绑定任何公网接口,公网只看得到 nginx。
安全的迁移顺序(在一对活着的两端上):① 先改插件监听,让它不再占用公网端口;
② 再加 nginx 的 TLS server block,让它接管那个端口;③ 紧接着改源站的 serverUrl。
第 ③ 步不能拖:只切换了一半的时候,仍写着 http://…:8791 的源站会拿到 400,因为
那个端口此刻是 TLS-only 的 nginx。源站多的时候用更稳的做法——先用另一个 TLS 端口
(例如 8793)起这个 block,把源站一台一台迁过去,最后再回收 8791:这样任何时刻都
只有一台源站在切换窗口里。
这不是唯一一种做法,也不该被当成默认。等价的三条:VPN / 内网(同步口留在私网里,
serverUrl 继续写明文也可以)、防火墙加源站白名单(只放已知源站的 IP 到 8791)、
SSH 隧道(源站经隧道指向服务器的回环端口)。nginx 方案换来的是
"复用一个已经在管的证书与域名",代价是多一个必须与插件的两个上限对齐的配置面。
- 插件的浏览器路由要求与 GUI 相同的浏览器会话(
ctx.connection.requestRejection), 所以它们并不比 GUI 本身更容易到达。没有挂载浏览器前端的组合没有ctx.connection可继承,那时这些路由的保护程度就只等于该组合自己的 web 界面。 - 同步传输(
<listenPort>)是一个独立的监听者,有自己的密码握手。它不在浏览器 会话的覆盖范围内,所以对外暴露的部署应当对该端口设防火墙并使用长密码:浏览器门禁 并不说明谁可以发布或接管会话。 - 密码用
timingSafeEqual比较;token 按机器铸造并绑定到它被签发时的机器名,所以 客户端无法冒用另一台机器的身份发布。 - 密码以明文存放在配置真正所在的地方(迁到设置层之后就是 DSH 的设置文档;没有
settings的组合里仍是dsh-session-sync.json),因为服务器要拿它和客户端发来的值 比较。把那份存储当作秘密对待。声明里它是role('secret'),所以插件页的表单读到的 是"是否已设置"而不是值本身。 - 除非
serverUrl写了https://,传输是明文 HTTP。在不可信网络上,密码与每一段被 同步的正文在途中都是可读的。请使用 TLS 终结代理、VPN,或可信的局域网。 - 发布一个会话会把它完整可读的正文——prompt、助手文本、推理块,以及带参数与结果的 工具调用——送到服务器。不要发布那些触及你不愿放到那台服务器上的材料的会话。
- 镜像只在内存里,服务器重启即消失。
-
服务器可以接管一个会话;它不能开始一个。 prompt 被收进源站的会话,所以接管只对 那边已经存在的会话有效。
-
实时思考与输出在一步运行期间被转达;镜像不保留它们。 每个已发布会话的
follow选择加入进程本地的助手帧,它们的增量按会话、按步骤累积,到目前为止的整段文本每 150 ms 作为一帧瞬时数据发出——这个 tick 是增量自身的速率要求的,因为它们以每秒约 200 个到达,而一整块思考不到三秒就上了线路。控制台把它渲染在正文之下,而持久的 了结——结束该步的assistant/message,或一次失败/被中止的请求留下的assistant/attempt——才是让它退场的东西。这些都不被存储,所以镜像与在步骤中途打开 的控制台是从下一次转达而不是从重放里填满的。一步的思考与它的回答分别累积, 在线的两端都是如此:它们是同一个 attempt id 上的两条流,而转达携带每一条到目前 为止的整段文本,所以任何把两者相互比较的东西都会把回答看成对思考的替换——"思考 只在结束之后才出现"这件事真身就是如此(src/client/live-text.ts,回归测试见tests/live-text.spec.ts)。 -
镜像只显示一个会话的最后 4,000 条事件;更早的历史会从镜像本身被裁掉。控制台 按页读取镜像所持内容——400 条事件、最新在前——而镜像缺的那一页,会在读者要的时候 从拥有该会话的机器上读取。那次读取不是瞬时的:它是对那台机器日志的一次读、一次 回程 POST,以及穿过挡在前面任何代理的一趟,实测在跨境链路上是十到二十秒,所以那个 控件是等待而不是立刻作答。
-
镜像中段的洞会被修复,但那次修复从未在真实部署上被观察到发生。 清扫会注意到 一个洞,服务器向源站要一页瞄准它的内容,三层测试都钉住了这件事 (
tests/hole-repair*.spec.ts);但在已部署的那一对上,没有任何洞在人看着的时候 发生过,所以诚实的说法是"已实现、已测试,从未在野外见过"。留下来的洞会被如实报告 ——控制台在它的行上与头部显示缺 N 条。 -
镜像来的正文不携带任何由 Host 算出的面板。 自带对话提供它的变更审查卡片,而 官方
ui-deliverables插件是通过向它自己的 Host 要/api/changes.summary?sessionId=…&seq=…来填一张的。镜像会话活在同步服务器上, 所以那个 Host 对每一条公告都会回 404——该插件常规的"已不再服务"状态,每个一条失败 请求,以及一张卡在不可用上的卡片。数据在这里也无法找回:一条workspace/changes事件只携带{ turn },而文件与总计是由拥有该工作区的 Host 算出来的。所以那条公告 被从控制台喂出的窗口里过滤掉(src/client/official-session.tsx里的PANEL_ONLY_TYPES),卡片永远不出现——真正改了文件的那些工具行是普通事件,会留下。 要让那些卡片可用,就得把摘要经同步链路带过来并且写进那个插件自己的状态表, 那是比本插件今天承担的更深的耦合。 -
镜像面板会隐藏自带的"历史加载失败"行。 一个坐落在(被隐藏的)自带输入栏之后的 会话级集成仍然可以按契约 retain 该会话,而被 retain 的会话会对着拥有它的 Host 打开 历史——对镜像来说,那是一个从未听说过它的 Host。答案是
session/not-found,自带 聊天为它画出自己的"历史加载失败"行。那一行描述的是一次本面板既不用(正文来自镜像) 也无法满足的读取,所以它被藏在面板内部;头部标签说明了路线,那才是这个事实该在的 地方。把会话 View 画成不带内容外壳,曾是避免这次读取的一种尝试,结果是渲染出 一个空面板——外壳正是提供那个 View 所依据的上下文的来源。 -
一个机器名只能有一个源站。 两个用同一个
本机名称配置的源站会互相覆盖镜像。 -
一条接管 prompt 两分钟后过期,且同一时刻最多 32 条可以等待一台机器。两个上限都是 有意的;超出它们的排队 prompt 会被报告为
expired,而不是迟到地被投递。 -
在控制台答出的提问会让机器自己的对话框留在那里。 两边是竞速的,而竞速无法取消 它的输家:自带作答方仍然在那台提问的机器上持有对话框,而本插件没有座位去关掉另一个 插件的 UI。决定已经作出、工具调用也已经返回,所以回答那个陈旧的对话框不改变任何事 ——它自己的
next()链只会结束。为 dismiss 它而去 abort 共享 signal 被否决了: 那个 signal 属于提问的工具调用,abort 它会让答案本来要延续的那一步本身失败。 -
转达来的提问只在读者正在看的地方提供。 卡片出现在提问的那个会话的输入栏之上, 当读者正在看另一个会话时带一行指引。没人看的控制台就是永远不作答,而那台机器自己的 UI 默认获胜——这也是为什么提问十分钟的 TTL 只会让远端这个选项作废。
-
转达来的提问没有真实部署测试覆盖。 竞速、认领、对迟到答案的拒绝与过期清扫由
tests/interaction-race.spec.ts对着真实的 hub 与真实的中继钉住,而传输由tests/question-relay-link.spec.ts在真实监听者与真实链路上钉住——但没有任何测试 把一条提问从某台机器驱动着穿过已部署的服务器再回来;那条路径只被手工走过。 -
转达来的审批已经双向端到端驱动过。 竞速、认领、对"不是决定"的拒绝、对迟到决定的 拒绝以及 TTL 由
tests/approval-race.spec.ts钉住,传输由tests/approval-relay-link.spec.ts钉住,而卡片能对一次调用如实说什么由tests/approval-view.spec.ts钉住。在一次真实部署上,一个受限操作被两次提供给控制台: 一次放行,随后该操作完成(offered: 1/decidedRemotely: 1,带一个/ack且没有/approval/close);一次拒绝,该操作没有发生——它本来会删掉的那个文件 还在(offered: 2/decidedRemotely: 2)。两个方向携带同样的签名,因为控制台的 决定在服务器接受那条 ack 时总会被它闭合,而源站只在它做了决定或撤回时才发一条 close。本仓库里没有一个测试能覆盖的是never策略的优先级,因为它是由上游审批服务 在 waterfall 之前执行的——这个功能最依赖的那条次序,引自上游自己的docs/subsystems/approval.zh.md,而不是在这里断言。处于该策略下的会话根本不产生 请求,那正是offered: 0的含义。 -
在控制台决定的审批同样会让机器自己的对话框留在那里,结构上的理由与提问相同: 本插件没有座位去关掉另一个插件的 UI,而 abort 共享 signal 会让这个决定所针对的那次 工具调用失败。
-
在重连窗口里输入的接管 prompt 曾经会丢,现在会重试。 服务器把一条命令交给它 认为属于那台机器的流;如果那台机器刚掉了链路而服务器还没注意到,这次写入哪儿也没去, 而命令仍然被记成
delivered——并且没有任何东西会重发一条"已发送"的命令。这个窗口 很短(实测约 300 ms)。它由必须协同工作的两半修复:机器拒绝一个它已经收过的命令 id (RecentCommands,对重复确认而不是据它行动),服务器则把一条命令一直欠着, 直到它被确认,每五秒再交一次、最多重试四次,仍由两分钟的 TTL 终结,并把重试次数 报在状态上。少了前半,被重试的 prompt 会在同一个会话里变成第二条 prompt,那比丢失 更糟。tests/command-redelivery.spec.ts钉住了两半,包括一条真实的链路、其中重发的 命令不会被据以行动两次。 -
助手文本渲染为 Markdown,每个工具调用是一行折叠行,展开后变成它的工具调用所要 求的卡片——终端转录、带总计的 diff、限行数的读取、搜索命中、抓取的网页——其它一切 走通用的 IN/OUT 卡片。
-
图像块显示它的事实,绝不显示图片。 镜像携带那个内容块——媒体类型与大小——但不带 附件字节,所以控制台说明"这里曾有一张图",而不是把它画出来。
-
轮次级的折叠没有被照搬。 自带聊天把一轮的整个过程折在一个摘要之下,并提供一条 轮次导航轨;控制台改为列出每一步的行。不过逐轮的用量与时长读数是照搬的——那些打开 自带详情对话框的胶囊(见下)——会话总计则在头部。命令、审批与压缩卡片,以及完整的 输入栏(附件、斜杠命令、模型选择),同样没有被照搬。
-
界面替换会被尊重。 源站转发每个事件的
surfaceOp,所以一个替换掉更早一段的 持久事件——一次压缩、一次 fork 之后的重放——会在控制台里取代那些行,而不是出现在 它们旁边。 -
控制台是抄自带 UI,不是 import 它。 浏览器插件无法 import 另一个插件的组件, 所以树与对话都是本包自己的标记——除非构建提供了 retention 接缝,那种情况下对话 就是自带渲染器(见上),只有树是本包自己的。聊天行做得比"穿上 token"更多:
ToolRow、ReasoningRow、MessageIconActions、TurnUsagePanel、stat-dialog以及无障碍辅助都是自带ui-chat样式表的逐字拷贝,而SyncPanel使用它们的类名 词汇与 data 属性,所以一行的度量就是产品自己的度量。只有ui-chat留在那些共享 样式表之外的两个父级偏移留在这里——用户行的 6px 动作间距 (MessageItem.module.css)与尾部的 4px/-6px IconActions 偏移 (TurnTailNodeView.module.css)。ui-chat在此之外的任何变化——对行标记本身的重构 ——仍然需要重新拷贝,而不是重新推导。 -
那两个轮次统计胶囊是自带的,对话框也是。
用量打开轮次用量对话框,用时打开轮次时长对话框:自带座位(打开状态、useAnchoredPosition在触发器上方的钳制、 外部指针与 Escape 关闭)加上 portal 到 body 的面板,以及自带的那些行——provider/ model、缓存命中、四个 token 桶、内联推理;总运行时长、解码吞吐与首 token 延迟。 吞吐与 TTFT 由镜像来的流记录按 host 使用的同一套算术折出(turn-metrics.ts), 所以某一步记录过它们就会出现,没记录该行就省略。复制动作的 tooltip 是自带的复制/复制成功这一对。 -
服务器重启会丢掉镜像;源站在它们下一次 reconcile tick(10 秒内)加上它们的 follow 快照时重新发布。
PROGRESS.md 推进记录:现状、实测、未解问题
src/shared/protocol.ts 线路与持久化的形状,两个半边共用
src/host/dsh.ts 所用 Host 能力的结构化声明
src/host/config.ts 原子 JSON 配置文档(回退存储,以及一次性导入源)
src/host/config-schema.ts 声明为 Host `Config` 的设置项(全部 `.volatile()`)
src/host/config-store.ts 真源在设置层,没有 `settings` 时回退 JSON 文件;含导入与归档
src/host/hub.ts 服务器侧镜像、扇出与命令生命周期
src/host/interactions.ts 一个提问的双边竞速与它的认领
src/host/transport.ts 同步监听者与源站链路
src/host/service.ts 引擎:配置、follow 集合、发布、接管
src/index.ts Host 插件入口与浏览器路由
src/client/index.ts 浏览器插件入口:插槽与它们的注入
src/client/api.ts 传输,加上每个界面都读的那一份快照
src/client/official-session.tsx 被 retain 的会话:自带渲染器的面板,以及喂给自带底部的投影
src/client/MirrorComposer.tsx 接管输入卡片:两个座位(自带 composer stack 与控制台自己的底部)共用的同一个组件
src/client/composer-draft.ts 未发出的接管 prompt,按会话立案,跨两个座位共享
src/client/footer-projections.ts 底部数字映射成自带 footer 读的那三个投影形状,以及发布水位
src/client/official-views.ts 控制台向自带 shell 请求哪一页(chat / trajectory),以及兜底判定
src/client/QuestionCard.tsx 一个转达来的提问,提供给这位读者
src/client/transcript.ts 镜像事件投影成可读的行
src/client/tool-cards.ts 工具行模型:卡片选择、标签、上限
src/client/tool-presentation.ts 线路工具名的字形与本地化标题
src/client/session-chrome.ts 从日志读出的运行时外壳,以及台账行
src/client/TrajectoryView.tsx 轨迹 tab:工具栏、时间轴条、台账
src/client/message-stats.ts 一行的时钟、运行时长与 token 数字
src/client/turn-metrics.ts 逐轮的 TTFT、吞吐与路线,折出
src/client/stat-panels.tsx 轮次用量与轮次时长胶囊和对话框
src/client/locales.ts 两个界面渲染的每一个字符串,zh 与 en
src/client/*.module.css 自带聊天样式表,逐字拷贝
src/client/css-modules.d.ts 本次构建的 CSS 模块导入形状
src/client/config-entry.ts 配置表单该出现在哪些页面(纯函数,受测试约束),以及那一行摘要
src/client/ConfigSection.tsx 插件页上的本插件配置页
src/client/PanelIcon.tsx 侧边栏面板行的字形
src/client/SyncPanel.tsx 控制台:树、对话、接管
src/host/dsh.ts 以结构化方式声明它消费的 Host 服务,而不是 import 它们:本包没有
workspace 依赖图,所以 @deepseek-ai/* 无法从它的源码里解析。因此 Host 产物除 Node
内置模块外不 import 任何东西,而浏览器产物只把 shell 的 PLATFORM_MODULES 保留为
require() 调用。
powershell -ExecutionPolicy Bypass -File scripts/build-and-install.ps1两个半边都从除 tsdown 与 lightningcss 之外不依赖任何东西的源码构建;当 profile
把本包作为本地依赖时(dsh plugin --profile web add file:<此目录>),该脚本还会
刷新 profile 里已安装的那份拷贝。pnpm 会把 file: 依赖硬链接到本目录,所以一次重建
通常就地生效——那份拷贝是为 tsdown 的 clean 阶段断掉的链接而存在的。
当 profile 持有的是已发布的依赖(github:cczzyy-cn/dsh-session-sync)时,安装树
来自 pnpm 的 store,写在这里的任何东西都到不了它。脚本会构建,然后说明这件事,而不是
写入一份全新安装永远看不到的字节。两条前进的路:
- 想迭代:
dsh plugin --profile web add file:<此目录> - 想发布:提交并推送,然后
pnpm --dir <profile> update dsh-session-sync
powershell -ExecutionPolicy Bypass -File scripts/release.ps1 -MessageFile <tag说明文件>
powershell -ExecutionPolicy Bypass -File scripts/deploy-status.ps1
powershell -ExecutionPolicy Bypass -File scripts/compare-artifacts.ps1 -Restore- tag 是部署契约的一部分:profile 依赖钉的是
#v<版本>,所以没打 tag 的提交装不上。release.ps1因此先校验(版本未被本地/远端打过 tag、产物=源码、两道门禁+测试通过), 再打 tag 并推送分支与 tag;token 仍从 Windows 凭据管理器读,以一次性 header 交给 git, 不碰仓库自己的 git 配置。-DryRun只报告。 deploy-status.ps1逐面打印读数:仓库版本/HEAD/tag/产物哈希、本机安装副本的版本 与设置层标记、本机 DSH checkout、服务器上的依赖 spec / 安装版本 / unit 状态 / 端口 / DSH 版本(从 unit 的npx @deepseek-ai/dsh@<版本>读——profile 树里没有@deepseek-ai,只有 npx 缓存里有)。compare-artifacts.ps1回答"提交的产物是不是这些源码编出来的",三档判定identical/same apart from the build root/DIFFERENT。客户端产物换机器不可能 逐字节相同:CSS 模块的类名前缀是按源文件绝对路径哈希出来的,产物里还嵌着那个 路径。Host 产物没有这种指纹,必须逐字节相同。
一旦安装的那份产物被刷新,HMR watcher 会 stat-poll 它,并在约半秒内热换浏览器
半边(重新加载页面即可)。Host 半边在启动时读取,所以那里的改动需要重启服务器;
scripts/restart-server.ps1 从 host 之外执行一次——这是唯一可行的方式,因为请求重启的
agent 正运行在被重启的那个进程里。
在一台真实的 host 上,对 github: 依赖的一次 pnpm update 换掉了产物而 watcher 没有
注意到:shell 继续用上一个 rev 提供上一次的字节,于是控制台继续渲染旧的那一半。
touch 一下安装位置的 client/client.js 就让它在不到一秒内重新发布
(/plugins/events 为该插件报出了新的 rev,bootstrap combo 也带上了新代码)。
重启同样会重新读取它,因为模块注册表的缓存活在进程里。那台 host 还把它的浏览器产物
装在一个 /plugins/??… combo 里提供,所以核对一次部署,意味着在那个 combo 里 grep
新构建的某个标记,而不是去取 /plugins/<id>/client.js。