为什么 Web 新建会话不是 CLI 的前端按钮
Headless 入口直接调用 agents.create(),Web 端通过 ApiProxy 走 HTTP/WebSocket。ApiProxy 是 Host 控制面,不是简单转发。Client Runtime 按领域投影状态,不直接持有 Session。dsh web 是 --profile web 的别名,但走的是 web app bundle,在 Host 进程里跑一切,浏览器只是个渲染终端。
你在浏览器界面点了”新建会话”按钮。你可能以为这就是 CLI createAgent() 的前端版本——按钮一点,浏览器里 new 一个 Session,new 一个 Agent,开始跑。不是这样的。浏览器里没有 Cordis,没有 AgentRegistry,没有 SessionStore,没有 LLM client。它连 Node.js 都没有。
浏览器发一个 JSON-RPC POST 请求到 Host 进程的 /api/rpc 接口,方法名 session.create。Host 进程收到后在自己的 Cordis 容器里做权限检查、preset 组合、cwd 校验、workspace 归属、并发去重,然后才调用 ctx.agents.create()。Session 在 Host 进程里,Agent 在 Host 进程里,模型调用从 Host 进程发出。浏览器只是一个渲染终端加输入设备。
Headless 入口:直接调 agents.create()
Headless profile 的 bundle 列表是 ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless']。boot 完成后,headless 入口直接拿到 ctx.agents,调用 agents.create() 或 agents.createAgent(),Agent 在当前进程里开始跑。命令行参数通过 ctx.cmdlineArgs 传入,prompt 从 stdin 或命令行读取,结果输出到 stdout。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '114,117p' "$repo/packages/boot/app-boot/src/profile.ts"
这是同进程调用,没有网络,没有序列化,没有 RPC 层。你拿到的是 Agent 对象的直接引用,你可以直接调 agent.prompt()、agent.cancel()、订阅 agent 的事件。这是最简单的路径——进程内直接调用。
Web 入口:ApiProxy 控制面
Web profile 的 bundle 列表是 ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']。dsh-web-app bundle 做什么?它不创建 Agent,它启动 HTTP 服务器(webServer),挂载前端静态文件服务(frontend-static),注册 ApiProxy,注册 WebSocket 事件流,然后打印一行 URL dsh web: http://127.0.0.1:PORT。
ApiProxy 不是简单的请求转发器。它是 Host 的控制面——所有从浏览器来的操作都经过它,它做校验、做投影、做去重、做授权、做错误翻译。你新建会话的 RPC 到了 ApiProxy 里,不是直接 ctx.agents.create() 那么简单。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '2167,2220p' "$repo/packages/host/apiproxy/src/api-proxy.ts"
看 sessions.create 的实现:先解析 workspaceId(如果有),workspace 不存在直接返回 workspace-not-found 错误;确定 cwd(workspace.path > 请求的 cwd > 默认 cwd);确定 requestedPreset;然后调 ensureSession()。ensureSession 里做了一堆事:检查这个 sessionId 是否已经有 live Agent(有就直接返回)、检查是否已经持久化(有就 resume 而不是 create)、检查 cwd 冲突、检查 preset 冲突、mkdir cwd、composeAgent(根据 preset 决定挂载哪些工具)、最后才调 ctx.agents.create()。
flowchart TD
A["浏览器点击 New Session"] --> B["HTTP POST /api/rpc session.create"]
B --> C["ApiProxy.sessions.create()"]
C --> D{"workspaceId?"}
D -->|有| E["查 workspaceRegistry,不存在返错"]
D -->|无| F["用默认 cwd"]
E --> G["确定 cwd"]
F --> G
G --> H["ensureSession(sessionId, cwd)"]
H --> I{"sessionCreations 去重 Map?"}
I -->|已有同 id 创建中| J["await 同一个 Promise"]
I -->|没有| K["检查 live Agent → 有就返回"]
K --> L{"持久化里有?"}
L -->|有| M["cwd 校验 + preset 校验 → resume"]
L -->|没有| N["mkdir cwd + composeAgent(preset)"]
M --> O["ctx.agents.resume()"]
N --> P["ctx.agents.create()"]
J --> Q["返回 Agent"]
O --> Q
P --> Q
Q --> R["ok response { sessionId, agentPreset }"]
R --> S["浏览器拿到 sessionId → WebSocket 订阅 mux 流"]
Client Runtime 按领域投影状态
浏览器里的 dsh-client-runtime 不持有 Session 对象。它也不可能持有——Session 是 Host 进程里 Cordis 容器里的 JavaScript 对象,浏览器根本看不到。
Client Runtime 维护自己的状态 store。它通过 HTTP RPC 获取初始数据(session.list、session.history),通过 WebSocket mux 流接收增量事件(session/event、session/projection、session/queue、approval/requested 等),然后按领域(sessions、conversation、workspaces、jobs、settings)把这些 wire 事件投影到自己的本地状态快照。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '1-16p' "$repo/packages/client/runtime/src/client/sessions/service.ts"
看 SessionRuntime 的注释:“list snapshot store (manager projection; carries current, the persisted selection every session-scoped surface keys off), Agent scope tree (mintScope pattern: no-op plugin Fiber + ctx.extend scope tag; one scope per session, agent id === session id), stable SessionBinding cache, breadcrumb-route projection.”
它有一个 SessionSummary 接口,是从 Host 返回的 wire 数据投影来的——id、title、displayTitle、cwd、agentPreset、parentId、running、blank、updatedAt、projectionValues。这不是 Session 对象,这是 Session 的摘要视图。真正的 Session(带事件日志、工具调用状态、LLM 引用)在 Host 里。
apps/web/main.ts 只有 10 行
你去看浏览器端的入口 apps/web/src/main.ts,它只有 10 行。它不组装 Cordis,不创建 Session,不连接模型。它只做一件事:new AppWebEntry(el).run()。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
cat "$repo/apps/web/src/main.ts"
AppWebEntry 来自 @deepseek-ai/dsh-client-web,这是客户端的 shell 库。它负责建立 HTTP/WebSocket 连接、初始化 Client Runtime、挂载 React 组件树。但它不拥有 Agent 生命周期,不做业务决策——那是 Host 的事。注释写得很明白:“Everything — loader holding, module-table seeding, AppRoot gate, plugin assembly — lives in @deepseek-ai/dsh-client-web; this file only finds the mount point.”
dsh web 是 —profile web 的别名但走的是 web bundle
dsh web 命令确实是 dsh --profile web 的硬编码别名——在 args.ts 里做了个 argv 替换。但它和 dsh --profile headless 的区别不只是”有 UI 和没 UI”。
两个 profile 加载不同的 bundle 列表:
- headless: dsh-base + dsh-headless → 直接 stdin/stdout 交互,进程内直接跑 Agent
- web: dsh-base + dsh-web-app → 启动 HTTP 服务器、ApiProxy、静态文件服务、WebSocket 流,Agent 在 Host 进程里由 ApiProxy 管理
Web 路径上,Session 由 Host 管理,不是由浏览器直接操作。浏览器不能创建 Session、不能直接调 model、不能访问文件系统——所有这些操作都必须经过 ApiProxy 的 RPC 接口,ApiProxy 在 Host 侧做权限检查和输入校验。
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
sed -n '156,169p' "$repo/apps/cli/src/args.ts"
ApiProxy 的投影:不是转发,是变换
ApiProxy 不只是把浏览器请求翻译成 Host 调用,它还做大量的投影工作:
- session.list:合并内存中 attached sessions 的实时状态和持久化中 cold sessions 的元数据,对 cold session 做 blank probe(只读元数据,不加载整个日志),生成 SessionSummary 列表。
- session.history:分页,对齐消息边界(不切断消息组),通过 viewFor 为 tool/call 和 tool/result 事件生成 UI 视图(presenter 模式),附带 projection 基线。
- session.create:做 workspace 归属、cwd 冲突检测、preset 冲突检测、并发创建去重(sessionCreations Map),错误翻译成稳定的 wire 错误码。
- 事件流:通过 WebSocket mux 推送 session/event 帧,同时附带 host-level 事件(session created/destroyed、agent failures)、approval/question 帧(需要用户交互的暂停点)、queue 快照、projection 变更帧。
这不是一个透明代理。这是一个领域网关——它理解 Session 的生命周期、Agent 的状态、Workspace 的归属关系,它把 Host 内部的复杂对象图变换成浏览器能消费的 JSON 事件流。
容易踩的坑
坑一:以为 Web 新建会话是 CLI 的前端按钮。 不是。CLI headless 直接同进程调用 agents.create(),Web 通过 HTTP RPC 到 ApiProxy,ApiProxy 做一堆校验和去重后才在 Host 进程里创建。浏览器里没有 Agent。
坑二:以为 ApiProxy 是透明转发。 它做 workspace 归属检查、cwd/preset 冲突检测、并发去重、session 状态合并(live + cold)、错误码翻译、presenter 视图生成。如果你绕过 ApiProxy 直接在 Host 里操作 Session,Web UI 不会知道——它只订阅 ApiProxy 广播的事件。
坑三:以为浏览器里有 Session 对象。 Client Runtime 只有 SessionSummary 投影和从事件流组装的 conversation 视图。没有日志、没有工具注册表、没有 LLM 连接。所有操作都是 RPC。
坑四:以为 dsh web 和 dsh —profile headless 只差个 UI。 Bundle 列表不同,启动的服务不同,交互模式不同(请求-响应+WebSocket流 vs stdin/stdout),会话管理不同(ApiProxy 管控 vs 直接控制),安全边界不同(浏览器不能直接访问文件系统)。
坑五:以为前端可以直接创建任意 session。 ApiProxy 的 session.create 校验 cwd 必须是绝对路径、校验 workspace 归属、校验 preset 一致性(已存在 session 的 preset 不能变),这些校验在 Host 侧做,前端绕不过去。
第一部讲完了。从 dsh 命令敲下,到 bin.ts 分发,到五层 patch 叠出配置树,到 Cordis 容器自组装,到 Agent/Session 的事务创建,到 Web 和 Headless 两条路径的分岔。你已经知道控制权是怎么从命令行一步步交到运行时手里的。第二部会进入运行时内部——Agent Loop 是怎么驱动模型对话的,工具是怎么注册和调用的,Session 事件流是怎么工作的。