dsh-external-bridge
jason25417/dsh-external-bridge
Host-side HTTP bridge on 127.0.0.1:39217 with a Bearer token stored at 0600: external processes can create real DSH Desktop sessions that appear in the workspace sidebar, send prompts (queue or steer), read state and the last assistant reply, list workspaces and sessions, and long-poll until a turn finishes. Zero runtime dependencies; fixed loopback port; no outbound network; message bodies are never logged.
インストール
dsh plugin --profile web add github:jason25417/dsh-external-bridgeREADME
dsh-external-bridge
在本机 127.0.0.1:39217 开一个 HTTP 口子,让外部进程(终端里的 Claude Code 等)指挥桌面版 DSH 主进程干活:建会话(可选 agent preset / 模型)、发消息、查状态、长轮询等回复。
建出来的会话走宿主自己的 sessionController.create,是 GUI 里的真会话:会出现在对应工作区的侧栏,用户点进去能看到消息与回复实时滚动,与手动新建没有区别。dsh headless 建的会话只落盘 ~/.dsh/sessions/、不进 workspace.json 注册表,GUI 里看不到——那正是本插件要补的缺口。
不改 DSH 本体、不改 @yunmiao/studymate。 只新增目录 ~/.dsh/profiles/desktop/plugins/dsh-external-bridge/。
为什么不用宿主自带的 HTTP 口
桌面宿主的 Web 口(如 127.0.0.1:50186、*:3081)对没有凭据的请求一律回 401 unauthorized,外部进程拿不到 GUI 客户端的凭据。所以本插件不挂 connection.fetch,自己用 node:http 起一个只绑 loopback 的监听,配一枚随机 Bearer token(0600 落盘),与 GUI 的鉴权彻底分开。
- 只绑
127.0.0.1,端口固定39217;被占就启动失败并在日志里说清楚,不静默换端口。 - 启动时生成随机 token(32 字节,base64url),写到
~/.dsh/secrets/external-bridge.token(目录 0700、文件 0600)。 - 每个请求都必须带
Authorization: Bearer <token>,不带或不对回401。 - 插件 dispose 时关闭监听。
接口
所有响应都是 JSON。失败一律是:
{ "ok": false, "error": { "code": "machine-readable-code", "message": "人读的一句话" } }
| 路由 | 作用 |
|---|---|
GET /health | { ok, version, presets, models };拿不到的字段回 null,不编 |
POST /sessions | 建会话,回 { sessionId, agentPreset?, workspaceId?, model?, firstPrompt?, warning? } |
POST /sessions/:id/prompt | 发消息,回接收回执(不是回复) |
GET /sessions/:id | { state, lastAssistantText, turnCount, error? } |
GET /sessions/:id/wait?timeoutMs= | 长轮询:空闲/出错立即返回(内容同上);超时回 { state: "running", timedOut: true } |
GET /sessions | 列工作区与会话 id |
GET /health
{
"ok": true,
"version": "0.2.0",
"presets": ["standard", "learning", "qa"], // 可用(非 broken)的 preset id;服务缺失时 null
"models": ["opencode-go-4/deepseek-v4-pro", "…"] // provider/model 摊平;目录拿不到时 null
}
POST /sessions
// 入参(cwd 与 workspaceId 二选一,必须给一个)
{
"workspaceId": "ws_xxx", // 二选一:已注册工作区 id(推荐,保证侧栏可见)
// "cwd": "/abs/path", // 二选一:目录;命中已注册工作区时内部改走 workspaceId
"agentPreset": "studymate", // 可选;studymate 是 learning 的显式别名(见下)
"provider": "opencode-go-4", // 可选,与 model 同时给
"model": "deepseek-v4-pro",
"reasoningEffort": "max", // 可选,只在给了 provider+model 时有效
"firstPrompt": "只回复两个字:收到" // 可选,建完即投递(queue 模式)
}
// 成功
{ "ok": true, "sessionId": "session-…", "agentPreset": "learning", "workspaceId": "ws_xxx",
"model": { "provider": "opencode-go-4", "model": "deepseek-v4-pro", "reasoningEffort": "max" },
"firstPrompt": { "accepted": true } }
cwd不属于任何已注册工作区时仍会建会话,但回包里带warning:GUI 侧栏可能不显示它(会话确实存在)。要进侧栏,先给它建工作区,或改用workspaceId。- 指定模型失败(如模型不可用)时回错误,但带
sessionId,调用方可以接上继续用或自行处理。
POST /sessions/:id/prompt
// 入参
{ "message": "继续", "mode": "queue" } // mode: queue(默认)| steer
// 成功
{ "ok": true, "sessionId": "…", "requestId": "bridge-prompt-…", "mode": "queue", "receipt": { "accepted": true } }
GET /sessions/:id 与 GET /sessions/:id/wait
{
"ok": true,
"sessionId": "…",
"state": "running", // running | idle | error
"lastAssistantText": "…", // 最后一条**带正文**的助手回复;工具调用行不算
"turnCount": 3, // 已完成的回合数
"error": { "code": "RATE_LIMIT", "message": "上游限流" } // 仅当最后一轮以 error 结束时
}
wait默认等 30 秒,timeoutMs上限 240 秒(压在 Node 默认requestTimeout300s 内)。- 客户端断开时,桥会中止它这边的等待,不把宿主读操作挂住。
GET /sessions
{ "ok": true, "workspaces": [ { "id": "ws_xxx", "title": "默认工作区", "path": "/Users/…", "sessionIds": ["session-…"] } ] }
错误码
code | HTTP | 含义 |
|---|---|---|
unauthorized | 401 | 缺 token / token 不对 |
body-invalid / bad-request | 400 | 请求体不是 JSON 对象 / 参数不合法(消息里写清哪一项) |
body-too-large | 413 | 请求体超过 1 MiB |
not-found | 404 | 路由不存在 |
method-not-allowed | 405 | 路径存在但方法不对 |
session/not-found / workspace/not-found | 404 | 会话/工作区不存在 |
agent-preset/not-found | 400 | 预设不存在;message 里列可用 id |
session/model-unavailable | 400 | 模型不可用(同时带 sessionId) |
service-unavailable | 503 | 宿主服务缺失/未就绪(哪条服务、为什么,写在 message 里) |
token-unavailable | 503 | token 文件写不了;监听仍在,但所有请求都回这个 |
port-unavailable | — | 启动期错误(只在宿主日志里):端口被占 |
internal | 500 | 其它意外;message 带原始错误 |
两个问题的结论(查过代码,不是猜的)
1. 怎么给会话指定模型
sessionController.create()不接受任何模型字段(dsh-api-session-controller/lib/index.js的create(request):只认sessionId / workspaceId / cwd / agentPreset)。所以「建会话时顺带指定模型」在宿主接口里不存在。- GUI 的模型菜单走的是另一个 RPC:
sessionController.selectModel({ sessionId, provider, model, reasoningEffort? })(客户端@deepseek-ai/dsh-client-ui-model-selection/lib/client.js调this.sessions.selectModel(...))。 本桥的顺序:create→selectModel。 - 注意:
selectModel内部会顺手把这次选择存成全局默认模型(SessionCommandController.selectModel→agentDefaultModel.saveSelection)。外部调用不应该悄悄改掉用户 GUI 的默认模型,所以桥在成功后 把原来的默认值写回(agentDefaultModel.currentSelection()/saveSelection())。saveSelection的写入是排队提交的,因此「先存新值、后存旧值」的顺序可靠。 - 可用模型清单:
sessionController.modelCatalog()→{ groups: [{ id, name, models: [{ id, name, reasoning }] }], default, failures }。/health.models就是它摊平成provider/model。 - 想指定「DeepSeek 账号 · V4-Pro · Max」:
provider用opencode-go-4之类(以/health的清单为准),model: "deepseek-v4-pro",reasoningEffort: "max"。
2. 怎么读会话状态与最后一条助手消息
宿主里能给出这些的是 sessionController(RPC 门面)+ sessionQuery(底层被动读):
- 进程内状态:
sessionController.state({ sessionId })→{ cursor, active, running, queue, pendingInput }。running就是/sessions/:id的 running 依据。 - 长轮询:
sessionController.wait({ sessionId, timeoutMs, afterSeq, maxMessages })→{ reason: "idle" | "input-required" | "timeout", state, output: { records, truncated } }。output.records是 newest-first 的assistant/message事件(每条是{ type: "event", event }), 正文在event.data.message.content[]里type === "text"的块。 超时不是抛错在上层,而是reason: "timeout"/RemoteError code session/wait-timeout(冻结在代码里的时间点不同,两种都处理了)。 - 完整事件表(冷/热都读、不激活会话):
sessionQuery.observeSession(sessionId, { projectionMode: "none" })→ observation,含events/cursor/[Symbol.dispose]()。 兜底:sessions.get(sessionId).snapshotEvents()(只对活会话有效)。本桥先走前者,后者兜底。 - 回合数与错误:扫事件表——
turn/end计数即已完成回合数; 最后一轮turn/end.data.reason.kind === "error"时,reason.error里有{ code, message },桥把它填进error。aborted(用户中断)不算 error。
为什么不直接用
@deepseek-ai/dsh-session的 Session 对象硬扫:sessionId可能是冷会话(只在磁盘上),sessionQuery.observeSession才是冷热通吃的读口;而且它不会把会话「激活」成活的 Agent。
挂载(由你操作;我没有改 profile、没有重启宿主)
已打包为 bundle(
package.json的dsh.bundle)后,推荐直接安装,不用手工改 profile:dsh plugin --profile desktop add dsh-external-bridge(npm 包 / tarball / 本地路径均可),然后完全退出并重开 DSH Desktop。 下面这段手工挂载保留给源码 checkout 场景。
- 在
~/.dsh/profiles/desktop/cordis.patch.yml末尾追加:
# --- 2026-11-05: 外部桥(本机 HTTP,供终端里的 Claude Code 指挥桌面 DSH)---
# 只挂 file:// 的 Host 半;没有 client 半,不需要软链进 node_modules。
# 回滚:删掉本段,完全退出并重开 DSH Desktop。
- insert:
- id: external-bridge
name: file:///path/to/dsh-external-bridge/index.js
-
完全退出并重开 DSH Desktop(Host 插件只在启动时挂载)。
-
验证:
test -f ~/.dsh/secrets/external-bridge.token && ls -l ~/.dsh/secrets/external-bridge.token
TOKEN=$(cat ~/.dsh/secrets/external-bridge.token)
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:39217/health | jq
看到 {"ok":true,"version":"0.2.0","presets":[…],"models":[…]} 就是起来了。
- 排错:宿主日志里搜
[dsh-external-bridge]或external bridge。无法监听 127.0.0.1:39217→ 端口被别的进程占了(lsof -nP -iTCP:39217 -sTCP:LISTEN)。token 文件写不了→~/.dsh/secrets/权限/磁盘问题;此时接口全回 503token-unavailable。
回滚
# 1) 删掉 cordis.patch.yml 里 `# --- 2026-11-05: 外部桥` 起的那 6 行
# 2) 完全退出并重开 DSH Desktop
# 3) (可选)删掉 token
rm -f ~/.dsh/secrets/external-bridge.token
# 4) (可选)删掉插件目录
rm -rf ~/.dsh/profiles/desktop/plugins/dsh-external-bridge
改 patch 前建议先备份:cp ~/.dsh/profiles/desktop/cordis.patch.yml ~/.dsh/profiles/desktop/cordis.patch.yml.bak-before-external-bridge-$(date +%Y%m%d-%H%M)。
curl 冒烟流程(挂载后)
TOKEN=$(cat ~/.dsh/secrets/external-bridge.token)
BASE=http://127.0.0.1:39217
AUTH="Authorization: Bearer $TOKEN"
# 0) 健康检查
curl -s -H "$AUTH" $BASE/health | jq
# 1) 拿一个工作区 id(没建过会话的工作区也可以,只要已注册)
WS=$(curl -s -H "$AUTH" $BASE/sessions | jq -r '.workspaces[0].id')
echo "workspace: $WS"
# 2) 建一个学习模式会话,并投递第一句
SESSION=$(curl -s -X POST -H "$AUTH" -H 'content-type: application/json' \
-d "{\"workspaceId\":\"$WS\",\"agentPreset\":\"studymate\",\"firstPrompt\":\"只回复两个字:收到\"}" \
$BASE/sessions | tee /tmp/bridge-create.json | jq -r '.sessionId')
cat /tmp/bridge-create.json | jq
echo "session: $SESSION"
# 3) 等它跑完(默认 30s,最长 240s)
curl -s -H "$AUTH" "$BASE/sessions/$SESSION/wait?timeoutMs=120000" | jq
# 4) 再读一次状态与最后回复
curl -s -H "$AUTH" "$BASE/sessions/$SESSION" | jq '{state,lastAssistantText,turnCount}'
期望:第 2 步回 agentPreset: "learning";第 3 步 state: "idle" 且 lastAssistantText 是「收到」;
同时 GUI 侧栏对应工作区里出现这条新会话,点进去能看到消息与回复。
测试
cd ~/.dsh/profiles/desktop/plugins/dsh-external-bridge
node --test tests/*.test.mjs # 或 npm test
35 项全绿(鉴权 / 路由 / 参数校验 / 错误信封 / 建会话别名 / 模型选择与默认恢复 / 状态与 wait / 端口占用 / token 写不了 / 日志不落正文 / readBody 超限)。
没跑的部分:真实挂载后的 curl 冒烟(上面那段)。因为本插件尚未被挂载、宿主也未重启,
跑不了真宿主上的 /health、真会话创建与 wait。测试里用的是假 ctx + 真的 http.Server:
路由、鉴权、参数、信封、日志都覆盖了;唯一没验证的是「宿主真实服务的方法名/返回字段是否与
dsh-api-session-controller 源码一致」——挂载后第一件事就是跑冒烟流程确认。
补一句结构说明:apply() 只做三件事(取 logger、起/关 server、用 ctx.effect 登记 disposer),
所有可测逻辑都在 startBridge() / 请求处理器里;apply 本身没有单测(它会绑固定端口 39217),
靠挂载后的冒烟覆盖。
已知边界
- 预设别名:
studymate→learning是显式映射(宿主session_create工具文档里写作studymate, 注册表里的真实 id 是learning)。仅当注册表里没有同名 id 时生效;/health.presets里给的是真实 id。 其它 preset id 原样传递,不存在就报agent-preset/not-found并列出可用 id。 - 模型选择会短暂改写默认模型再恢复:恢复失败只在日志 warn,接口不报(会话的模型仍然是对的)。
- cwd 不在任何已注册工作区时,会话能建但侧栏可能不显示,回包带
warning。 - 状态读取会 materialize 整条会话事件(
sessionQuery.observeSession的events):超长会话有 内存/耗时成本;turnCount必须全量扫,暂未做分页优化。 - 宿主刚启动的瞬间
/health.presets/models可能还是null(服务未就绪),等一两秒再请求即可。 - wait 上限 240s,超过请自己循环(客户端断开后桥会中止等待,不残留)。
- token 每次 Host 启动都会重新生成,旧 token 立即失效;重启后外部脚本要重新读文件。
- 端口固定 39217,被占即启动失败(不换端口),日志里会写明 errno。
- 只绑 loopback;HTTP 无 TLS(本机回环)。不联网、不上报;日志只记路由/会话 id/耗时/结果码,不记消息正文。
- 没有对
sessionId做格式白名单,非法 id 由宿主报session/not-found(避免桥和宿主两套校验打架)。