三种产品形态:Web、headless 与 SDK
专栏:DeepSeek Harness · 第 13 / 14 篇前几篇都在讲「运行中的 dsh 内部」。这篇往外看:同样的插件树(dsh-base 底座)如何顶上不同的壳,变成三种产品——第 5 篇那张 profile 表里的 web、headless、sdk(外加自动化的 acp)。
Web GUI:两个进程半面的分工
Web 形态是「宿主进程 + 浏览器」的协作,dsh 刻意把两侧做成对称的两半:
几个值得学的决策:
- webserver 不懂任何业务。它只是具名路由注册表(
ctx.webServer.register),/api桥、/pluginsbundle 路由、HMR SSE、SPA 静态资源各自认领,冲突即配置错误。功能插件与路由解耦:
// packages/client/modules/src/index.ts:585-591(host 侧模块认领路由)
ctx.effect(
() => ctx.webServer.register({ kind: 'prefix', path: '/plugins', handler: this.serveBundle }),
'client-modules: bundle route',
)
- 浏览器也是 Cordis 应用。client 侧同样跑插件与 slot 系统,UI 功能不是一棵写死的组件树,而是约 45 个插件向类型化 slot(single/list/keyed/chain 四种基数)里注册内容。
- 认证三道栅栏:每进程随机启动令牌(
?token=...只在首页接受一次,立刻换成签名 cookie)→ cookie 密钥存进凭据 grant → 请求信任检查(loopback/受信 + Origin 一致 + 拒绝 cross-site)。
会话数据如何到像素(读 Web 代码的地图):
| 链路 | 通路 |
|---|---|
| 持久展示 | Host 会话日志 → Remote follow/page 历史 → client 会话窗口 → Conversation 组装器 → slot 视图 → React |
| 实时流 | agent/assistant-stream → client 专属瞬态事件(断线重连后从日志结算重建) |
| 交互决策 | Host waterfall(如审批)→ Remote $events → 浏览器 ctx.remote.$on() → 结果沿 waterfall 返回 |
浏览器侧消费 scoped waterfall 的真实代码(第 12 篇的审批卡片就是它):
// packages/client/ui-approval/src/client/index.ts:75-93(节选)
export function apply(ctx: ClientContext): void {
const registerPendingInteraction = ctx.uiSession.registerPendingInteraction<PendingApproval>(() => 0)
ctx.slots.inject('conversation.composer', () => ctx.slots.register({
name: 'conversation.composer', priority: 1,
select: ({ pendingInteraction }: ComposerChainProps): PendingApproval | null =>
pendingInteraction instanceof PendingApproval ? pendingInteraction : null,
...
}, ApprovalPanel))
ctx.remote.$on('approval/request', function (request, next) {
return answerApproval(ctx, this, request, next, registerPendingInteraction)
})
}
headless:一次性运行
dsh --profile headless "任务":无 GUI、无服务器、不开端口。推理增量以 dsh: reasoning: 前缀流到 stderr,最终答案独占 stdout——这是给脚本和管道的消费约定。退出码即语义:最终 turn/end 为 completed → 0;aborted/error/没有轮次 → 1。没有交互后续,缺任务文本在任何东西运行前就拒绝。
SDK:一条 JSON-RPC 管道
SDK 形态(dsh --profile sdk)把整个 harness 装进一个按换行分帧的 JSON-RPC 2.0 over stdio 协议里,方法表小得惊人:
// packages/sdk/protocol/src/types.ts:106-119
/** Server-to-client notifications by JSON-RPC method name. */
export interface HarnessSdkNotificationMap {
'session.event': SessionEventNotification
'session.status': SessionStatusNotification
'subagent.started': SubagentStartedNotification
'subagent.finished': SubagentFinishedNotification
}
/** Client-to-server request methods with their param and result shapes. */
export interface HarnessSdkRequestMap {
'initialize': { params: InitializeParams; result: InitializeResult }
'session/prompt': { params: SessionPromptParams; result: SessionPromptResult }
'shutdown': { params: undefined; result: Record<string, never> }
}
语义上的关键决定:session/prompt 只返回持久入队回执(messageId),不等待结果——结果通过 session.event 无过滤广播回来,客户端自己收集到 session.status 变为 idle。没有版本协商,也没有取消方法(放弃轮次 = 关进程;关闭阶梯:shutdown 请求 → stdin EOF → SIGTERM → SIGKILL)。
一次 SDK 会话的时序:
Python SDK 是 TS 的设计孪生,但它不自带应用——随附的运行时 wheel 内嵌 dsh 可执行文件,以 --profile sdk 拉起子进程。两个硬规矩:dsh_home 必须显式给(绝不静默读 ~/.dsh);进程惰性启动、跨 run() 复用。
补充:多 agent 与后台任务
- subagent(
ctx.subagents):一个约定服务 + 任意多提供方——进程内 spawn/fork、ACP、真实 Codex、真实 Claude Code、完整 Harness 运行时——tool-subagent把委派暴露给模型。「把一个轮次委派给另一个产品」和「新建一个子 agent」在同一个接口之后。 - jobs(
ctx.jobs):长任务注册为作业(归属启动它的会话,永不串台),完成以会话内通知送达,job_*工具负责读取与终止。第 14 篇写后台任务时会用到。
与其他模块的联系
- Web 的审批卡片(第 12 篇)是 scoped waterfall 的浏览器应答者;
- headless 的退出码(completed→0)直接来自 turn/end 的 reason 枚举(第 7 篇);
- SDK 的 session.event 广播的就是会话日志(第 6 篇)——订阅者拿到的是完整事件流而非摘要;
- subagent 的六种后端(第 14 篇)意味着”委托给真实 Codex”在协议层是第一公民。
三种形态其实是同一个问题的三个答案:「agent 的输入输出边界画在哪」。Web 画在浏览器与宿主之间(事件流 + waterfall 透传),headless 画在进程边界(stdout/stderr/退出码),SDK 画在 stdio 协议(JSON-RPC)。底座完全相同——这正是第 5 篇「profile = 有序组合包」的回报。
下一篇是整个专栏的收官:把这些全部用起来,亲手写一个工具插件。