DSH / Atlas
2026-09-09implementedfeaturearchived 2026-09-30

Headless machine-readable run surface

Headless 的机器可读运行接口

`dsh --profile headless` serves a human terminal: the task arrives only through argv, stdout carries one final assistant message, provider reasoning streams to stderr, and every run creates a fresh random session. [Headless is a direct core entry point](../../archived/architecture/2026-08-09-headless-direct-core-entry-point.md) owns that transport and completion contract; [headless reasoning progress](../../archived/

English

Problem

dsh --profile headless serves a human terminal: the task arrives only through argv, stdout carries one final assistant message, provider reasoning streams to stderr, and every run creates a fresh random session. Headless is a direct core entry point owns that transport and completion contract; headless reasoning progress owns the stderr projection.

A supervising process that drives one headless process per wake, such as an external agent runtime, needs three things that contract does not provide. It needs the task over a private pipe rather than argv, because a long prompt exceeds the argument limit and argv is visible to other processes. It needs a machine-readable stream that separates assistant text, reasoning, tool calls and results, turn boundaries, and usage, because scraping stderr yields only reasoning and the final stdout line yields no tool activity. It needs an exact session identity it can pass back on the next wake, because a fresh random session per process makes continuity impossible.

Decision

The dsh-headless bundle owns an opt-in machine-readable run surface. The default invocation keeps the previous contract unchanged: one final assistant message on stdout, reasoning on stderr, exit 0 exactly when the terminal turn/end reason is completed.

Three additions extend the app-owned command line that Apps own their command lines established:

  • --json replaces the stdout payload with newline-delimited JSON run events. Reasoning becomes an event instead of stderr output, so stderr carries only dsh: diagnostics.
  • --session-id <id> selects the exact session identity: adopt the persisted session with that id, and fail when no such log exists. Without the flag the run mints session-<uuid> as before.
  • The task text also arrives on stdin when no positional task is present, or when the positional is -.

A per-run --model override is deliberately out of scope; the composition default stays authoritative.

The product change is confined to packages/bundle/headless: src/startup.ts, src/index.ts, the new src/json-stream.ts, the package manifest and tsconfig.json, and its tests. Around it, the product-profile expectation test in apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts covers both output modes end to end and adds one optional caller-owned cwd to the packages/test-support/loader-smoke harness so two wakes can share a world. scripts/check-workspace-constraints.ts and the package manifest publish the shared lib/json-stream-*.js chunk both entries import, and pnpm-lock.yaml records the new @deepseek-ai/dsh-session-query workspace link. No core session, persistence, session-controller, base composition, or launcher file changes.

Command-line contract

dsh --profile headless [--json] [--session-id <id>] [<task>... | -]

Task resolution order: joined positionals, then -, then piped stdin. A whitespace-only positional is a usage error on its own, even when stdin is not a terminal, so an accidental blank argument never consumes a pipe; a task that is absent entirely is a usage error only when stdin is a terminal. A lone - is the only stdin marker: mixing it with other task words is a usage error instead of a task that starts with a dash. A piped task is sent verbatim, trailing newline included. In --json mode every usage error — including commander's own grammar rejections such as an unknown option or a missing option value — writes the error event before the process exits, because the runner never mounts to write it; the event message omits commander's error: prefix so one event type carries one message shape.

--json changes the stdout payload and the destination of the reasoning projection only. Exit status, shutdown ordering, session flush, and the durable session log are unchanged, so a supervisor classifies a run exactly as it does today.

Event stream

--json writes one JSON object per line to stdout and nothing else. The vocabulary is a projection of the session event log, not the log itself.

typeFieldsEmitted
sessionsessionId, cwdfirst line, before any model output
statusphase (turn_start, step_start, step_end, turn_end), turn, step, usage, reasonone per boundary
texttextone committed assistant text block
thinkingtextone committed reasoning block
tool_callcallId, tool, inputonce per call
tool_resultcallId, status, resultonce per appended result
errormessagea failure the runner raises outside a turn
finaltextlast line

Projection rules:

  • Text and reasoning are projected only from a committed assistant/message, never from live attempt deltas. A retried or discarded attempt appends assistant/attempt, which the projection ignores, so the stream never carries content the durable log does not contain (publish state only at its commit point).
  • Each committed content block becomes exactly one text or thinking event in content order; tool-call blocks are not projected because the tool/call event owns them. user/message echoes and internal session events (title, model selection, projection, checkpoint, goal, subagent) are not projected.
  • A tool/result is projected only when its surfaceOp is append. A compaction replacement of an older result is history, and projecting it would emit a call id with no matching tool_call.
  • Every projected string and object key is bounded at 8 KiB, an event with a cut value carries truncated: true, and one serialized event line, newline included, is bounded at 32 KiB — an over-long event keeps its scalar fields, drops structured ones, and at the extreme reduces to type and truncated, while a payload nested 64 levels or deeper is cut at that depth so no legal input can overflow the bounding recursion. This includes the process-level error event; a literal __proto__ argument key is copied as data rather than through the inherited setter, an empty tool-argument string projects as {} to match the executor, and arguments that JSON cannot round-trip (an overflowing number such as 1e400) keep their raw text rather than the null that JSON.stringify would report. The terminal final event is deliberately unbounded: it carries the same lossless answer the default mode prints.
  • Text and reasoning arrive when the step commits, not per token; default-mode stderr reasoning remains the only live text channel. A turn that fails in-turn still ends the stream with final and no error event, so a supervisor classifies that run from the exit code and the turn_end reason even when the stream is well formed.
  • usage appears on step_end only when every attempt in the step reported a sample, summed across them — including a retried attempt whose only usage sample sits in its discarded assistant/attempt stream — so a partial total is never presented as exact.
  • Raw session events stay out of scope. A debug escape hatch can be added later without changing this vocabulary.

Session identity

The runtime owns identity. A run without --session-id mints session-<uuid> and reports it in the first event. A supervisor persists that value and passes it back on the next wake.

--session-id <id> is adopt-only: observe the persisted session and resume it, failing when the log does not exist. The first wake omits the flag, so the runtime mints the identity and reports it in the session event; every later wake names that value and continues the history. A requested id with no stored log is an error rather than a fresh conversation, so a mistyped or stale id cannot silently open an empty history the caller believes it is resuming, and the JSONL store's refusal of an existing log id (session persistence) is never on this path. The id is opaque, so the runner validates non-emptiness on the trimmed value and passes the caller's exact string through, whitespace included.

Adoption compares the persisted session's recorded cwd with the process cwd, since sessions are organized per project directory (project session directories). A mismatch exits 1 with a dsh: diagnostic instead of silently continuing a conversation rooted elsewhere, and a session that recorded no cwd is rejected for the same reason. A session running under an agent preset is rejected because this bundle composes no preset roster: resuming it here would run it under the headless tools and prompts instead of the composition its log records. The check reads the preset the log currently records — the creation header advanced by any agent-preset/selected event — because a blank session may switch preset after creation while the header stays a creation fact, and a malformed selection record fails closed rather than reading as no preset. A session linked to a parent or subagent — including a user fork — is rejected. A live Agent already holding the requested id is refused outright: its owner may still drive it, and whenIdle is not a single-message signal, so the runner cannot own an exclusive interval over it. The resumed log is checked again after the runner's idle wait, so a preset selected in that window is still rejected. A whitespace-only sessionId is rejected on both the CLI and the direct-config path. Two live processes cannot write one id; the store's write lease already rejects the second writer. The runner reads the observation through the composed sessionQuery service and fails loudly when --session-id is requested without it — the observation is how it finds the id it must resume — or when the requested identity would lack the sessionPersistence service that makes it durable.

Consequences

What landed: src/startup.ts parses --json and --session-id <id>, treats an absent or - task as "read stdin", and raises the usage error only when stdin is a terminal. src/index.ts resolves the task, adopts the named session — or mints a fresh identity when the flag is absent — and wires either the stderr reasoning projection or the new src/json-stream.ts projection. cordis.patch.yml forwards the two new settings. package.json publishes the shared lib/json-stream-*.js chunk both entries import, so the installed tarball loads.

  • Default mode is unchanged: a text-only run writes one final assistant line to stdout and nothing to stderr, and exit status still follows the terminal reason.
  • --json stdout parses line by line as JSON, starts with session, ends with final, and contains no plain text. Stderr carries no reasoning in this mode.
  • A step that retries publishes text and thinking only for the attempt that commits, so a discarded attempt leaves no trace in the stream.
  • Two consecutive runs with the same --session-id share history, and a requested id with no stored log exits 1 before the task runs. A run whose cwd differs from the persisted session, that recorded no cwd, that is a subagent or forked session, that runs under an agent preset, that carries a malformed preset record, or whose identity is already live in the process, exits 1 with a diagnostic.
  • A piped task with no positional task is honored, a whitespace-only positional is rejected instead of consuming the pipe, and an interactive invocation without a task still fails with the usage error.
  • Unit coverage lands in packages/bundle/headless/tests/startup.spec.ts, tests/headless.spec.ts, and tests/json-stream.spec.ts. The product headless profile expectation test in apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts covers both output modes end to end.

Deferred and open:

  • The per-run --model override is unimplemented. A later change must respect the session-local selection precedence owned by the Session Controller rather than overriding a stored selection.
  • Cold start plus log replay grows with session length, so a long-lived conversation pays more per wake than a fresh one.
  • --json moves reasoning from stderr to stdout, so a log collector that watches stderr sees nothing on a reasoned run in that mode.
  • Bounded tool_result payloads hide full output from the supervisor; the 8 KiB string/key cap and the 32 KiB line cap are owned by src/json-stream.ts and should stay constants, with the terminal final event the only exemption.

Alternatives considered

--verbose human text on stderr. A supervisor parses stdout, so a stderr-only projection is invisible to it. Default-mode stderr reasoning already is the human verbose surface.

Dump raw session events. They repeat the assembled message beside its deltas, echo user/message, and include internal events. Measured on one prompt, pi's delta stream produced 84 lines and 11.7 KB against opencode's 3 lines and 962 B, with roughly a quarter of pi's bytes spent repeating one message across message_end, turn_end, and agent_end.

A long-lived SDK process instead of one process per wake. The SDK already speaks structured events and an explicit session identity, but it replaces the one-process-per-wake model the supervisor is built on. Measured cold start for the headless profile is about 0.45 s warm and 1.2 s cold, small against a real turn.

Let the supervisor mint the session id. Identity belongs to the runtime that owns the log. The supervisor records what the first event reports.

Adopt-or-create --session-id. Creating the requested id when no log exists would let a mistyped or stale id silently open an empty history that the supervisor believes it is continuing; the first wake already omits the flag and reads the minted id from the session event, so no caller needs --session-id to create.

Task from argv only. Long prompts exceed ARG_MAX and expose the prompt in the process list.

中文

问题

dsh --profile headless 面向的是人类终端:任务只能通过 argv 传入,stdout 只输出最终一条助手消息,provider 的推理过程流式写到 stderr,而且每次运行都新建一个随机会话。Headless is a direct core entry point 拥有那套传输与完成契约;headless reasoning progress 拥有 stderr 投影。

一个"每次唤醒起一个 headless 进程"的监督进程(例如外部 agent 运行时)需要三样该契约没有提供的东西。它需要通过私有管道而不是 argv 传入任务,因为长提示词会超出参数上限,而 argv 对其他进程可见。它需要一条机器可读的流,把助手文本、推理、工具调用与结果、轮次边界和用量区分开,因为抓取 stderr 只能拿到推理,而 stdout 最后一行拿不到任何工具活动。它需要一个精确的会话身份,以便在下一次唤醒时传回去,因为每次进程都新建随机会话意味着无法连续。

决策

dsh-headless bundle 拥有一个可选的机器可读运行接口。默认调用保持原有契约不变:stdout 输出一条最终助手消息,推理走 stderr,当且仅当终端 turn/end 原因为 completed 时退出码为 0。

三项新增扩展 Apps own their command lines 确立的 app 自有命令行:

  • --json 把 stdout 负载换成逐行 JSON 运行事件。推理变成一条事件而不再写 stderr,因此该模式下 stderr 只承载 dsh: 诊断。
  • --session-id <id> 选定精确的会话身份:采用具有该 id 的持久化会话,不存在时就失败。不带该 flag 时,运行仍像以前一样生成 session-<uuid>。
  • 没有位置参数、或者位置参数为 - 时,任务文本改从 stdin 读取。

每次运行的 --model 覆盖被明确排除在范围之外;组合默认模型仍然权威。

产品改动限于 packages/bundle/headless:src/startup.ts、src/index.ts、新增的 src/json-stream.ts、包清单与 tsconfig.json,以及测试。围绕它,产品 profile 的期望测试位于 apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts,端到端覆盖两种输出模式,并给 packages/test-support/loader-smoke harness 增加了一个可选的调用方自有 cwd,让两次唤醒共享同一个世界;scripts/check-workspace-constraints.ts 与包清单负责发布两个入口共同引用的共享 chunk lib/json-stream-*.js,pnpm-lock.yaml 则记录新增的 @deepseek-ai/dsh-session-query workspace 链接。不修改任何 core session、持久化、session-controller、base 组合或 launcher 文件。

命令行契约

dsh --profile headless [--json] [--session-id <id>] [<task>... | -]

任务解析顺序:拼接后的位置参数,其次是 -,其次是管道 stdin。只有空白的位置参数本身就属于用法错误,即使 stdin 不是终端也一样,因此误传的空白参数绝不会消费管道内容;完全缺失任务时,仅在 stdin 是终端时属于用法错误。单独的 - 是唯一的 stdin 标记:把它与其他任务词混用属于用法错误,而不是以连字符开头的任务。管道任务会原样发送,包括结尾换行。在 --json 模式下,所有用法错误——包括 commander 自身的语法拒绝,例如未知选项或选项缺少取值——都会在进程退出前写出 error 事件,因为 runner 从未挂载来写它;事件 message 省略 commander 的 error: 前缀,使同一事件类型只承载一种消息形态。

--json 只改变 stdout 负载和推理投影的去向。退出码、关闭顺序、会话 flush 和持久化会话日志都不变,因此监督进程对一次运行的分类方式与现在完全一致。

事件流

--json 向 stdout 每行写一个 JSON 对象,不写其他内容。词汇表是会话事件日志的投影,而不是日志本身。

type字段发出时机
sessionsessionId、cwd第一行,先于任何模型输出
statusphase(turn_start、step_start、step_end、turn_end)、turn、step、usage、reason每个边界一条
texttext一个已提交的助手文本块
thinkingtext一个已提交的推理块
tool_callcallId、tool、input每次调用一条
tool_resultcallId、status、result每次追加的结果一条
errormessagerunner 在轮次之外抛出的失败
finaltext最后一行

投影规则:

  • 文本与推理只从已提交的 assistant/message 投影,绝不来自实时的 attempt 增量。被重试或丢弃的 attempt 会追加 assistant/attempt,投影直接忽略,因此事件流永远不会承载持久化日志中不存在的内容(只在提交点发布状态)。
  • 每个已提交的内容块按内容顺序变成恰好一条 text 或 thinking 事件;tool-call 块不投影,因为 tool/call 事件已经拥有它。user/message 回显和内部会话事件(标题、模型选择、投影、检查点、目标、子 agent)都不投影。
  • tool/result 仅在其 surfaceOp 为 append 时投影。压缩对旧结果的替换属于历史,投影它会产生没有对应 tool_call 的 call id。
  • 每个被投影的字符串与对象键都限制在 8 KiB;被截断的事件带 truncated: true,单条序列化事件行(含换行)限制在 32 KiB——超长事件保留标量字段、丢弃结构化字段,极端情况下只剩 type 与 truncated,嵌套达到 64 层及以上的负载会在该深度被截断,因此任何合法输入都不会让限界递归溢出。进程级 error 事件同样受限;字面量 __proto__ 参数键会作为数据复制,而不经过继承的 setter;空工具参数字符串会投影为 {},与执行器保持一致;JSON 无法往返的参数(例如溢出为 Infinity 的 1e400)保留原始文本,而不是 JSON.stringify 会报告的 null。终止 final 事件刻意不做限长:它承载与默认模式相同的无损答案。
  • 文本与推理在步骤提交时到达,而不是逐 token 到达;默认模式的 stderr 推理仍是唯一的实时文本通道。轮次内失败的运行仍以 final 结束且没有 error 事件,因此即使事件流格式良好,监督进程也要用退出码与 turn_end 原因来分类该次运行。
  • usage 仅在该步每一次 attempt 都上报了样本时出现在 step_end 上,并累计这些样本——包括仅在被丢弃的 assistant/attempt 流中留下用量样本的重试——因此部分汇总不会被当作精确总量发布。
  • 原始会话事件不在范围内。调试用的逃生口可以以后再加,不必改动这套词汇表。

会话身份

身份由运行时拥有。不带 --session-id 的运行生成 session-<uuid>,并在第一条事件里报告它。监督进程保存该值,并在下一次唤醒时传回。

--session-id <id> 是只采用:先观察持久化会话并 resume,日志不存在时失败。首轮不传该 flag,由运行时生成身份并在 session 事件里报告;后续每一轮都用该值指名并续接历史。请求的 id 没有持久化日志时报错,而不是开一段新会话,因此写错或过期的 id 不会静默开出一段调用方自以为在续接的空历史,JSONL 存储拒绝已存在日志 id 的问题(见 session persistence)也不会出现在这条路径上。标识是不透明的,因此 runner 只在 trim 后的值上校验非空,并把调用方的原始字符串(含空白字符)原样传下去。

采用时会比较持久化会话记录的 cwd 与进程 cwd,因为会话按项目目录组织(见 project session directories)。不一致时以 dsh: 诊断退出 1,而不是静默续接一个根目录在别处的会话;未记录 cwd 的会话出于同样理由被拒绝。运行在 agent preset 下的会话被拒绝,因为本 bundle 不组合任何 preset roster:在这里 resume 它,会用 headless 的工具与提示词运行它,而不是它日志当前记录的组合。该检查读取日志当前记录的 preset——创建 header 再叠加任何 agent-preset/selected 事件——因为空白会话可能在创建后切换 preset,而 header 始终只是创建事实;畸形的选择记录会失败关闭,而不会读成「无 preset」。带父会话或子 agent 关联的会话——包括用户 fork 出的会话——被拒绝。本进程已存在持有请求 id 的存活 Agent 时直接拒绝:它的 owner 可能仍在驱动它,而 whenIdle 不是单条消息的完成信号,runner 无法对它取得独占区间。resume 后的日志会在 runner 等待 idle 后再次检查,因此该窗口内选中的 preset 仍会被拒绝。纯空白的 sessionId 在 CLI 与直接配置两条路径上都会被拒绝。两个存活进程不能写同一个 id;存储的写租约已经会拒绝第二个写入者。runner 通过已组合的 sessionQuery 服务读取观察结果,并在请求 --session-id 却没有该服务时显式失败——观察结果正是它找到待 resume id 的途径;若所请求的身份缺少让它持久化的 sessionPersistence 服务,同样显式失败。

后果

实际落地:src/startup.ts 解析 --json 与 --session-id <id>,把缺失或为 - 的任务视为"从 stdin 读取",并且只在 stdin 是终端时抛出用法错误。src/index.ts 解析任务、采用指名的会话——未传 flag 时生成新身份——并接上 stderr 推理投影或新的 src/json-stream.ts 投影。cordis.patch.yml 转发这两个新设置。package.json 发布两个入口共同引用的共享 chunk lib/json-stream-*.js,因此安装后的 tarball 可以加载。

  • 默认模式不变:纯文本运行向 stdout 写一行最终助手消息、stderr 无输出,退出码仍跟随终端原因。
  • --json 的 stdout 逐行可解析为 JSON,以 session 开头、以 final 结尾,不含纯文本。该模式下 stderr 不承载推理。
  • 发生重试的步骤只为最终提交的 attempt 发布 text 与 thinking,因此被丢弃的 attempt 不会在事件流中留下任何痕迹。
  • 两次连续的相同 --session-id 运行共享历史,而请求一个没有持久化日志的 id 会在任务运行前退出 1。cwd 不一致、未记录 cwd、属于子 agent 或 fork 会话、运行在 agent preset 下、preset 记录畸形,或本进程已存在同 id 存活 Agent 的运行都以诊断退出 1。
  • 无位置参数但 stdin 有管道输入时任务被采纳,只有空白的位置参数会被拒绝而不会消费管道,交互式无任务调用仍以用法错误失败。
  • 单元覆盖落在 packages/bundle/headless/tests/startup.spec.ts、tests/headless.spec.ts 与 tests/json-stream.spec.ts。apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts 的产品 headless profile 期望测试端到端覆盖两种输出模式。

延期与未决:

  • 每次运行的 --model 覆盖尚未实现。后续改动必须尊重 Session Controller 拥有的会话局部选择优先级,而不是覆盖已保存的选择。
  • 冷启动加上日志重放会随会话变长而增长,因此长会话每次唤醒的代价高于新会话。
  • --json 把推理从 stderr 移到 stdout,因此只监听 stderr 的日志收集器在该模式的有推理运行上什么都看不到。
  • 有界的 tool_result 负载会让监督进程看不到完整输出;8 KiB 字符串/键上限与 32 KiB 行上限由 src/json-stream.ts 拥有,应保持为常量,终止 final 事件是唯一的例外。

备选方案

--verbose 人类可读文本写到 stderr。 监督进程解析的是 stdout,只落在 stderr 的投影对它不可见。默认模式的 stderr 推理已经是人类可读的 verbose 面。

直接倾倒原始会话事件。 它们在增量之外重复整条已组装消息,回显 user/message,还夹带内部事件。在同一条提示词上实测,pi 的增量流产生 84 行、11.7 KB,而 opencode 是 3 行、962 B;pi 大约四分之一的字节花在把同一条消息在 message_end、turn_end、agent_end 里重复三遍。

用长驻 SDK 进程代替每次唤醒一个进程。 SDK 已经有结构化事件和显式的会话身份语义,但它会替换掉监督进程所依赖的"一次唤醒一个进程"模型。实测 headless profile 的冷启动约为热态 0.45 s、冷态 1.2 s,相对一个真实轮次很小。

让监督进程生成会话 id。 身份属于拥有日志的运行时。监督进程记录第一条事件报告的值即可。

采用或创建语义的 --session-id。 在日志不存在时创建所请求的 id,会让写错或过期的 id 静默开出一段监督进程自以为在续接的空历史;首轮本就不传该 flag 并从 session 事件读到生成的 id,因此没有任何调用方需要用 --session-id 来创建。

任务只走 argv。 长提示词会超出 ARG_MAX,并且把提示词暴露在进程列表里。