dsh-session-hub
把散在各处的会话收进 DSH 的同一棵树。
远端服务器上的 DSH 会话,本机 Codex CLI、Claude Code、opencode、Pi 的历史对话, 一起进官方 Web UI。侧边栏和对话区都用官方的,插件只搬数据。两件事互相独立,可以只装一件。
[!NOTE] 正式版
0.1.0(变更记录)。核心链路都在真机上跑通过:网关路由、官方 UI 桥接、 实时帧注入、审批应答、跨机对话。但只验证过一套环境(Windows 本机 + 腾讯云 Linux,SSH 隧道), 配置格式和安全边界在小版本内仍可能调整。
这是什么
DSH 的会话树只认这一台机器、这一个工具产生的会话。于是:
服务器上跑了一半的任务,要开新标签页、连隧道、从头找起。关掉笔记本再回来,不知道它现在到哪一步。
上周用 Codex CLI 聊过的方案,在 DSH 里根本不存在,只能去翻 ~/.codex 底下的 JSONL。
这个插件把这两类会话都接进同一棵树:
工作区
├── my-project ← 本机工作区(官方原样)
├── another-repo ← 本机工作区
├── tencent ← 远端服务器,点开就是那台机器的会话
│ ├── 部署脚本调试
│ └── 日志分析
└── audio-engine ← 本机工作区,混着导入的历史对话
├── 重构方案讨论 (Codex CLI)
└── 修 CI 那次 (Claude Code)
点开任意一个会话,包括远端的,官方对话区照常渲染:历史、逐 token 流式输出、审批卡片、提问问卷, 全是官方组件。插件只搬数据,不画界面。
远端会话并不是你本机在代跑。agent 循环在那台机器自己的 dsh 里,本机只是个看板。
所以可以关掉本机去吃饭,回来再接上看它跑到哪了:还在跑就从当前这一刻续上实时流,跑完了就看完整历史。
两件独立的事
插件做两件事,互相不依赖,装了可以只开一件(见配置):
🖧 把远端机器接进来
每台服务器成为工作区树里的一个分组(标题带 🖥️ 标识,一眼可辨是远程),组内是那台机器的会话。
- 树内操作是原生语义:+ 新建会话在那台机器上创建、归档 / 删除路由到会话所在机器、重命名分组即重命名服务器、删除分组即断开该连接。
- 会话跑在远端,不依赖你开着:agent 循环在那台机器自己的
dsh里推进。本机关掉、断网、换台电脑,远端该跑的照跑。回来时它可能已经跑完了。 - 中途接得上:hub 启动时若远端正在生成,从当前这一刻续上实时流,缺席期间的历史由
session.history补齐,和官方断线重连是同一套语义。 - 对话区零替换:远端会话的历史、实时流、审批与提问卡片全部由官方组件渲染。没有自绘聊天窗,没有 shadow slot。
- 隧道不用你建:填
user@host与私钥路径,插件自己开 SSH 隧道、自己保活重连,本地端口它自己分配。 - 模型配置增量同步:服务器连上后,把本机有而远端缺的提供方、默认模型与 API Key 补过去。只补缺,不覆盖远端已有配置。
📥 把其他工具的历史接进来
本机 Codex CLI / Claude Code / opencode 聊过的对话,按项目目录归入对应工作区。
- 按软件逐个手动开启,未点「导入」的软件日志一个字节都不会被读;
- 找不到对应工作区的项目会自动建成真实工作区(只登记,不创建目录);目录已不存在的会话自动隐藏;
- 导入的会话在树里只读;直接向它发消息会自动转成真实 DSH 会话(保留用户/助手对话,原只读副本隐藏);
- 源日志只读打开,从不改写、从不删除。
两件事共用的底子
- 纯
/api协议:不靠 SSH 执行命令、不做屏幕抓取、不改远端配置,远端不装任何插件,就是一个未经修改的dsh web。 - 官方 UI 零改动:不替换侧边栏、不替换对话区。插件只在数据层做合并与路由。
只有一台机器? 第二件事照样成立:不填任何服务器,只开导入,把本机 Codex / Claude Code / opencode 的历史接进来即可。
安装
dsh plugin --profile web add dsh-session-hub
装完重启 dsh web(kill -TERM <pid> 并等待退出,别用 kill -9,会在写入中途撕裂会话 zstd 日志),刷新页面。
设置 → 插件 里出现 会话枢纽 标签页即成功。
dsh plugin 把参数原样转发给 profile 目录里的 pnpm(本机需要 pnpm)。不带版本标签装的是正式版 latest;
预发布版(如有)需显式 dsh-session-hub@alpha。
dsh plugin --profile web add https://github.com/Asaiuta/dsh-session-hub/archive/refs/tags/v0.1.0.tar.gz
仓库已提交构建产物,tarball 安装同样无需本地构建。
从源码安装(改代码调试)git clone https://github.com/Asaiuta/dsh-session-hub && cd dsh-session-hub
npm install && npm run build
dsh plugin --profile web add file:$(pwd)
或手动挂载:把 cordis.patch.yml 的 insert 条目并入 profile 的 patch 层。
升级:重跑 add 并重启 dsh web:
dsh plugin --profile web add dsh-session-hub
临时禁用:从 profile 的 bundle 列表 / cordis.patch.yml 移除 dsh-session-hub 条目后重启。
服务器注册表保留,重新启用即恢复。
彻底移除:移除条目并重启后,删掉这两个文件即可(都在 $DSH_HOME/plugins/):
dsh-session-hub.json(服务器注册表)、dsh-session-hub-imports.json(导入解析缓存)。
插件从不写入你的项目目录,也不改动任何工具的原始日志。
环境要求
| 项 | 要求 |
|---|---|
| 本机 DSH | 实测 @deepseek-ai/dsh@0.1.0-rc.6;mainline 未逐 commit 跟踪 |
| Node | ^22.19 || >=24(用到内置 WebSocket / fetch) |
| Profile | 仅 web:插件 inject webServer / apiProxy,装进 headless / tui 会一直 pending 并导致该 profile 启动失败 |
| 浏览器 | 官方 Web UI,无版本约束(插件不替换任何 UI) |
| 远端 DSH | 仅接远端时需要:任何能应答标准 /api 的 dsh web,远端无需安装本插件 |
| 外部工具 | 仅导入时需要:本机装过 Codex CLI / Claude Code / opencode / Pi 中的任意一个,读它们默认的日志位置 |
最后验证 2026-08-14: 远端侧,本机 Windows + Node v24.9.0 对远端 OpenCloudOS 9.4 + Node v24.9.0,经 SSH 隧道跑通跨机对话、审批应答与实时流全链路; 导入侧,本机解析 615 个会话(Codex 523 / Claude Code 67 / opencode 25),归入 21 个工作区,只读会话转真实会话已验证。
Alpha 期间:配置格式、路由表、
/hub/events帧协议在 1.0 前可能破坏性变更。
快速开始
装完插件后按你要的那件事往下走,两条路互不依赖:
- 只想把本机 Codex / Claude Code / opencode 的历史接进来,直接跳到导入本机其他工具的会话,三次点击就完事,不用配服务器、不用隧道。
- 想接远端机器,从下面这个最小示例开始。
接一台远端机器
最小可复现示例:一台远端 + 本机 hub + SSH 隧道。
① 远端(假设 10.0.0.5),什么都不用装,保持默认即可:
dsh web --port 3080 # 只监听环回,这也是当前唯一允许的绑定
② 本机,只装插件:
dsh plugin --profile web add https://github.com/Asaiuta/dsh-session-hub/archive/refs/tags/v0.1.0.tar.gz
# 重启 dsh web
隧道不用自己建,下一步填好 SSH 信息,插件会自己开、自己保活。
③ 浏览器 http://127.0.0.1:3080:
设置 → 插件 → 会话枢纽 → 添加服务器,保持默认的 SSH 隧道 方式,填:
字段 例 名称 tencent主机 10.0.0.5SSH 用户 root私钥路径 ~/.ssh/id_ed25519(留空则用 ssh agent)远端 dsh 端口 3080点测试(返回远端 DSH 版本即通)→ 添加。本地端口由插件自行分配,你不必知道它是多少。
官方工作区树里出现名为
tencent的分组,远端会话就在组内;点开任一会话,官方对话区照常工作:历史、实时流、审批卡片、发送 / 取消 / 重命名。
已经手动开着
ssh -L的话,切到直连地址填http://127.0.0.1:<你的端口>也可以,插件不会去碰那条隧道。
为什么一定要隧道:当前 dsh(0.1.0-rc.6)拒绝把 Web 服务绑到环回以外。
--host 0.0.0.0被 CLI 挡下 ("would expose remote code execution to the network"),具体 LAN IP 连配置校验都过不了(host只接受127.0.0.1与0.0.0.0两个字面量)。所以远端保持默认,由隧道把它带到本机环回;这也意味着不存在把 3080 暴露公网的选项,上游已经先一步堵死了。隧道进程活在 dsh 里:dsh 退出时一并关闭,启动时按保存的配置自动重建(端口每次重新分配,所以配置存的是 SSH 目标而不是 URL)。SSH 掉线会以退避重连,恢复后链接自动指向新端口。
导入本机其他工具的会话
不需要任何服务器或隧道。打开 设置 → 插件 → 会话枢纽 → 外部会话,按软件点「导入」:
| 软件 | 读取位置 |
|---|---|
| Codex CLI | ~/.codex/sessions/**/rollout-*.jsonl |
| Claude Code | ~/.claude/projects/**/*.jsonl |
| opencode | ~/.local/share/opencode/opencode.db |
| Pi Coding Agent | ~/.pi/agent/sessions/**/*.jsonl |
- 未点「导入」的软件,日志一个字节都不会被读;
- 勾选自动:60 秒周期扫描 + 导入时增量解析跟进新写入的日志,不勾选就只在你点刷新时更新;
- 导入的会话在树里只读,工具调用(参数、结果、错误标记)以真实工具卡片显示,文本与卡片按源日志顺序排列,卡片的 call id / 参数 / 输出均取自原始记录,不合成;
- 移除:把该软件的会话撤出树,不影响其他软件,也不动原始日志;
- 导入的会话在树里只读;直接向它发消息会自动转成真实 DSH 会话(保留用户/助手对话和工具调用,原只读副本隐藏)。
配置
四个功能开关默认全开,装完即用。只要其中一件时把另一件关掉:
# 只要导入,不要多服务器(单机用户的典型配置)
- id: dsh-session-hub
config:
features:
aggregate: false
tunnel: false
modelSync: false
# 只要多服务器,不碰本机其他工具的日志
- id: dsh-session-hub
config:
features:
importer: false
关闭等于不存在,不是闲置:关掉的功能不构造服务、不读缓存、不扫目录、不注册路由,也不在设置页出现。
四个全关时插件等于没装(不拦截任何 /api)。
| 配置项 | 类型 | 默认 | 说明 |
|---|---|---|---|
dataFile |
string? |
$DSH_HOME/plugins/dsh-session-hub.json |
服务器注册表持久化位置 |
trustedHosts |
string[]? |
仅环回 | 网关拦截的 /api 再校验白名单(裸 host[:port],格式同 client-connection.trustedHosts;SSH 隧道部署无需配置) |
features.aggregate |
boolean |
true |
多服务器聚合:关闭后不注册 /api 拦截、不建虚拟分组,服务器卡片从设置里消失 |
features.tunnel |
boolean |
true |
SSH 隧道托管:关闭后添加表单只剩直连地址,已保存的 SSH 条目会被跳过并在日志里说明 |
features.modelSync |
boolean |
true |
模型配置同步:关闭后不读本机凭据、不推送远端,同步卡片消失 |
features.importer |
boolean |
true |
外部会话导入:关闭后不读解析缓存、不扫描日志目录,导入卡片消失(缓存文件保留,重新开启即恢复) |
在 profile 的 cordis.yml / cordis.patch.yml 中配置:
- id: dsh-session-hub
config:
dataFile: /srv/dsh-hub-servers.json
trustedHosts: ['192.168.1.10:3080']
唯一相关的环境变量是 DSH_HOME(影响注册表默认路径,默认 ~/.dsh)。插件不需要任何 API key 或令牌环境变量。
运行期 /hub/events 用的随机 token 每进程生成一次,只经快照下发给浏览器,不落盘。
它访问什么
读你本机的会话日志(需你逐个授权)、和你配置的服务器通信、不碰你的项目文件。
下面每一项都只在对应功能开着时才发生:关掉导入就不读任何日志,关掉聚合就不发起任何出站连接。
完整清单(文件 / 网络 / 凭据 / 会话内容)文件访问:
- 读/写
$DSH_HOME/plugins/dsh-session-hub.json(0600,原子写:tmp + rename); - 读/写
$DSH_HOME/plugins/dsh-session-hub-imports.json(导入会话解析缓存,0600); - 只读扫描本机会话日志:
~/.codex/sessions/**/rollout-*.jsonl、~/.claude/projects/**/*.jsonl、~/.local/share/opencode/opencode.db(SQLite 只读打开)、~/.pi/agent/sessions/**/*.jsonl,用于生成只读的导入会话视图;绝不写回这些文件。按软件逐个手动导入:未在设置里点「导入」的软件,其日志不会被读取。
网络:
- 出站(对每个已配置服务器
baseUrl):HTTPPOST /api/*(unary RPC)+ WebSocket 升级/api/events.mux、/api/events.host; - 入站(本机进程内):
/hub/eventsSSE(仅环回 Host + 随机 token + 浏览器 same-origin 三重校验)、Typert/api/sessionHub/*(环回); - 入站(浏览器):被拦截的
/api/session.*与/api/respond会经网关再校验环回/trustedHosts。
SSH 密钥:SSH 隧道条目会按你填写的路径读取私钥(仅用于建立那条隧道),密钥内容不落盘、不外传、不写进插件配置,配置里只存路径。留空则走 ssh agent,插件完全不接触密钥材料。
凭据:模型同步功能会读取本机 $DSH_HOME/.credentials.yaml 明文(仅用于提取 llm-* 命名空间 apiKeyEnv 引用的密钥值),并在远端未配置该引用时经 credentials.set 写入远端(唯一的密钥出站方向,隧道/HTTPS 下加密)。密钥从不随响应回传,hub 也不落盘密钥副本。若不想自动推送密钥,可在服务器配置中不启用模型同步(或拆掉该服务器的 llm-* 命名空间)。
用户数据:远端会话列表、历史内容、实时流会经由 hub 进程与浏览器中转显示。跨机传输走 SSH 隧道(加密,也是目前唯一的连接方式);我方不明文落盘任何会话内容。
和其他插件共存
生态里已经有 1800+ 个 dsh 插件,其中不少也改前端。这个插件改的是数据层,不是画面层:
| 层 | 谁在改 | 冲突吗 |
|---|---|---|
| 会话/工作区数据 | 本插件(网关合并 session.list / workspace.list) |
与 UI 插件不冲突 |
| 侧边栏/对话区画面 | 各类 UI 插件(换树、换主题、加 Tab) | 本插件一个都不占 |
本插件在浏览器侧只做一件事:往 settings.plugins.tab 这个 list 槽加一个 id: 'session-hub' 的条目。
不 shadow sidebar.workspaces,不 shadow conversation,不碰任何单槽,所以换侧边栏、换对话区、换主题的插件都能与它并存。
换树的插件反而会自动显示远端与导入的会话:它们通过官方 useSessions / useWorkspaces 取数,
而这两个 hook 的上游正是本插件合并过的 /api。例如 dsh-plugin-ya-workspace-sidebar 完整替换了
sidebar.workspaces,本插件的服务器分组和导入会话照样出现在它画的树里,双方互不知情。
/api 的插件DSH 的 web server 对同一条 exact 路径只允许一个注册者(重复注册直接抛错,这是刻意的组合约定)。 本插件为实现会话合并,接管了这些路径:
/api/session.list /api/session.history /api/session.prompt /api/session.cancel
/api/session.rename /api/session.fork /api/session.models /api/session.selectModel
/api/session.updateQueue /api/session.attachment /api/session.search /api/session.create
/api/workspace.list /api/workspace.rename /api/workspace.delete /api/workspace.archiveSession
/api/respond
另一个插件若也拦截其中任意一条,本插件会让出已占的路由并降级,dsh 与对方插件照常运行:
[dsh-session-hub] gateway DISABLED — webserver: duplicate exact route "/api/respond".
Another plugin intercepts the same route, so remote servers and imported sessions will not appear.
Set features.aggregate and features.importer to false to silence this, or remove the conflicting plugin.
此时远端会话与导入会话不会出现(这两项功能依赖那些路由),但 DSH 本身、对方插件、以及本插件的设置页都正常。
把 features.aggregate 与 features.importer 设为 false 可消除这条告警。
这条路径实机验证过:用一个抢占
/api/respond的测试插件复现,dsh首页仍200、官方session.list正常应答、sessionHub端点存活,路由撞车不会拖垮宿主。
绝大多数插件不走这条路:加工具、加技能、加 UI、加 Tab 都不需要拦截 /api。
Typert 命名空间:本插件独占 sessionHub(对应 /api/sessionHub/*),SSE 独占 /hub/events。命名空间撞车的可能性极低。
和 dsh-remote 的分界
dsh-remote 同样做“远程”,但做的是另一件事。两者技术上不冲突(它占 /dsh-remote/* 与工作区选择器槽,我们占 /api/* 与设置页 Tab,实机同装验证过),可以一起装。
区别在于 agent 循环在谁家跑:
dsh-remote |
dsh-session-hub |
|
|---|---|---|
| 远端需要什么 | 只要 sshd |
一个跑着的 dsh web |
| 谁在干活 | 本机的 agent,通过 SSH 伸手过去 | 远端自己的 agent |
| 会话存在哪 | 都在本机 | 各机器自己的 ~/.dsh |
| 本机关机后 | 任务停了 | 远端继续跑 |
| 别人在远端发起的会话 | 看不到(远端没有会话) | 树里直接出现 |
选型规则:
远端能装 dsh → 本插件(远端自己有会话,你去聚合) 远端只有 sshd →
dsh-remote(本机伸手过去操作)
客户的生产机、不允许装东西的跳板机,适合用 dsh-remote。而想要“关机之后任务继续跑”,则必须让远端自己有一个 dsh。
常见问题
| 症状 | 原因 / 处理 |
|---|---|
添加服务器报 self-loop |
baseUrl 指向 hub 自身。插件启动时也会自动检测并跳过自环条目(日志 warn) |
历史加载失败 signal timed out |
最常见:SSH 隧道断了。检查 netstat -ano | grep :3333;重启隧道后远端自动重连 |
历史加载失败 invalid_value … expected "server-response" |
旧版本网关直通缺陷,升级到 0.1.0-alpha.1+(已修复:出口统一补 type: 'server-response') |
| 会话列表少了一项 | 冷启动后远端首个 session.list 拉取未完成;打开会话本身会触发重拉 |
| 实时流断开(LIVE 徽标变灰) | SSE 自动重连;发送后 900ms 无实时事件自动回退历史重载 |
| 本机关了一阵子,远端会话现在怎么看 | 直接点开就行。远端还在跑就从当前这一刻续上实时流,缺席那段由历史补齐;已跑完则直接看到完整结果 |
| 远端明明停了,UI 还在转圈 | 0.1.0-alpha.2 之前的缺陷:host/session-status 帧送错了入口被丢弃。升级即可;临时解法是刷新页面 |
| 插件未生效 | 检查 dsh plugin 后是否重启 web;看启动日志有无 dsh-session-hub 加载与 gateway 使能信息 |
| 点了「导入」但树里没有 | 该软件的会话所属项目目录已不存在,目录不在磁盘上的会话会被自动隐藏;恢复目录后一个扫描周期内回来 |
| 导入的会话发不出消息 | 正常:导入会话只读。直接发送会自动转成真实 DSH 会话,之后照常对话 |
| 某个软件显示「未安装」 | 只按默认路径查找(~/.codex / ~/.claude / ~/.local/share/opencode / ~/.pi);装在别处目前无法指定 |
| 移除某个软件后别的也少了 | 0.1.0-alpha.1 之前的缺陷(跨源误删),升级即可 |
日志位置:dsh web 进程 stdout/stderr(systemd 部署看 journalctl -u dsh-web,nohup 部署看输出文件,本地终端部署看控制台)。
回滚:dsh plugin --profile web add dsh-session-hub@<上一版本> 并重启即可。注册表文件向后兼容(未知字段忽略),降级不会丢配置。
各版本的破坏性变更与降级注意事项见 CHANGELOG。
开发
git clone https://github.com/Asaiuta/dsh-session-hub && cd dsh-session-hub
npm install # devDeps:esbuild / typescript / zod / react
npm run typecheck # tsc -p tsconfig.json --noEmit
npm run build # esbuild → lib/index.js + lib/client.js + lib/types/
- 类型检查走仓库自带
stubs/(按 harness 源码抄写的最小声明面,经 tsconfigpaths映射);对真实 DSH checkout 构建可删paths/stubs、把@deepseek-ai/*引回link:devDeps(参考 dsh-interconnect)。 @deepseek-ai/dsh-*未发布到 npm,运行时由 profile 提供(peerDeps 因此全部 optional)。- 测试:当前无自动化套件;冒烟路径(网关合并去重、SSE 三重鉴权、跨机实时对话、审批应答、self-loop 拒绝)为实机手动验证。欢迎贡献测试与 PR。
许可证与安全
- License:MIT。
- 安全边界见上方「它访问什么」;设计不变量:不中继特权域、审批一律人工应答(插件不做自动放行)、自环/未授权源一律拒绝。
- 私密报告:请通过 GitHub Issues 提交(标注
[security]),或直接联系维护者 @Asaiuta;修复前不会公开细节。
架构
┌──────────────────────── 本地 DSH 进程 ────────────────────────┐
│ host 插件 (src/index.ts → hub/) │
│ │
│ ServerRegistry ──持久化── $DSH_HOME/plugins/dsh-session-hub.json│
│ │ 每个 ServerLink ── RemoteApiClient(AbstractApiClient) │
│ │ · HTTP unary → 远端 /api/session.* │
│ │ · WS mux/host 双流 + 指数退避重连 │
│ │ · 会话列表缓存 / pending 交互表 (rpcId→服务器索引) │
│ ├── HubGateway(exact 路由优先于官方 /api prefix) │
│ │ 接管 session.list/history/prompt/cancel/rename/fork/ │
│ │ models/selectModel/updateQueue/attachment/search/respond│
│ │ + workspace.list/rename/delete/archiveSession │
│ │ 按会话归属三路分派:远端 → ServerLink、导入 → ImportStore│
│ │ 、本地 → 官方 ApiProxy │
│ │ session.list 合并去重(官方 + 远端 + 导入) │
│ ├── ImportStore(外部会话导入,按软件手动开启) │
│ │ 只读解析 codex/claude/opencode/pi 日志 → 规范会话模型 │
│ │ 文件监听 + mtime 增量扫描,缓存 dsh-session-hub-imports.json│
│ │ 按 cwd 最长前缀归入工作区;无对应工作区的项目自动登记 │
│ │ 首次发消息 → promote 成真实 DSH 会话(官方 create+replay)│
│ └── SessionHubRuntime (TypertRemoteService @Remote) │
│ 暴露 wire 命名空间 sessionHub(服务器管理/导入开关/同步)│
│ SSE /hub/events(随机 token + 环回 + same-origin 三重围栏) │
└──────────────┬────────────────────────────────────────────────┘
│ 官方 /api unary(浏览器→网关→路由)
│ SSE 远端 mux 帧(原样转发)
┌──────────────▼────────────────────────────────────────────────┐
│ browser:官方 UI(零替换 / 零 shadow) │
│ · 官方工作区树:/api/session.list 由网关合并 → 远端会话直接 │
│ 出现在官方树;点击打开 │
│ · 官方对话区:远端会话 open() 走 /api/session.history 网关路由,│
│ 实时 mux 帧由 client 桥 (startOfficialBridge) 注入官方 │
│ sessions.handleMuxEnvelope → 官方逐 token 流式渲染/审批卡 │
│ · 设置 → 插件 → 会话枢纽:服务器增删/状态/探活、按软件导入、 │
│ 模型同步 │
└────────────────────────────────────────────────────────────────┘
实时通道:每条远端链路的 mux/host WS 帧经 HubEventBus fan-out 到本地 SSE /hub/events;浏览器按 event.seq 与历史基线去重(打开会话先拉尾部历史,live 事件缓冲后按 seq > tailSeq 应用),assistant/chunk 增量折叠逐 token 气泡,审批/提问帧到达即上卡;SSE 断线自动重连。
导入会话的树变化(新增、移除、转正隐藏)由 hub 帧 watcher 以 host 帧经同一通道推给官方工作区运行时。
English
dsh-session-hub
Every session you have, in the same DSH tree.
Sessions from remote DSH servers and local history from Codex CLI, Claude Code, opencode and Pi, all in the official Web UI. The sidebar and conversation pane are the official ones — the plugin only moves data. The two features are independent; you can enable just one.
[!NOTE] Stable release
0.1.0(changelog). The core paths have been exercised on real machines — gateway routing, official-UI bridging, live frame injection, approval answering, cross-machine dialogue. Only one environment has been verified so far (Windows local + Tencent Cloud Linux over an SSH tunnel); the config format and security boundaries may still shift within a minor version.
What this is
DSH's session tree only knows sessions created on this machine by this tool. So:
A job half-finished on a server needs a new tab, a tunnel, and a hunt through logs. Close the laptop and come
back — no idea where it is now. A plan you discussed with Codex CLI last week does not exist in DSH at all;
you go dig through the JSONL files under ~/.codex.
This plugin brings both kinds of sessions into the same tree:
Workspaces
├── my-project ← local workspace (official, untouched)
├── another-repo ← local workspace
├── 🖥️ tencent ← remote server group; open it to see that machine's sessions
│ ├── deploy script debugging
│ └── log analysis
└── audio-engine ← local workspace with imported history mixed in
├── refactor discussion (Codex CLI)
└── fixing that CI run (Claude Code)
Open any session — remote included — and the official conversation pane renders it as usual: history, token-by-token streaming, approval cards, question forms. All official components. The plugin only moves data; it draws no UI.
Remote sessions are not proxied locally. The agent loop runs inside that machine's own dsh; your machine is
just a dashboard. Close the laptop and grab lunch; when you come back, if the session is still running the live
stream picks up from the current moment, and if it already finished you see the complete result.
Two independent features
The plugin does two things. They do not depend on each other — you can enable only one (see Configuration):
🖧 Bringing remote machines in
Each server becomes a group in the workspace tree (the title carries a 🖥️ marker so its remote origin is obvious at a glance), containing that machine's sessions.
- Tree operations are native semantics: + New session creates it on that machine, Archive / Delete route to the owning machine, rename the group renames the server, delete the group disconnects it.
- Sessions run remotely, not on your machine: the agent loop advances inside that machine's own
dsh. Turning off your machine, losing network, or switching computers does not stop it. When you come back it may already be done. - Joining mid-run: if the remote is generating when the hub starts, the live stream resumes from the current
moment and the missed history is backfilled via
session.history— the same semantics as an official reconnect. - Zero replacement in the conversation pane: history, live stream, approval and question cards for remote sessions are all rendered by official components. No self-drawn chat window, no shadow slots.
- No tunnel setup on your side: fill in
user@hostand a private key path, and the plugin opens the SSH tunnel itself, keeps it alive and reconnects it, allocating the local port on its own. - Incremental model config sync: when a server connects, providers, default models and API keys present locally but missing remotely are pushed over. Only missing pieces; remote config is never overwritten.
📥 Bringing other tools' history in
Conversations from local Codex CLI / Claude Code / opencode / Pi land in their project workspace by directory.
- Enabled per tool, manually. Tools you have not clicked Import for are never read — not a byte.
- Projects with no matching workspace are auto-registered as real workspaces (registration only, no directories are created); sessions whose project directory no longer exists are hidden automatically.
- Imported sessions are read-only in the tree; sending a message to one automatically promotes it to a real DSH session (user/assistant turns and tool calls are preserved; the read-only original is hidden).
- Source logs are opened read-only — never rewritten, never deleted.
Shared foundations
- Pure
/apiprotocol: no SSH command execution, no screen scraping, no remote config changes. The remote needs no plugin at all — it is an unmodifieddsh web. - Official UI untouched: no sidebar replacement, no conversation-pane replacement. The plugin merges and routes at the data layer only.
One machine only? The second feature still applies: add no servers, enable just the importer, and pull in your local Codex / Claude Code / opencode / Pi history.
Installation
dsh plugin --profile web add dsh-session-hub
Restart dsh web afterwards (kill -TERM <pid> and wait for exit — never kill -9, it can tear a session's
zstd log mid-write), then refresh the page. Success means a Session Hub tab under Settings → Plugins.
dsh plugin forwards its arguments verbatim to the pnpm inside the profile directory (pnpm is required on
this machine). Installing without a tag gets the stable latest; prereleases (if any) need an explicit
dsh-session-hub@alpha.
dsh plugin --profile web add https://github.com/Asaiuta/dsh-session-hub/archive/refs/tags/v0.1.0.tar.gz
Build artifacts are committed, so tarball installs do not need a local build.
From source (hacking / debugging)git clone https://github.com/Asaiuta/dsh-session-hub && cd dsh-session-hub
npm install && npm run build
dsh plugin --profile web add file:$(pwd)
Or mount manually: merge the insert entry of cordis.patch.yml into the profile's patch layer.
Upgrade: re-run add and restart dsh web:
dsh plugin --profile web add dsh-session-hub
Disable temporarily: remove the dsh-session-hub entry from the profile's bundle list /
cordis.patch.yml and restart. The server registry survives; re-enabling restores it.
Remove completely: remove the entry, restart, then delete these two files (both under $DSH_HOME/plugins/):
dsh-session-hub.json (server registry) and dsh-session-hub-imports.json (import parse cache).
The plugin never writes into your project directories and never modifies any tool's original logs.
Requirements
| Item | Requirement |
|---|---|
| Local DSH | tested against @deepseek-ai/dsh@0.1.0-rc.6; mainline is not tracked commit-by-commit |
| Node | ^22.19 || >=24 (uses built-in WebSocket / fetch) |
| Profile | web only: the plugin injects webServer / apiProxy; installed into headless / tui it stays pending and fails that profile's startup |
| Browser | official Web UI, no version constraint (the plugin replaces no UI) |
| External tools | only needed for importing: any of Codex CLI / Claude Code / opencode / Pi installed locally, read at their default log locations |
Last verified 2026-08-14: remote side — local Windows + Node v24.9.0 against remote OpenCloudOS 9.4 + Node v24.9.0 over an SSH tunnel: cross-machine dialogue, approval answering and the live stream end to end; import side — 615 local sessions parsed (Codex 523 / Claude Code 67 / opencode 25) into 21 workspaces, read-only → real-session promotion verified.
The config format, route table and
/hub/eventsframe protocol may still change within a minor version.
Quick start
Pick the feature you want — the two paths are independent:
- Just importing local Codex / Claude Code / opencode / Pi history: jump to importing sessions from other tools, three clicks, no servers, no tunnels.
- Connecting a remote machine: start from the minimal example below.
Connecting one remote machine
Minimal reproducible example: one remote + local hub + SSH tunnel.
① Remote (say 10.0.0.5): nothing to install; keep the defaults:
dsh web --port 3080 # listens on loopback only — the only binding currently allowed
② Local: install only the plugin:
dsh plugin --profile web add https://github.com/Asaiuta/dsh-session-hub/archive/refs/tags/v0.1.0.tar.gz
# restart dsh web
No tunnel needed by hand — fill in the SSH details next and the plugin opens and keeps it alive itself.
③ Browser http://127.0.0.1:3080:
Settings → Plugins → Session Hub → Add server, keep the default SSH tunnel mode, and fill in:
Field Example Name tencentHost 10.0.0.5SSH user rootPrivate key path ~/.ssh/id_ed25519(leave empty to use the ssh agent)Remote dsh port 3080Click Test (it returns the remote DSH version if reachable) → Add. The plugin assigns the local port itself; you never need to know it.
A group named
tencentappears in the official workspace tree, with the remote sessions inside it;Open any session — the official conversation pane just works: history, live stream, approval cards, send / cancel / rename.
If you already run
ssh -Lby hand, switch to Direct address and fill inhttp://127.0.0.1:<your port>; the plugin will not touch that tunnel.
Why a tunnel is mandatory: current dsh (0.1.0-rc.6) refuses to bind the web server beyond loopback.
--host 0.0.0.0is blocked by the CLI ("would expose remote code execution to the network"), and a concrete LAN IP fails config validation (hostaccepts only the two literals127.0.0.1and0.0.0.0). So the remote stays on defaults and the tunnel brings it to the local loopback — which also means there is no option to expose :3080 publicly; upstream has already closed that door.The tunnel process lives inside dsh: it closes when dsh exits, and rebuilds on startup from the saved config (the port is reallocated each time, so the config stores the SSH target, not a URL). On SSH drop it reconnects with backoff, and the link follows the new port.
Importing sessions from other tools
No servers or tunnels needed. Open Settings → Plugins → Session Hub → External sessions and click Import per tool:
| Tool | Read location |
|---|---|
| Codex CLI | ~/.codex/sessions/**/rollout-*.jsonl |
| Claude Code | ~/.claude/projects/**/*.jsonl |
| opencode | ~/.local/share/opencode/opencode.db |
| Pi Coding Agent | ~/.pi/agent/sessions/**/*.jsonl |
- Tools you have not clicked Import for are never read — not a byte;
- Tick Auto: a 60-second periodic scan plus incremental re-parsing on import picks up newly written log rows; unticked, updates only when you click refresh;
- Imported sessions are read-only in the tree, with tool calls rendered as real cards (arguments, results, error flags) in source-log order. Call ids, arguments and outputs all come from the original records — nothing is synthesized;
- Remove: pulls that tool's sessions out of the tree, no effect on other tools, original logs untouched;
- Imported sessions are read-only; sending a message to one automatically promotes it to a real DSH session (user/assistant turns and tool calls preserved, the read-only original hidden).
Configuration
Four feature switches, all on by default; turn off what you do not use:
# imports only, no multi-server (typical single-machine setup)
- id: dsh-session-hub
config:
features:
aggregate: false
tunnel: false
modelSync: false
# multi-server only, never touch local tool logs
- id: dsh-session-hub
config:
features:
importer: false
Disabled means absent, not idle: a disabled feature builds no services, reads no cache, scans no directories,
registers no routes, and does not appear in the settings page. With all four off the plugin is equivalent to
not being installed (it intercepts no /api).
| Key | Type | Default | Meaning |
|---|---|---|---|
dataFile |
string? |
$DSH_HOME/plugins/dsh-session-hub.json |
server registry persistence location |
trustedHosts |
string[]? |
loopback only | extra allowlist for the intercepted /api (bare host[:port], same format as client-connection.trustedHosts; not needed for SSH-tunnel deployments) |
features.aggregate |
boolean |
true |
multi-server aggregation: off → no /api interception, no virtual groups, server cards disappear from settings |
features.tunnel |
boolean |
true |
SSH tunnel hosting: off → the add form only offers direct addresses; saved SSH entries are skipped with a log line |
features.modelSync |
boolean |
true |
model config sync: off → local credentials are not read, nothing is pushed; the sync card disappears |
features.importer |
boolean |
true |
external session import: off → no parse cache reads, no log directory scans; the import card disappears (cache file is kept; re-enabling restores it) |
Configure in the profile's cordis.yml / cordis.patch.yml:
- id: dsh-session-hub
config:
dataFile: /srv/dsh-hub-servers.json
trustedHosts: ['192.168.1.10:3080']
The only relevant environment variable is DSH_HOME (affects the registry default path, ~/.dsh by
default). The plugin needs no API-key or token environment variables. The runtime /hub/events random token
is generated once per process and delivered to the browser only through the snapshot; it never touches disk.
What it accesses
It reads your local session logs (per tool, as you authorize), talks to the servers you configure, and never touches your project files.
Every item below happens only while its feature is enabled: with the importer off no logs are read; with aggregation off no outbound connections are made.
Full list (files / network / credentials / session content)File access:
- Reads/writes
$DSH_HOME/plugins/dsh-session-hub.json(0600, atomic: tmp + rename); - Reads/writes
$DSH_HOME/plugins/dsh-session-hub-imports.json(imported-session parse cache,0600); - Read-only scans of local session logs:
~/.codex/sessions/**/rollout-*.jsonl,~/.claude/projects/**/*.jsonl,~/.local/share/opencode/opencode.db(SQLite opened read-only),~/.pi/agent/sessions/**/*.jsonl, to build read-only imported session views; never written back. Enabled per tool, manually: a tool you have not clicked Import for is never read.
Network:
- Outbound (per configured server
baseUrl): HTTPPOST /api/*(unary RPC) + WebSocket upgrade/api/events.mux,/api/events.host; - Inbound (local, in-process):
/hub/eventsSSE (loopback host + random token + browser same-origin, a three-way fence), Typert/api/sessionHub/*(loopback); - Inbound (browser): intercepted
/api/session.*and/api/respondare re-checked by the gateway against loopback /trustedHosts.
SSH keys: tunnel entries read the private key at the path you provide (only to establish that tunnel); key material is never persisted, never sent anywhere, never written into the plugin config — the config stores the path only. Left empty, the ssh agent is used and the plugin never touches key material.
Credentials: model sync reads the local $DSH_HOME/.credentials.yaml plaintext (only to resolve secret
values referenced via apiKeyEnv in the llm-* namespaces) and writes them via credentials.set to the
remote only when that reference is not yet configured there (the only outbound direction for secrets;
encrypted under tunnel/HTTPS). Secrets never ride response traffic back, and the hub keeps no copy of them.
To avoid any automatic secret push, turn off model sync for the server (or drop its llm-* namespaces).
User data: remote session lists, history content and live streams transit the hub process to the browser. Cross-machine transport runs over the SSH tunnel (encrypted — currently the only connection mode); the plugin never writes session content to disk in cleartext.
Coexistence with other plugins
The ecosystem already has 1800+ dsh plugins and many touch the frontend. This one changes the data layer, not the presentation layer:
| Layer | Who changes it | Conflict? |
|---|---|---|
| Session/workspace data | this plugin (gateway merging session.list / workspace.list) |
no conflict with UI plugins |
| Sidebar/conversation presentation | assorted UI plugins (trees, themes, tabs) | this plugin occupies none |
On the browser side the plugin does exactly one thing: adds a id: 'session-hub' entry to the
settings.plugins.tab list slot. It shadows no sidebar.workspaces, no conversation, no single slot —
so sidebar, conversation and theme plugins all coexist with it.
Tree-replacing plugins actually show remote and imported sessions for free: they read through the official
useSessions / useWorkspaces hooks, whose upstream is precisely this plugin's merged /api. For example,
dsh-plugin-ya-workspace-sidebar fully replaces sidebar.workspaces, and this plugin's server groups and
imported sessions still appear in the tree it draws — neither knows about the other.
/apiDSH's web server allows exactly one registrant per exact route (duplicate registration throws on purpose — a deliberate composition rule). To merge sessions this plugin takes over these routes:
/api/session.list /api/session.history /api/session.prompt /api/session.cancel
/api/session.rename /api/session.fork /api/session.models /api/session.selectModel
/api/session.updateQueue /api/session.attachment /api/session.search /api/session.create
/api/workspace.list /api/workspace.rename /api/workspace.delete /api/workspace.archiveSession
/api/respond
If another plugin intercepts any of them, this plugin yields the occupied routes and degrades; dsh and the
other plugin keep working:
[dsh-session-hub] gateway DISABLED — webserver: duplicate exact route "/api/respond".
Another plugin intercepts the same route, so remote servers and imported sessions will not appear.
Set features.aggregate and features.importer to false to silence this, or remove the conflicting plugin.
Remote sessions and imported sessions then do not appear (both features depend on those routes), but DSH
itself, the other plugin, and this plugin's settings page all stay healthy. Set features.aggregate and
features.importer to false to silence the warning.
This path was exercised on a real machine: with a test plugin hogging
/api/respond, thedshhomepage still returned200, the officialsession.listanswered normally, thesessionHubendpoint stayed alive — a route collision cannot take the host down.
The vast majority of plugins never go this route: tools, skills, UI, tabs do not need /api interception.
Typert namespace: this plugin owns sessionHub (= /api/sessionHub/*) exclusively; SSE owns
/hub/events. A namespace collision is extremely unlikely.
Boundary with dsh-remote
dsh-remote also does "remote", but a different thing. The two are
technically non-conflicting (it occupies /dsh-remote/* and the workspace selector slot; we occupy /api/*
and a settings tab — verified side-by-side on a real machine), and can be installed together.
The difference is whose machine runs the agent loop:
dsh-remote |
dsh-session-hub |
|
|---|---|---|
| What the remote needs | just sshd |
a running dsh web |
| Who does the work | your agent, reaching over SSH | the remote's own agent |
| Where sessions live | all on your machine | in each machine's own ~/.dsh |
| Your machine off | work stops | the remote keeps going |
| Sessions started by others remotely | not visible (the remote has no sessions) | appear directly in the tree |
Decision rule:
Remote can run dsh → this plugin (the remote owns its sessions; you aggregate them) Remote has only sshd →
dsh-remote(reach over from your machine)
For production boxes you cannot install on, or jump hosts, dsh-remote fits. To have work continue after you
shut down, the remote must run its own dsh.
FAQ
| Symptom | Cause / fix |
|---|---|
Add server fails with self-loop |
baseUrl points at the hub itself. The plugin also auto-detects and skips self-loop entries at startup (log warn) |
History load fails signal timed out |
most common: the SSH tunnel dropped. Check netstat -ano | grep :3333; the remote reconnects after the tunnel restarts |
History load fails invalid_value … expected "server-response" |
a gateway passthrough bug in old versions; upgrade to 0.1.0-alpha.1+ (fixed: the exit uniformly stamps type: 'server-response') |
| A session is missing from the list | on cold start the remote's first session.list pull may not have finished; opening the session itself triggers a re-pull |
| Live stream dropped (LIVE badge went grey) | SSE auto-reconnects; if no live event arrives within 900ms after sending, it falls back to a history reload |
| How do I see a remote session after my machine was off for a while | just open it. Still running: the live stream resumes from the current moment, the gap is backfilled from history. Already done: you see the full result |
| Remote clearly stopped, UI still spins | defect before 0.1.0-alpha.2: the host/session-status frame was fed to the wrong entry and dropped. Upgrade; temp workaround is a page refresh |
| Plugin not effective | check whether dsh web was restarted after dsh plugin; look for dsh-session-hub load and gateway-enabled lines in the startup log |
| Clicked Import but nothing in the tree | sessions of that tool whose project directory no longer exists on disk are hidden automatically; restore the directory and they return within one scan cycle |
| Imported session cannot send messages | expected: imported sessions are read-only. Sending directly promotes to a real DSH session, then it converses normally |
| A tool shows "not installed" | only default paths are probed (~/.codex / ~/.claude / ~/.local/share/opencode / ~/.pi); custom locations cannot be specified yet |
| Removing one tool took others with it | defect before 0.1.0-alpha.1 (cross-source deletion); upgrade |
Logs: dsh web process stdout/stderr (systemd: journalctl -u dsh-web; nohup: the output file;
local terminal: the console).
Rollback: dsh plugin --profile web add dsh-session-hub@<previous version> and restart. The registry
file is backwards compatible (unknown fields are ignored); downgrading loses no config. Breaking changes and
downgrade notes per version are in CHANGELOG.
Development
git clone https://github.com/Asaiuta/dsh-session-hub && cd dsh-session-hub
npm install # devDeps: esbuild / typescript / zod / react
npm run typecheck # tsc -p tsconfig.json --noEmit
npm run build # esbuild → lib/index.js + lib/client.js + lib/types/
- Type checking runs against the repo's own
stubs/(a minimal declaration surface transcribed from the harness sources, mapped via tsconfigpaths); building against a real DSH checkout means droppingpaths/stubsand pointing@deepseek-ai/*back tolink:devDeps (see dsh-interconnect). @deepseek-ai/dsh-*are not published to npm; the runtime profile provides them, which is why every peerDep is optional.- Tests: no automated suite yet; the smoke paths (gateway merge dedupe, SSE three-way auth, real-time cross-machine dialogue, approval answering, self-loop rejection) are verified manually on real machines. Test and PR contributions welcome.
Architecture
┌──────────────────────── local DSH process ────────────────────────┐
│ host plugin (src/index.ts → hub/) │
│ │
│ ServerRegistry ──persists── $DSH_HOME/plugins/dsh-session-hub.json │
│ │ one ServerLink per remote ── RemoteApiClient(AbstractApiClient)│
│ │ · HTTP unary → remote /api/session.* │
│ │ · WS mux/host dual stream + exponential-backoff reconnect │
│ │ · session list cache / pending interaction table (rpcId→server)│
│ ├── HubGateway(exact routes take priority over official /api) │
│ │ takes over session.list/history/prompt/cancel/rename/fork/ │
│ │ models/selectModel/updateQueue/attachment/search/respond │
│ │ + workspace.list/rename/delete/archiveSession │
│ │ three-way dispatch by session ownership: remote → ServerLink,│
│ │ imported → ImportStore, local → official ApiProxy │
│ │ session.list merged & deduped (official + remote + imported) │
│ ├── ImportStore(external session import, enabled per tool) │
│ │ read-only parse of codex/claude/opencode/pi logs → canon │
│ │ mtime incremental scan, cache dsh-session-hub-imports.json │
│ │ grouped into workspaces by longest cwd prefix; projects │
│ │ without a workspace auto-register; first prompt → promote │
│ │ to a real DSH session (official create+replay) │
│ └── SessionHubRuntime (TypertRemoteService @Remote) │
│ exposes the sessionHub wire namespace (servers/import/sync) │
│ SSE /hub/events (random token + loopback + same-origin three-way) │
└──────────────┬─────────────────────────────────────────────────────┘
│ official /api unary (browser → gateway → route)
│ SSE remote mux frames (forwarded verbatim)
┌──────────────▼─────────────────────────────────────────────────────┐
│ browser: official UI (zero replacement / zero shadow) │
│ · official workspace tree: /api/session.list merged by the gateway │
│ → remote sessions appear directly; click to open │
│ · official conversation pane: remote session open() routes via │
│ /api/session.history; live mux frames are injected into the │
│ official sessions.handleMuxEnvelope by the client bridge │
│ (startOfficialBridge) → official token-by-token stream/approval │
│ · Settings → Plugins → Session Hub: servers add/remove/status, │
│ per-tool import, model sync │
└─────────────────────────────────────────────────────────────────────┘
Live channel: every remote link's mux/host WS frames fan out through HubEventBus to the local SSE
/hub/events; the browser dedupes by event.seq against the history baseline (opening a session pulls the
tail history first; live events buffer and apply when seq > tailSeq), assistant/chunk deltas fold into
per-token bubbles, approval/question frames surface as cards on arrival; SSE auto-reconnects on drop.
License & security
- License: MIT.
- Security boundary: see "What it accesses" above. Design invariants: no privileged-domain relay, approvals are always answered by a human (the plugin never auto-approves), and self-loop / unauthorized sources are always rejected.
- Private reports: please file via GitHub Issues
(prefix
[security]), or contact the maintainer @Asaiuta; details stay private until fixed.
No comments yet. Be the first to write one.