第七部:产品面——同一运行时的不同面孔
同一个 Cordis 运行时,长出五个产品面:Headless CLI 一次性跑完、JSON-RPC Server 长期驻留、TS/Python SDK 协议共享、ApiProxy 控制面、Conversation UI 只投影不持真。
![[图片占位:同一棵Cordis运行时树长出多个入口——终端CLI(复古绿)、JSON-RPC服务器(齿轮状)、SDK代码窗口(括号)、Web浏览器(多面板)、ACP自动化(机器人手臂)。每个入口共享同一根但枝叶不同。色调:彩虹棱镜效果,从根部深蓝向外辐射多彩。]](/static/images/handbook/deepseek-harness-internals/parts/07-product-surfaces.png)
展开阅读路线与实验入口
你用 DeepSeek Harness 的方式有几种?终端敲 dsh --profile headless "写个排序"?Python 里 from deepseek_harness import DeepSeekHarness?浏览器里点开对话界面?还是编辑器插件通过 stdio 连一个常驻进程?
看起来是五个产品。但你打开进程列表看——它们不是五个不同的 Agent 实现。它们是同一棵 Cordis 运行时树上长出的五个入口,共享根容器里的 system prompt、tools、sandbox、approval,只是在入口层做了不同的包装。
第七部回答一个反直觉的问题:为什么五个入口共享同一套 AgentLoop,但你写脚本调用 SDK 和在浏览器里点按钮,体验天差地别?答案不在 Agent 里,在入口层做了什么。
这一部解决什么
读完这五章,你能分清五个产品面各自的边界在哪里、谁是事实源谁是投影、谁是一次性进程谁是常驻服务。你会知道 Headless 不是 ACP,SDK 不是简单 API wrapper,ApiProxy 不是透明代理,Conversation UI 从来不持有真相。把这些搞混,你会在 SDK 里找 Session 对象,在浏览器里找 Agent 引用,在 ApiProxy 里找模型调用代码——全部找错地方。
如果你不是线性通读,想先把产品面脑图搭起来,可以这么走:先看 Headless / Server / SDK 的入口边界,再看 ApiProxy 到底是什么,最后看 Conversation UI 为什么只是投影。第七部不难在五个名词本身,而难在别把事实源、包装层、投影层混成一团。
accTitle: 第七部阅读路径——同一运行时五个产品面
accDescr: Headless CLI 提供命令行交互,JSON-RPC Server 暴露协议,TypeScript/Python SDK 封装客户端,ApiProxy 是控制面板,Conversation UI 投影到前端界面
accDescription: 第七部阅读路径流程图,从 Cordis Root(system prompt/tools/sandbox/approval)出发,分支出五个产品面:Headless CLI(一次性任务→输出→退出)、JSON-RPC Server(长期驻留多session)、TS/Python SDK(共享协议契约)、ApiProxy(Host控制面)、Conversation UI(投影而非真相源),并展示它们之间的关系。
flowchart TB
Root["Cordis Root\n(system prompt / tools / sandbox / approval)"]
Root --> H["Headless CLI\n一次性任务→输出→退出"]
Root --> S["JSON-RPC Server\n长期驻留多 session"]
Root --> SDK["TS / Python SDK\n共享协议契约"]
Root --> AP["ApiProxy\nHost 控制面"]
Root --> UI["Conversation UI\n投影而非真相源"]
S --> SDK
AP --> UI
H -.->|"对比"| S
SDK -.->|"嵌入"| Other["其他应用"]
style Root fill:#1e3a5f,stroke:#3b82f6,color:#fff
style H fill:#065f46,stroke:#10b981,color:#fff
style S fill:#7c2d12,stroke:#f97316,color:#fff
style SDK fill:#581c87,stroke:#a855f7,color:#fff
style AP fill:#1e3a8a,stroke:#3b82f6,color:#fff
style UI fill:#831843,stroke:#ec4899,color:#fff
本部实验全部只读:grep/cat 源码文件,用 $DSH_SOURCE_DIR 指向官方固定 commit checkout。
从最小的产品面开始:Headless CLI。你以为它就是”没有 TUI 的 CLI”?它比那更极端——它连 Agent 都不长期持有。
Headless CLI 与 JSON-RPC Server——两种产品面
同一套 Cordis 运行时拥有两种截然不同的产品面。Headless 是"一把梭"——一条任务进、一段文本出、进程死;JSON-RPC Server 是"长驻服务"——启动后持续等待请求、按需创建 Agent、通过协议帧维持多 session 并发。本章用 Mode C 对比辨析这两者的混淆根源、各自的生命周期模型、以及何时必须分清它们。
混淆的根源
你在仓库里翻到两个东西:packages/bundle/headless/ 和 packages/sdk/server/。两个包都调用 agents.create()、都依赖 Cordis 上下文、都能让 Agent 跑起来回答问题。于是你产生了一个合理但错误的假设:JSON-RPC Server 就是 Headless 的”加强版”——多了网络监听而已。
这个假设隐含的心智模型是”同一个循环的不同出口”。实际上它们是两种拓扑:
- Headless:进程 = 任务。一条 task 进来,跑完,进程死。没有第二轮。
- JSON-RPC Server:进程 = 服务。启动后不退出,持续通过 stdio 接收 JSON-RPC 请求,按 sessionId 分发给不同的 Agent,协议帧推送事件流,直到客户端发
shutdown才关闭。
混淆它们会导致三类工程错误:在 CI 里用 JSON-RPC Server 跑一次性任务(进程不会自己死,你得显式 shutdown);在 SDK 集成里用 Headless 循环 spawn(每次冷启动 Cordis,延迟爆炸);在设计自动化管道时以为两者的输出格式相同(一个是 stdout 纯文本,一个是协议帧)。
还有一类更隐蔽的错误:以为 JSON-RPC Server 在收到 prompt 后会”跑完就退出那个 session”。不会。Agent 创建后一直存活在 sessions Map 里,直到 shutdown 或 Server dispose。这是 long-lived 的核心含义——资源不随单次请求释放。
下面逐个拆解。
Concept A:Headless CLI——一把梭
设计意图
Headless 的设计哲学是 Unix 命令:接收输入、产出输出、退出。它的用户是人类在终端敲命令,或 shell 脚本 / CI pipeline 的 $(dsh --profile headless "...") 调用。
它的全名是 @deepseek-ai/dsh-headless,在 bundle 层以 Cordis 插件形式挂载。和其他 profile(Web、Electron)一样走 runProfile() 启动。差别仅在 bundle 组合:Headless 不包含 webserver、不包含 apiproxy、不包含 frontend dist serving。
生命周期:四步走完
Headless 只有两个插件协作:headless-startup 负责解析命令行拿到 task 字符串;headless-runner 负责执行。
第一阶段:解析 task
headless-startup 从 cmdlineArgs 服务拿到原始命令行,用 commander 解析。如果 task 为空,直接 program.error() 报错退出——不会启动 Agent。解析成功后通过 ctx.provide(HEADLESS_STARTUP_SERVICE, { task }) 把 task 发布为 Cordis 服务。
第二阶段:创建 Agent 并投递消息
headless-runner 声明 inject = ['agentDefaultModel', 'agents', 'sessions']。它的 apply() 读取 task 配置后直接调用 run() 函数:
await ctx.get('loader')?.await()—— 等所有 loader siblings 挂载完。不等的话你拿到的工具注册表可能是半成品。agents.create({ sessionId, meta: { cwd }, agentOptions })—— 生成全新session-${randomUUID()}作为 id,从agentDefaultModel.currentSelection()取 provider 和 model。注意:没有 preset 组合、没有 workspace 验证、没有去重检查。agent.followup(createUserMessage(...))—— 投递一条 user 消息。await agent.whenIdle()—— 等 Agent 跑到空闲。在此期间 AgentLoop 正常执行:system prompt 组装、LLM 调用、tool dispatch、approval 流程一个不缺。
第三阶段:提取结果
summarize() 函数从 firstSeq 之后的事件流里找最后一条 assistant/message 事件,提取其中所有 type === 'text' block 拼接为字符串。非文本内容(tool call 卡片、图片、reasoning tokens)不会出现在输出里。
第四阶段:刷盘退出
sessions.flush(agent.session) 把 session 事件写入持久化层。然后根据 turn/end 的 reason:
kind === 'completed'→io.exit(0)- 其他 → stderr 写错误信息,
io.exit(1)
输出格式
stdout 只有一行(或多行)纯文本——summarize() 的拼接结果加一个换行符。stderr 只在异常时输出 dsh: <code>: <message> 格式的错误行。没有 JSON、没有协议帧、没有事件流。
这意味着你可以 result=$(dsh --profile headless "explain quicksort") 然后 echo "$result" 直接用。但你不能 parse 结构化事件、不能区分 tool output 和 final answer、不能 cancel 正在进行的 turn(只能 SIGINT 杀进程)。
退出码语义明确:0 = Agent 正常完成任务(turn/end reason 是 completed),1 = 其他情况(包括 error、max-tokens、被打断等)。这让 CI 脚本可以直接用 set -e 来判断成败,不需要 parse 输出内容。
Headless 不是”精简版 Agent”
Headless 仍然挂载完整的 dsh-base。system prompt 会组装、sandbox 能生效、approval 机制存在、bash 工具正常执行、code runtime worker thread 会启动。它只是”精简版入口”——入口只做一件事:把一条 task 喂给完整的 Agent 然后等结果。
如果 Agent 在执行过程中需要 approval(比如危险命令),approval 插件会尝试请求用户确认。但 Headless 没有 TUI 来展示对话框——行为取决于 approval 策略配置:可能超时失败、可能按预设规则自动批准或拒绝。在脚本中跑 Headless 前,务必确认 approval 策略不会卡住等待交互输入。
Concept B:JSON-RPC Server——长驻协议服务
设计意图
JSON-RPC Server 的用户不是人类,是程序——外部 SDK 客户端(Python、TypeScript、Go wrapper)。它的设计哲学是 Language Server Protocol 式的长连接协议:一个常驻进程通过 stdio 和客户端通信,客户端可以创建多个 session、向不同 session 投递 prompt、订阅事件通知、最后显式 shutdown。
它的全名是 @deepseek-ai/dsh-sdk-jsonrpc-server,以 Cordis 插件形式加载。它声明 inject = ['agents']——只需要 Agent 工厂,不需要 webserver、apiproxy 等 Web 层。
生命周期:三阶段握手
JSON-RPC Server 的协议借鉴 LSP 模式:initialize → 正常请求 → shutdown。
阶段一:initialize 握手
客户端连接后发送 initialize 请求,携带 cwd、provider、model、可选的 maxTokens。Server 解析参数、设置工作目录、检查 LLM adapter 是否已注册(如果请求的 provider 是 deepseek-official 但尚未加载,Server 会动态挂载 LlmDeepSeek 插件)。返回 { serverInfo: { name, version } }。
这一步完成后,Server 准备好接受 prompt 请求。
阶段二:prompt 与事件流
客户端通过 session/prompt 方法投递消息,携带 sessionId 和 contentBlocks。Server 按 sessionId 查找或创建 Agent:
- 如果
sessionsMap 里已有该 id 的记录,复用现有 Agent。 - 如果没有,调用
agents.create()创建新的,保存到 Map。
这就是和 Headless 的核心差异:Headless 只创建一个 Agent 然后进程死;Server 维护一个 Map<string, SessionRecord>,Agent 跨请求存活。
Agent 跑起来后,事件通过 Cordis 事件总线流出。Server 构造函数里注册了四类监听器:
session/event→transport.notify('session.event', ...)推送 session 事件流agent/status→transport.notify('session.status', ...)推送 Agent 状态变化session/created(带 parentSession)→transport.notify('subagent.started', ...)通知子 Agent 启动subagent/end→transport.notify('subagent.finished', ...)通知子 Agent 完成
这些 notification 是 JSON-RPC notification(无 id,客户端不需要响应)。客户端可以据此渲染流式输出、跟踪 Agent 状态、管理子 Agent 树。
阶段三:shutdown 与优雅退出
客户端发 shutdown 请求。Server 执行 performShutdown():
- 设置
shuttingDown = true,后续的getOrCreateSession会直接 throw - 等待所有进行中的 session creation Promise settle
- 遍历
sessionsMap,逐个handle.dispose()销毁 Agent - 移除所有事件监听器
- 如果动态挂载了 LlmDeepSeek fiber,dispose 它
- 返回空对象
{}
收到 shutdown 响应后,index.ts 里的 disposeAndExit() 会 flush transport、dispose root fiber、调用 exit(0)。进程至此退出。
请求分发:handleRequest
Server 的分发逻辑极简——一个 switch:
initialize → this.initialize(params)
session/prompt → this.prompt(params)
shutdown → this.shutdown()
default → throw unknown method
目前只有三个方法。这不是偶然的精简,而是 SDK 协议的设计约束:Server 只负责 Agent 会话的创建和消息投递,不负责 workspace 管理、settings 配置、credential 存储等 Host 层面的操作。那些操作属于另一套 RPC 体系(packages/host/apiproxy/ 的 Host RPC,面向浏览器前端)。
Transport 层:stdio + line-delimited JSON-RPC
JsonRpcLineTransport 把 stdin/stdout 包装成 JSON-RPC 帧的读写流。这意味着:
- stdout 被协议占用——你不能在 Server 模式下往 stdout 写日志(Headless 可以,因为 stdout 就是它的输出通道)。
- 传输层和业务层解耦——
HarnessSdkJsonRpcServer不知道底层是 stdio 还是 TCP 还是 Unix socket,它只和JsonRpcTransportPeer接口交互。 - 帧以换行符分隔——每行是一个完整的 JSON 对象。没有 Content-Length header(LSP 的做法),没有二进制帧。简单到你可以用
readline模块手工实现客户端。
apply() 函数把所有东西串起来:创建 transport、创建 server、注册 transport.onRequest 回调把每个进来的 JSON-RPC request 路由到 server.handleRequest。如果方法是 shutdown,在返回响应后用 setImmediate 异步执行 disposeAndExit()——确保响应帧先写出去再关进程。
并发安全:sessionCreations Map
一个微妙的设计:如果两个 session/prompt 请求携带同一个 sessionId 几乎同时到达,而该 session 尚未创建过,会不会创建两个 Agent?不会。getOrCreateSession() 维护了一个 sessionCreations: Map<string, Promise<SessionRecord>>。第一个请求触发 createSession() 并存入 pending promise;第二个请求发现 pending 存在,直接 await 同一个 promise。创建完成后 promise 从 map 中移除,record 进入 sessions map。
这保证了即使 transport 层并发分发多个请求,同一 sessionId 只会创建一个 Agent。Headless 不需要这个机制——它只有一个请求,不存在并发。
对比表:维度级差异
| 维度 | Headless CLI | JSON-RPC Server |
|---|---|---|
| 进程模型 | 进程 = 一个任务的生命周期 | 进程 = 服务的生命周期 |
| Agent 数量 | 正好一个,跑完即销毁 | 零到多个,按 sessionId 复用 |
| Session 管理 | 每次新建 randomUUID,不复用 | 客户端指定 sessionId,首次创建后续复用 |
| 通信方式 | 命令行参数进,stdout 文本出 | stdio JSON-RPC 帧,双向通信 |
| 输出格式 | 纯文本(最后一条 assistant message) | 结构化 notification 事件流 |
| 多轮对话 | 不支持,进程结束就没了 | 天然支持,同 sessionId 可多次 prompt |
| 取消机制 | SIGINT/SIGTERM 杀进程 | 协议层面可扩展(当前通过 shutdown) |
| 启动开销 | 每次任务冷启动 Cordis | 一次启动,后续请求零开销 |
| 适用场景 | CI 脚本、一次性问答、shell 管道 | SDK 集成、编辑器插件、自动化管道 |
| inject 依赖 | agentDefaultModel + agents + sessions | agents(initialize 时可能动态加载 LLM) |
| 退出方式 | 自动退出(task 完成 or 错误) | 显式 shutdown 请求 |
共性:同一个 Agent、同一套基础设施
容易因为差异太显著而忽略共性。两者共享的关键基础设施:
-
同一个
agents.create()工厂——两者创建 Agent 的 API 调用几乎一模一样。Headless 传sessionId: SessionId(`session-${randomUUID()}`),Server 传sessionId: SessionId(clientProvidedId)。差别只是 id 来源。两者都传meta: { cwd }(Headless 用process.cwd(),Server 用 initialize 时客户端提供的 cwd)。两者都传agentOptions: { provider, model }(Headless 从agentDefaultModel.currentSelection()读,Server 从 initialize params 读)。 -
同一个 AgentLoop——创建出来的 Agent 走相同的循环:system prompt 组装 → LLM 调用 → tool dispatch → approval → 下一轮。两者没有各自的 “简化版循环”。当 Agent 需要执行 bash 命令时,Headless 和 Server 里的 Agent 走同一条 tool execution 路径。
-
同一个 Cordis 上下文——两者都挂载在 Cordis 树上,享有相同的 DI 和生命周期管理。Server 通过
ctx.on('session/event', ...)监听事件,Headless 通过agent.session.events直接读事件数组——数据源是同一个。 -
同一套 session 持久化——Headless 显式调
sessions.flush()。Server 的 Agent 通过 Cordis 树的正常生命周期在 dispose 时也会触发持久化。两者的 session 事件流格式相同、写入位置相同。 -
同一套 invariant 机制——两个包都有
invariant.ts伴随插件。虽然当前都注册了空的 installer(因为它们是”面”不是”核心”,没有自己需要审计的可变状态),但它们都遵循了仓库级别的 invariant 注册纪律。
这就是”同一运行时的不同面孔”这个部标题的含义:运行时一个,面孔两张。你换掉的不是 Agent、不是 LLM adapter、不是 tool registry,你换的只是进程的”外壳”——是一次性命令壳还是协议服务壳。
什么时候你必须分清它们
场景一:CI/CD 中跑一次性任务
用 Headless。dsh --profile headless "run tests and report failures" → 拿 stdout → 写到 PR comment。不要启动 JSON-RPC Server 然后发 initialize + prompt + shutdown——那是杀鸡用牛刀,而且你得自己管进程退出。
场景二:编辑器插件需要多轮对话
用 JSON-RPC Server。编辑器启动一个 server 子进程,通过 stdio 通信。用户打开多个 tab 就对应多个 sessionId。Agent 在两次用户输入之间保持存活,不需要重建上下文。
场景三:Python SDK 批量处理文件
用 JSON-RPC Server。启动一次,循环发 prompt,避免每次冷启动 Cordis 的开销。如果批量任务彼此独立,可以用不同的 sessionId 并行。
场景四:Dockerfile 里执行一条命令
用 Headless。容器跑完就退出,exit code 决定 CI 步骤成功与否。
场景五:自动化测试框架验证 Agent 行为
看你要测什么。如果测 Agent 的最终输出——Headless 足够,spawn 进程断言 stdout。如果测 Agent 的中间过程(tool call 顺序、子 Agent 启动、事件流完整性)——用 JSON-RPC Server,订阅 notification 做断言。
场景六:需要超时控制
Headless 本身没有超时参数。你可以在 shell 层用 timeout 30 dsh --profile headless "..." 包装——进程被 SIGTERM 杀死,exit code 非零。JSON-RPC Server 的超时由客户端控制:客户端可以在等待 notification 时设置自己的 deadline,超时后发 shutdown 走优雅退出路径。
shutdown 的语义差异
两者都有”退出”的概念,但语义完全不同。
Headless 的退出是隐式的。 task 跑完 → summarize() → io.exit(code)。不需要外部触发,不需要协议帧,进程自行决定何时死。退出码由 Agent turn 的结束原因决定:completed → 0,其他 → 1。调用方(shell、CI)通过 $? 判断成败。
JSON-RPC Server 的退出是显式的。 客户端必须发 shutdown 请求。收到后 Server 依次做:设置 shuttingDown 标志位 → 等待所有 pending session creation → dispose 所有活跃 Agent → 移除事件监听器 → dispose 动态加载的 LLM fiber → 返回 {} 响应。响应写出后,disposeAndExit() flush transport → dispose root fiber → exit(0)。
如果客户端进程崩溃导致 stdin EOF 而没发 shutdown 怎么办?transport 检测到 EOF 后触发 Cordis effect disposer,走 server.shutdown() + transport.close() 路径——功能上等同于收到了 shutdown 请求。这是一种”优雅降级”:即使客户端不合作,Server 也不会无限悬挂。
Transport 层的设计选择:为什么是 stdio JSON-RPC
你可能会问:为什么 SDK Server 不用 HTTP?为什么不用 WebSocket?
答案在 packages/host/webserver/ 和 packages/host/apiproxy/ 里——HTTP + WebSocket 那套是给浏览器前端用的 Host RPC 体系,它承载的是完整的产品 UI 交互(session 列表、workspace 管理、settings 配置、model 选择等几十个方法)。SDK Server 有意不走这条路:
- stdio 天然适合子进程通信——父进程 spawn 子进程,stdin/stdout 管道自动就位,不需要端口分配、不需要 HTTP 握手、不需要 CORS。
- 协议极简——只有三个方法。SDK 客户端不需要管理 workspace 或 settings,它只管”初始化 → 发消息 → 关闭”。
- 进程生命周期天然绑定——父进程退出,子进程收到管道关闭信号。不需要心跳、不需要重连逻辑。
这和 Headless 的”没有协议”形成对照:Headless 的输出通道(stdout)就是最终产物;Server 的输出通道(stdout)是协议传输层,最终产物在协议帧里。
易混淆点:Host RPC vs SDK JSON-RPC
仓库里有两套 RPC 体系,容易搞混:
Host RPC(packages/host/apiproxy/):面向浏览器前端的四象限 RPC 模型。支持 session.list、session.create、session.prompt、workspace.list、settings.update 等几十个方法。通信走 HTTP POST + Server-Sent Events。有 ClientRequest、ServerResponse、ServerRequest、ClientResponse 四种消息类型。
SDK JSON-RPC(packages/sdk/server/):面向 SDK 客户端的精简协议。只有三个方法(initialize、session/prompt、shutdown)加四类 notification。通信走 stdio line-delimited JSON-RPC。
两者的目标用户不同、协议复杂度不同、传输层不同。但它们底层调的都是同一个 agents 服务来创建和驱动 Agent。
容易踩的坑
坑一:在 Headless 里期望流式输出。 Headless 直接 await agent.whenIdle(),等整个 turn 跑完才输出。你看不到 token 逐个打印的效果。想流式消费?用 JSON-RPC Server 的 session.event notification。
坑二:以为 JSON-RPC Server 每个 prompt 都创建新 Agent。 不是。getOrCreateSession() 先查 this.sessions Map,有就复用。同一个 sessionId 的多次 prompt 会投递给同一个存活的 Agent。这正是多轮对话的实现机制。
坑三:在 JSON-RPC Server 里用 console.log 调试。 stdout 被协议帧占用。你的 log 会被客户端当成畸形 JSON-RPC 帧 parse 失败。调试请写 stderr 或用 Cordis logger(默认不写 stdout)。
坑四:以为 Headless 不持久化 session。 它明确调了 sessions.flush(agent.session)。每次跑都会落盘一个 JSONL 文件。CI 里长期跑会堆积。清理方案:定期删 DSH_SESSION_ROOT 下的旧文件。
坑五:试图在 Headless 里实现 cancel。 Headless 没有协议层 cancel。await agent.whenIdle() 是不可中断的(除了进程级 SIGINT 触发 Cordis 树 dispose)。如果你需要超时控制,在 shell 层用 timeout 命令包装。
坑六:把 SDK JSON-RPC 的 session/prompt 和 Host RPC 的 session.prompt 混为一谈。 名字相似但协议不同、传输不同、参数结构不同。SDK prompt 携带 contentBlocks;Host RPC prompt 走 SessionsApi['prompt'] 签名,有更复杂的 attachment / queue 语义。分隔符也不同:SDK 用 /(session/prompt),Host RPC 用 .(session.prompt)。
坑七:以为 JSON-RPC Server 不需要 LLM adapter 预配置。 initialize 时如果请求的 provider 不在已注册列表里,Server 只会为 deepseek-official 做动态加载。任何其他未注册的 provider 会直接 throw。如果你的 SDK 客户端要用自定义 provider,确保 Server 进程的 cordis.yml 配置里预加载了对应的 adapter 插件。
收口:一个运行时,两张面孔
回到开头的问题:为什么同一套 Cordis 运行时需要两种进程外壳?因为它们服务的消费者完全不同。
shell 用户要的是一条命令、一个结果、一个退出码。不需要握手,不需要协议,也不想管理连接。Headless 正好满足这个场景,代价是每次都要冷启动 Cordis,所以更适合低频调用。
SDK 客户端要的是一次启动、多次交互、结构化事件流和显式生命周期控制。它不能接受反复冷启动,也不能只拿纯文本输出,还可能要并发管理多个 session。JSON-RPC Server 满足的是这类需求,代价是调用方必须实现协议客户端并接管进程生命周期。
flowchart LR
subgraph Runtime["Cordis Runtime (dsh-base)"]
AG["agents service"]
SS["sessions service"]
LLM["LLM adapters"]
TOOLS["tool registry"]
end
subgraph Headless["Headless CLI"]
H1["parse task from cmdline"]
H2["agents.create (one agent)"]
H3["followup → whenIdle"]
H4["summarize → stdout → exit"]
end
subgraph Server["JSON-RPC Server"]
S1["listen stdio transport"]
S2["initialize handshake"]
S3["session/prompt → getOrCreateSession"]
S4["notify events to client"]
S5["shutdown → dispose all"]
end
Runtime --- Headless
Runtime --- Server
style Headless fill:#1e3a5f,stroke:#60a5fa,color:#fff
style Server fill:#3b1f2b,stroke:#f472b6,color:#fff
style Runtime fill:#1a2e1a,stroke:#4ade80,color:#fff
Headless 是 task → answer → exit。JSON-RPC Server 是 start → (initialize → prompt* → shutdown) → exit。两者不是前者的简化版和后者的增强版关系,而是面向不同消费者的两种产品形态——shell 用户 vs 程序化 SDK。
理解这个区别后,你就能解释仓库里看似冗余的设计:为什么有两个包都调 agents.create()、为什么 Headless 不复用 SDK Server 的 transport、为什么 Server 不内嵌 Headless 的 summarize 逻辑。答案是它们面向的消费者对”一次交互”的定义根本不同——shell 脚本认为”进程就是交互”,SDK 客户端认为”协议帧才是交互”。
你选择哪个,取决于一个问题:你的调用方是会退出的脚本,还是会一直活着的程序? 前者用 Headless,后者用 JSON-RPC Server。没有中间状态。