Преглед изворни кода

fix(headless): address re-review findings on the machine-readable run surface

- Reject adopting a Session recorded under an agent preset, and one that
  recorded no cwd; align the subagent/forked diagnostic wording.
- Write the bounded JSON `error` event for a --json usage error before exit,
  since the runner never mounts to write it.
- Keep the caller's exact --session-id instead of the trimmed value.
- Bound object keys and copy a literal `__proto__` key as data rather than
  through the inherited setter.
- Reject a lone `-` mixed with other task words.
- Document the in-turn failure contract and step-commit text/thinking timing.
lsdsjy пре 2 недеља
родитељ
комит
45032ea1bd

+ 2 - 2
.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md
-2026-09-09-headless-machine-readable-run-surface.md: c55f0b96313cfe792e845396b7049249f4ca43ee
-2026-09-09-headless-machine-readable-run-surface.zh.md: 7826fe0435acba74e7156bdd2a3abd9b0372cf7f
+2026-09-09-headless-machine-readable-run-surface.md: f4fd89b3ef0ae8528574517bb740396bfadec981
+2026-09-09-headless-machine-readable-run-surface.zh.md: 25a41b7fa3d2d46b80e01ca33809e9436db66212

+ 6 - 5
.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md

@@ -30,7 +30,7 @@ The change is confined to `packages/bundle/headless`: `src/startup.ts`, `src/ind
 dsh --profile headless [--json] [--session-id <id>] [<task>... | -]
 ```
 
-Task resolution order: joined positionals, then `-`, then piped stdin. A terminal stdin with no positional task remains a usage error, so an interactive invocation cannot hang waiting for input.
+Task resolution order: joined positionals, then `-`, then piped stdin. A terminal stdin with no positional task remains a usage error, so an interactive invocation cannot hang waiting for input. 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. In `--json` mode a usage error writes the `error` event before the process exits, because the runner never mounts to write it.
 
 `--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.
 
@@ -54,7 +54,8 @@ 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](../../../../packages/AGENTS.md)).
 - 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 is bounded at 8 KiB, and an event with a cut string carries `truncated: true`, including the process-level `error` event. The terminal `final` event is deliberately unbounded: it carries the same lossless answer the default mode prints.
+- Every projected string and object key is bounded at 8 KiB, and an event with a cut value carries `truncated: true`, including the process-level `error` event; a literal `__proto__` argument key is copied as data rather than through the inherited setter. 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`, matching the token accounting a provider reports per step.
 - Raw session events stay out of scope. A debug escape hatch can be added later without changing this vocabulary.
 
@@ -62,9 +63,9 @@ Projection rules:
 
 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-or-create: observe the persisted session, resume it when it exists, create it otherwise. Create-only would fail the second run, because the JSONL store rejects an existing log id ([session persistence](../../implemented/architecture/2026-06-14-session-persistence.md)).
+`--session-id <id>` is adopt-or-create: observe the persisted session, resume it when it exists, create it otherwise. Create-only would fail the second run, because the JSONL store rejects an existing log id ([session persistence](../../implemented/architecture/2026-06-14-session-persistence.md)). 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](../../implemented/architecture/2026-07-24-project-session-directories.md)). A mismatch exits 1 with a `dsh:` diagnostic instead of silently continuing a conversation rooted elsewhere. A session linked to a parent or subagent is rejected. The same two checks run when a live Agent already holds the requested id, so a live identity cannot bypass them. 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.
+Adoption compares the persisted session's recorded cwd with the process cwd, since sessions are organized per project directory ([project session directories](../../implemented/architecture/2026-07-24-project-session-directories.md)). 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 created 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 was recorded under. A session linked to a parent or subagent — including a user fork — is rejected. All checks run when a live Agent already holds the requested id, so a live identity cannot bypass them. 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.
 
 ## Consequences
 
@@ -73,7 +74,7 @@ What landed: `src/startup.ts` parses `--json` and `--session-id <id>`, treats an
 - 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. A run whose cwd differs from the persisted session exits 1 with a diagnostic, whether the identity is live or persisted.
+- Two consecutive runs with the same `--session-id` share history. A run whose cwd differs from the persisted session, that recorded no cwd, that is a subagent or forked session, or that was created under an agent preset, exits 1 with a diagnostic, whether the identity is live or persisted.
 - A piped task with no positional task is honored, 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.
 

+ 6 - 5
.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md

@@ -30,7 +30,7 @@ Status: implemented
 dsh --profile headless [--json] [--session-id <id>] [<task>... | -]
 ```
 
-任务解析顺序:拼接后的位置参数,其次是 `-`,其次是管道 stdin。终端 stdin 且无位置参数仍是用法错误,因此交互式调用不会挂起等待输入。
+任务解析顺序:拼接后的位置参数,其次是 `-`,其次是管道 stdin。终端 stdin 且无位置参数仍是用法错误,因此交互式调用不会挂起等待输入。单独的 `-` 是唯一的 stdin 标记:把它与其他任务词混用属于用法错误,而不是以连字符开头的任务。在 `--json` 模式下,用法错误会在进程退出前写出 `error` 事件,因为 runner 从未挂载来写它。
 
 `--json` 只改变 stdout 负载和推理投影的去向。退出码、关闭顺序、会话 flush 和持久化会话日志都不变,因此监督进程对一次运行的分类方式与现在完全一致。
 
@@ -54,7 +54,8 @@ dsh --profile headless [--json] [--session-id <id>] [<task>... | -]
 - 文本与推理只从已提交的 `assistant/message` 投影,绝不来自实时的 attempt 增量。被重试或丢弃的 attempt 会追加 `assistant/attempt`,投影直接忽略,因此事件流永远不会承载持久化日志中不存在的内容([只在提交点发布状态](../../../../packages/AGENTS.md))。
 - 每个已提交的内容块按内容顺序变成恰好一条 `text` 或 `thinking` 事件;`tool-call` 块不投影,因为 `tool/call` 事件已经拥有它。`user/message` 回显和内部会话事件(标题、模型选择、投影、检查点、目标、子 agent)都不投影。
 - `tool/result` 仅在其 `surfaceOp` 为 `append` 时投影。压缩对旧结果的替换属于历史,投影它会产生没有对应 `tool_call` 的 call id。
-- 每个被投影的字符串都限制在 8 KiB;被截断的事件带 `truncated: true`,进程级 `error` 事件同样如此。终止 `final` 事件刻意不做限长:它承载与默认模式相同的无损答案。
+- 每个被投影的字符串与对象键都限制在 8 KiB;被截断的事件带 `truncated: true`,进程级 `error` 事件同样如此;字面量 `__proto__` 参数键会作为数据复制,而不经过继承的 setter。终止 `final` 事件刻意不做限长:它承载与默认模式相同的无损答案。
+- 文本与推理在步骤提交时到达,而不是逐 token 到达;默认模式的 stderr 推理仍是唯一的实时文本通道。轮次内失败的运行仍以 `final` 结束且没有 `error` 事件,因此即使事件流格式良好,监督进程也要用退出码与 `turn_end` 原因来分类该次运行。
 - `usage` 出现在 `step_end` 上,对应 provider 每步上报的 token 计量。
 - 原始会话事件不在范围内。调试用的逃生口可以以后再加,不必改动这套词汇表。
 
@@ -62,9 +63,9 @@ dsh --profile headless [--json] [--session-id <id>] [<task>... | -]
 
 身份由运行时拥有。不带 `--session-id` 的运行生成 `session-<uuid>`,并在第一条事件里报告它。监督进程保存该值,并在下一次唤醒时传回。
 
-`--session-id <id>` 是采用或创建:先观察持久化会话,存在就 resume,不存在就 create。只创建会让第二次运行失败,因为 JSONL 存储拒绝已存在的日志 id(见 [session persistence](../../implemented/architecture/2026-06-14-session-persistence.zh.md))。
+`--session-id <id>` 是采用或创建:先观察持久化会话,存在就 resume,不存在就 create。只创建会让第二次运行失败,因为 JSONL 存储拒绝已存在的日志 id(见 [session persistence](../../implemented/architecture/2026-06-14-session-persistence.zh.md))。标识是不透明的,因此 runner 只在 trim 后的值上校验非空,并把调用方的原始字符串(含空白字符)原样传下去。
 
-采用时会比较持久化会话记录的 cwd 与进程 cwd,因为会话按项目目录组织(见 [project session directories](../../implemented/architecture/2026-07-24-project-session-directories.zh.md))。不一致时以 `dsh:` 诊断退出 1,而不是静默续接一个根目录在别处的会话。带父会话或子 agent 关联的会话被拒绝。当某个存活 Agent 已经持有请求的 id 时,同样执行这两项检查,因此存活身份无法绕过它们。两个存活进程不能写同一个 id;存储的写租约已经会拒绝第二个写入者。runner 通过已组合的 `sessionQuery` 服务读取观察结果,并在请求 `--session-id` 却没有该服务时显式失败。
+采用时会比较持久化会话记录的 cwd 与进程 cwd,因为会话按项目目录组织(见 [project session directories](../../implemented/architecture/2026-07-24-project-session-directories.zh.md))。不一致时以 `dsh:` 诊断退出 1,而不是静默续接一个根目录在别处的会话;未记录 cwd 的会话出于同样理由被拒绝。由 agent preset 创建的会话被拒绝,因为本 bundle 不组合任何 preset roster:在这里 resume 它,会用 headless 的工具与提示词运行它,而不是它日志记录时所用的组合。带父会话或子 agent 关联的会话——包括用户 fork 出的会话——被拒绝。当某个存活 Agent 已经持有请求的 id 时,上述检查全部执行,因此存活身份无法绕过它们。两个存活进程不能写同一个 id;存储的写租约已经会拒绝第二个写入者。runner 通过已组合的 `sessionQuery` 服务读取观察结果,并在请求 `--session-id` 却没有该服务时显式失败。
 
 ## 后果
 
@@ -73,7 +74,7 @@ dsh --profile headless [--json] [--session-id <id>] [<task>... | -]
 - 默认模式不变:纯文本运行向 stdout 写一行最终助手消息、stderr 无输出,退出码仍跟随终端原因。
 - `--json` 的 stdout 逐行可解析为 JSON,以 `session` 开头、以 `final` 结尾,不含纯文本。该模式下 stderr 不承载推理。
 - 发生重试的步骤只为最终提交的 attempt 发布 `text` 与 `thinking`,因此被丢弃的 attempt 不会在事件流中留下任何痕迹。
-- 两次连续的相同 `--session-id` 运行共享历史。cwd 与持久化会话不一致的运行以诊断退出 1,无论身份是存活还是持久化的。
+- 两次连续的相同 `--session-id` 运行共享历史。cwd 不一致、未记录 cwd、属于子 agent 或 fork 会话,或由 agent preset 创建的运行都以诊断退出 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 期望测试端到端覆盖两种输出模式。
 

+ 2 - 2
packages/bundle/headless/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/bundle/headless/README.md
-README.md: 484d975135dcf494fb05bdc41b5413596b57b40f
-README.zh.md: 1206e793923826810f5cc9cc812cb47fbc979130
+README.md: b79433c3a5b95224b1c18aec65cb4eafa1e5dc15
+README.zh.md: a822106be6e1dcc972c048fa76c0a22694d4682d

+ 4 - 4
packages/bundle/headless/README.md

@@ -51,11 +51,11 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
 
 ### Choosing the session identity
 
-Every invocation defaults to a fresh `session-<uuid>` identity. Pass `--session-id <id>` to name it yourself: the runner adopts the persisted Session with that id when one exists, and creates it otherwise. Adoption is scoped to the current working directory and refuses a Session owned by a subagent, so a supervisor cannot silently drive someone else's conversation; either mismatch fails before the task runs.
+Every invocation defaults to a fresh `session-<uuid>` identity. Pass `--session-id <id>` to name it yourself: the runner adopts the persisted Session with that id when one exists, and creates it otherwise. The identity is opaque, so the exact string is used, whitespace included. Adoption is scoped to the current working directory and refuses a Session that is a subagent or forked session, that recorded no working directory, or that was created under an agent preset this profile does not compose, so a supervisor cannot silently drive someone else's conversation under a different composition; any mismatch fails before the task runs.
 
 ### Machine-readable output
 
-`--json` replaces the final-text stdout line with a newline-delimited JSON event stream, while stderr keeps only the `dsh:` diagnostics. The stream opens with `session` (carrying the identity the run used) and closes with `final`, and carries `status`, `text`, `thinking`, `tool_call`, and `tool_result` events in between. `text` and `thinking` are projected from committed assistant messages, so a retried or discarded attempt never reaches the stream. The terminal `final` event carries the same lossless answer as the default mode and is not capped; every other string is capped at 8 KiB and flagged with `truncated`. A process-level failure outside a turn writes an `error` event and ends the stream without `final`, in addition to the `dsh:` stderr line.
+`--json` replaces the final-text stdout line with a newline-delimited JSON event stream, while stderr keeps only the `dsh:` diagnostics. The stream opens with `session` (carrying the identity the run used) and closes with `final`, and carries `status`, `text`, `thinking`, `tool_call`, and `tool_result` events in between. `text` and `thinking` are projected from committed assistant messages, so a retried or discarded attempt never reaches the stream; they arrive when the step commits, not per token, and default-mode stderr reasoning remains the only live text channel. The terminal `final` event carries the same lossless answer as the default mode and is not capped; every other string and object key is capped at 8 KiB and flagged with `truncated`. A process-level failure outside a turn writes an `error` event and ends the stream without `final`, in addition to the `dsh:` stderr line. A turn that fails in-turn still ends with a `final` event (often empty) and no `error` event, so a well-formed stream can still describe a failed run: treat exit code 1 and the `turn_end` reason as the failure signal.
 
 ### When to use it
 
@@ -63,7 +63,7 @@ Use headless for scripted or automated dsh runs — CI steps, batch jobs, quick
 
 ### Help and task errors
 
-`dsh --profile headless --help` prints the command's help text and exits without running anything. A missing or whitespace-only task is a usage error when stdin is a terminal: nothing runs and the process exits 1. When stdin is not a terminal the runner reads the task from it instead and rejects an empty result the same way.
+`dsh --profile headless --help` prints the command's help text and exits without running anything. A missing or whitespace-only task is a usage error when stdin is a terminal: nothing runs and the process exits 1. When stdin is not a terminal the runner reads the task from it instead and rejects an empty result the same way. A lone `-` is the only stdin marker; mixing it with other task words is a usage error rather than a task that starts with a dash. In `--json` mode a usage error also writes an `error` event to stdout before the process exits, so a line-oriented supervisor sees a well-formed stream even when the runner never mounts.
 
 -----
 
@@ -142,7 +142,7 @@ These limits tell you when headless does not fit and what it needs from the `dsh
 - **No pre-token heartbeat** — in default mode stderr stays silent until the provider emits a non-empty reasoning delta; a delayed first token exposes no earlier progress signal.
 - **Reasoning enters stderr logs** — in default mode, redirection and supervisors may retain substantially more and potentially sensitive model output; route stderr to a controlled sink when needed.
 - **Default stdout carries only the final answer** — a run without an assistant message prints an empty stdout line and exits 1; intermediate tool output is not printed unless you opt into `--json`.
-- **Adoption is cwd- and ownership-scoped** — `--session-id` refuses a Session recorded in another working directory or owned by a subagent, and requires the composed Session query service.
+- **Adoption is cwd-, ownership-, and preset-scoped** — `--session-id` refuses a Session recorded in another working directory, one that recorded no working directory, one that is a subagent or forked session, or one created under an agent preset this profile does not compose, and requires the composed Session query service.
 - **The event stream is a projection, not the log** — `--json` caps every string except the terminal `final` at 8 KiB and omits events the projection does not model, so it is not a lossless copy of the Session log.
 
 <a id="dev-note"></a>

+ 5 - 5
packages/bundle/headless/README.zh.md

@@ -51,11 +51,11 @@ agent(智能体)会完成该任务,把提供方的每个非空推理增量
 
 ### 选择 Session 标识
 
-每次调用默认使用全新的 `session-<uuid>` 标识。传入 `--session-id <id>` 可自行命名:该 id 对应的持久化 Session 存在时 runner 会沿用,否则创建。沿用被限定在当前工作目录内,并拒绝由 subagent 拥有的 Session,因此监督进程无法悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。
+每次调用默认使用全新的 `session-<uuid>` 标识。传入 `--session-id <id>` 可自行命名:该 id 对应的持久化 Session 存在时 runner 会沿用,否则创建。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话,以及由本 profile 不组合的 agent preset 创建的会话,因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。
 
 ### 机器可读输出
 
-`--json` 用按行 JSON 事件流取代 stdout 的最终文本行,stderr 仅保留 `dsh:` 诊断信息。事件流以 `session`(携带本次运行使用的标识)开头、以 `final` 结尾,其间为 `status`、`text`、`thinking`、`tool_call` 与 `tool_result` 事件。`text` 与 `thinking` 只从已提交的 assistant 消息投影,因此被重试或丢弃的尝试不会进入事件流。终止 `final` 事件携带与默认模式相同的无损答案,不做限长;其他每个字符串上限为 8 KiB,超出时标记 `truncated`。轮次之外的进程级失败会写出 `error` 事件并在没有 `final` 的情况下结束事件流,同时向 stderr 写入 `dsh:` 行。
+`--json` 用按行 JSON 事件流取代 stdout 的最终文本行,stderr 仅保留 `dsh:` 诊断信息。事件流以 `session`(携带本次运行使用的标识)开头、以 `final` 结尾,其间为 `status`、`text`、`thinking`、`tool_call` 与 `tool_result` 事件。`text` 与 `thinking` 只从已提交的 assistant 消息投影,因此被重试或丢弃的尝试不会进入事件流;它们在步骤提交时到达,而不是逐 token 到达,默认模式的 stderr 推理仍是唯一的实时文本通道。终止 `final` 事件携带与默认模式相同的无损答案,不做限长;其他每个字符串与对象键上限为 8 KiB,超出时标记 `truncated`。轮次之外的进程级失败会写出 `error` 事件并在没有 `final` 的情况下结束事件流,同时向 stderr 写入 `dsh:` 行。轮次内失败的运行仍会以 `final` 事件(通常为空)结束且没有 `error` 事件,因此格式良好的事件流也可能描述一次失败的运行:请把退出码 1 与 `turn_end` 原因作为失败信号。
 
 ### 何时使用
 
@@ -63,7 +63,7 @@ agent(智能体)会完成该任务,把提供方的每个非空推理增量
 
 ### 帮助与任务错误
 
-`dsh --profile headless --help` 打印该命令的帮助文本并直接退出,不运行任何内容。stdin 是终端时,缺失或只有空白的任务属于用法错误:什么都不运行,进程退出 1。stdin 不是终端时,runner 改从 stdin 读取任务,并以同样方式拒绝空结果。
+`dsh --profile headless --help` 打印该命令的帮助文本并直接退出,不运行任何内容。stdin 是终端时,缺失或只有空白的任务属于用法错误:什么都不运行,进程退出 1。stdin 不是终端时,runner 改从 stdin 读取任务,并以同样方式拒绝空结果。单独的 `-` 是唯一的 stdin 标记;把它与其他任务词混用属于用法错误,而不是以连字符开头的任务。在 `--json` 模式下,用法错误还会在进程退出前向 stdout 写出 `error` 事件,因此按行解析的监督进程即使 runner 从未挂载也能看到格式良好的事件流。
 
 -----
 
@@ -142,8 +142,8 @@ runner 不向请求前缀添加任何内容;它只是把一条用户消息驱
 - **首个 token 前没有心跳**——默认模式下,提供方发出第一个非空推理增量前 stderr 保持静默;延迟首个 token 的提供方不会更早给出进度信号。
 - **推理进入 stderr 日志**——默认模式下,重定向与监督进程可能保留更多且可能敏感的模型输出;需要时应把 stderr 路由到受控位置。
 - **默认 stdout 只承载最终答案**——没有 assistant 消息的运行向 stdout 打印空行并以 1 退出;中间工具输出不会打印,除非显式启用 `--json`。
-- **沿用受 cwd 与归属限制**——`--session-id` 会拒绝记录在其他工作目录或由 subagent 拥有的 Session,并要求已组合的 Session 查询服务。
-- **事件流是投影而非日志**——`--json` 除终止 `final` 外把每个字符串限制在 8 KiB,并省略投影未建模的事件,因此它不是 Session 日志的无损副本。
+- **沿用受 cwd、归属与 preset 限制**——`--session-id` 会拒绝记录在其他工作目录、未记录工作目录、属于子 agent 或 fork 会话,或由本 profile 不组合的 agent preset 创建的 Session,并要求已组合的 Session 查询服务。
+- **事件流是投影而非日志**——`--json` 除终止 `final` 外把每个字符串与对象键限制在 8 KiB,并省略投影未建模的事件,因此它不是 Session 日志的无损副本。
 
 <a id="dev-note"></a>
 ### 开发备注

+ 14 - 1
packages/bundle/headless/src/index.ts

@@ -180,12 +180,25 @@ interface AdoptableHeader {
   cwd?: string | undefined
   origin?: 'subagent' | undefined
   parentSession?: SessionId | undefined
+  agentPreset?: string | undefined
 }
 
 /** Reject a Session the one-shot runner must not adopt. */
 function assertAdoptable(header: AdoptableHeader, sessionId: SessionId): void {
+  if (header.agentPreset !== undefined) {
+    // This bundle composes no preset roster, so resuming the session here would
+    // silently run it under the headless tools and prompts instead of the
+    // composition the log was recorded under.
+    throw new Error(
+      `session "${sessionId}" was created under agent preset "${header.agentPreset}", `
+      + 'which the one-shot runner does not compose',
+    )
+  }
   if (header.origin === 'subagent' || header.parentSession !== undefined) {
-    throw new Error(`session "${sessionId}" belongs to a subagent and cannot be driven directly`)
+    throw new Error(`session "${sessionId}" is a subagent or forked session and cannot be driven directly`)
+  }
+  if (header.cwd === undefined) {
+    throw new Error(`session "${sessionId}" recorded no working directory, so it cannot be adopted`)
   }
   if (header.cwd !== process.cwd()) {
     throw new Error(`session "${sessionId}" was recorded in "${header.cwd}", not "${process.cwd()}"`)

+ 22 - 9
packages/bundle/headless/src/json-stream.ts

@@ -11,7 +11,7 @@ import type { Context } from '@deepseek-ai/cordis'
 import type { Agent } from '@deepseek-ai/dsh-agent'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 
-/** Default per-string cap applied to every bounded projected payload. */
+/** Default per-string and per-key cap applied to every bounded projected payload. */
 export const MAX_STRING_BYTES = 8 * 1024
 
 /** The stdout sink a projection writes newline-delimited events to. */
@@ -24,7 +24,7 @@ export interface JsonSink {
 export interface JsonProjectionOptions {
   /** Working directory reported by the opening `session` event. */
   cwd?: string
-  /** Per-string byte cap; longer strings are truncated and flagged. */
+  /** Per-string and per-key byte cap; longer values are truncated and flagged. */
   maxStringBytes?: number
 }
 
@@ -48,7 +48,14 @@ function truncateUtf8(text: string, maxBytes: number): string {
   return decoded.endsWith('\uFFFD') ? decoded.slice(0, -1) : decoded
 }
 
-/** Recursively cap every string in one JSON-serializable value. */
+/** Cap one object key, flagging the payload when it was cut. */
+function boundKey(key: string, maxBytes: number, state: BoundState): string {
+  if (Buffer.byteLength(key, 'utf8') <= maxBytes) return key
+  state.truncated = true
+  return truncateUtf8(key, maxBytes)
+}
+
+/** Recursively cap every string in one JSON-serializable value, keys included. */
 function boundValue(value: unknown, maxBytes: number, state: BoundState): unknown {
   if (typeof value === 'string') {
     if (Buffer.byteLength(value, 'utf8') <= maxBytes) return value
@@ -57,20 +64,26 @@ function boundValue(value: unknown, maxBytes: number, state: BoundState): unknow
   }
   if (Array.isArray(value)) return value.map(item => boundValue(item, maxBytes, state))
   if (value !== null && typeof value === 'object') {
-    const bounded: Record<string, unknown> = {}
-    for (const [key, item] of Object.entries(value)) bounded[key] = boundValue(item, maxBytes, state)
+    // A null prototype keeps a literal `__proto__` key as data instead of
+    // invoking the inherited setter, which would silently drop it. Two keys
+    // that share a truncated prefix collide last-wins; only a payload naming
+    // two multi-kilobyte keys can reach that.
+    const bounded = Object.create(null) as Record<string, unknown>
+    for (const [key, item] of Object.entries(value)) {
+      bounded[boundKey(key, maxBytes, state)] = boundValue(item, maxBytes, state)
+    }
     return bounded
   }
   return value
 }
 
 /**
- * Bound every string in one projected payload, adding `truncated: true` when
- * any string was cut. Exported so the runner applies the same limit to its
+ * Bound every string and key in one projected payload, adding `truncated: true`
+ * when any was cut. Exported so the runner applies the same limit to its
  * process-level `error` event.
  * @param event - the event payload to bound.
- * @param maxStringBytes - per-string byte cap.
- * @returns a copy with every over-long string truncated.
+ * @param maxStringBytes - per-string and per-key byte cap.
+ * @returns a copy with every over-long string and key truncated.
  */
 export function boundJsonEvent(
   event: Record<string, unknown>,

+ 25 - 7
packages/bundle/headless/src/startup.ts

@@ -9,6 +9,7 @@
 import { Command } from 'commander'
 import type { Context } from '@deepseek-ai/cordis'
 import { parseCmdline } from '@deepseek-ai/dsh-cmdline'
+import { boundJsonEvent } from './json-stream.ts'
 
 /** Stable Cordis plugin name. */
 export const name = 'headless-startup'
@@ -30,8 +31,12 @@ export interface HeadlessStartupValues {
 }
 
 /** Process facts the provider reads; tests substitute them. */
-export const internals: { stdinIsTty: () => boolean } = {
+export const internals: {
+  stdinIsTty: () => boolean
+  stdout: { write(chunk: string): unknown }
+} = {
   stdinIsTty: () => process.stdin.isTTY,
+  stdout: process.stdout,
 }
 
 /**
@@ -64,20 +69,33 @@ Examples:
 export function apply(ctx: Context): void {
   const program = headlessCommand()
   program.action(() => {
+    const options = program.opts<{ json?: boolean; sessionId?: string }>()
+    const json = options.json === true
+    // A usage error is still a process-level failure outside a turn, so a
+    // --json caller is owed the documented error event even though the runner
+    // never mounts to write it.
+    const reject = (message: string): never => {
+      if (json) internals.stdout.write(`${JSON.stringify(boundJsonEvent({ type: 'error', message }))}\n`)
+      return program.error(message)
+    }
+    if (program.args.length > 1 && program.args.includes('-')) {
+      reject('error: `-` must be the only task argument')
+    }
     const joined = program.args.join(' ')
     const task = joined.trim() === '' ? undefined : joined
     if (task === undefined && internals.stdinIsTty()) {
-      program.error('error: a task is required, for example: dsh --profile headless "run the tests"')
+      reject('error: a task is required, for example: dsh --profile headless "run the tests"')
     }
-    const options = program.opts<{ json?: boolean; sessionId?: string }>()
-    const sessionId = options.sessionId?.trim()
-    if (options.sessionId !== undefined && sessionId === '') {
-      program.error('error: --session-id requires a non-empty session id')
+    // A SessionId is opaque, so whitespace is part of the identity: validate
+    // emptiness on the trimmed value but hand the runner the exact string.
+    const sessionId = options.sessionId
+    if (sessionId !== undefined && sessionId.trim() === '') {
+      reject('error: --session-id requires a non-empty session id')
     }
     ctx.provide(HEADLESS_STARTUP_SERVICE, {
       task,
       sessionId,
-      json: options.json === true,
+      json,
     } satisfies HeadlessStartupValues)
   })
   parseCmdline(ctx, program)

+ 44 - 5
packages/bundle/headless/tests/headless.spec.ts

@@ -31,7 +31,7 @@ interface Script {
 
 /** Observation stub returned by the `--session-id` query path. */
 interface ObservationStub {
-  header: { cwd: string; origin?: string; parentSession?: string }
+  header: { cwd?: string; origin?: string; parentSession?: string; agentPreset?: string }
   [Symbol.dispose](): void
 }
 
@@ -46,7 +46,7 @@ interface BenchOptions {
   /** Register a live Agent under `sessionId` before the runner starts. */
   prelive?: boolean
   /** Header facts for that pre-registered live Agent. */
-  preliveMeta?: { cwd?: string; origin?: 'subagent' }
+  preliveMeta?: { cwd?: string; origin?: 'subagent'; agentPreset?: string }
 }
 
 const frameStates = new WeakMap<Agent, { attemptId: ReturnType<typeof LlmAttemptId>; revision: number; index: number }>()
@@ -531,6 +531,34 @@ describe('headless runner', () => {
     await test.ctx.fiber.dispose()
   })
 
+  it('rejects a persisted Session created under an agent preset', async () => {
+    const test = await bench({ afterPrompt: () => {} }, {
+      sessionId: 'session-exact',
+      observe: () => Promise.resolve({
+        header: { cwd: process.cwd(), agentPreset: 'minimal' },
+        [Symbol.dispose]() {},
+      }),
+    })
+    const result = await test.run()
+    expect(result.code).toBe(1)
+    expect(result.err).toContain('created under agent preset "minimal"')
+    await test.ctx.fiber.dispose()
+  })
+
+  it('rejects a persisted Session that recorded no working directory', async () => {
+    const test = await bench({ afterPrompt: () => {} }, {
+      sessionId: 'session-exact',
+      observe: () => Promise.resolve({
+        header: {},
+        [Symbol.dispose]() {},
+      }),
+    })
+    const result = await test.run()
+    expect(result.code).toBe(1)
+    expect(result.err).toContain('recorded no working directory')
+    await test.ctx.fiber.dispose()
+  })
+
   it('rejects a persisted Session owned by a subagent', async () => {
     const test = await bench({ afterPrompt: () => {} }, {
       sessionId: 'session-exact',
@@ -541,7 +569,7 @@ describe('headless runner', () => {
     })
     const result = await test.run()
     expect(result.code).toBe(1)
-    expect(result.err).toContain('belongs to a subagent')
+    expect(result.err).toContain('is a subagent or forked session')
     await test.ctx.fiber.dispose()
   })
 
@@ -582,7 +610,18 @@ describe('headless runner', () => {
     })
     const result = await test.run()
     expect(result.code).toBe(1)
-    expect(result.err).toContain('belongs to a subagent')
+    expect(result.err).toContain('is a subagent or forked session')
+    await test.ctx.fiber.dispose()
+  })
+
+  it('rejects a live Agent created under an agent preset', async () => {
+    const test = await bench({ afterPrompt: () => {} }, {
+      sessionId: 'session-exact',
+      preliveMeta: { agentPreset: 'minimal' },
+    })
+    const result = await test.run()
+    expect(result.code).toBe(1)
+    expect(result.err).toContain('created under agent preset "minimal"')
     await test.ctx.fiber.dispose()
   })
 
@@ -609,7 +648,7 @@ describe('headless runner', () => {
     })
     const result = await test.run()
     expect(result.code).toBe(1)
-    expect(result.err).toContain('belongs to a subagent')
+    expect(result.err).toContain('is a subagent or forked session')
     await test.ctx.fiber.dispose()
   })
 

+ 32 - 0
packages/bundle/headless/tests/json-stream.spec.ts

@@ -205,6 +205,38 @@ describe('--json projection', () => {
     })
   })
 
+  it('keeps a literal __proto__ key and bounds over-long object keys', () => {
+    const proto = harness({ maxStringBytes: 32 }, 's1')
+    proto.emitSession({
+      type: 'tool/call',
+      data: {
+        turn: 1,
+        step: 1,
+        callId: 'p',
+        name: 'bash',
+        arguments: '{"__proto__":{"polluted":true},"a":1}',
+      },
+    } as unknown as SessionEvent)
+    const protoInput = proto.parsed()[1]?.input as Record<string, unknown>
+    expect(Object.keys(protoInput)).toEqual(['__proto__', 'a'])
+    expect(protoInput['__proto__']).toEqual({ polluted: true })
+
+    const longKey = harness({ maxStringBytes: 8 }, 's1')
+    longKey.emitSession({
+      type: 'tool/call',
+      data: {
+        turn: 1,
+        step: 1,
+        callId: 'k',
+        name: 'bash',
+        arguments: JSON.stringify({ ['k'.repeat(20)]: 1 }),
+      },
+    } as unknown as SessionEvent)
+    const longEvent = longKey.parsed()[1] as { input: Record<string, unknown>; truncated?: boolean }
+    expect(Object.keys(longEvent.input)).toEqual(['kkkkkkkk'])
+    expect(longEvent.truncated).toBe(true)
+  })
+
   it('drops a split trailing multibyte character when truncating', () => {
     const test = harness({ maxStringBytes: 5 }, 's1')
     test.emitSession(assistantMessage([{ type: 'text', text: 'ééé' }]))

+ 33 - 0
packages/bundle/headless/tests/startup.spec.ts

@@ -41,6 +41,7 @@ afterEach(async () => {
   cmdlineInternals.stdout = process.stdout
   cmdlineInternals.stderr = process.stderr
   startupInternals.stdinIsTty = () => process.stdin.isTTY
+  startupInternals.stdout = process.stdout
 })
 
 /**
@@ -81,6 +82,7 @@ export const apply = ctx => globalThis.__headlessStartupApply(ctx)
   cmdlineInternals.stdout = observing
   cmdlineInternals.stderr = observing
   startupInternals.stdinIsTty = () => options.stdinIsTty === true
+  startupInternals.stdout = observing
   const globals = globalThis as unknown as {
     __headlessStartupApply: typeof apply
     __headlessStartupObserved: Observed
@@ -141,6 +143,37 @@ describe('headless command-line provider', () => {
     expect(observed.exits).toEqual([1])
   })
 
+  it('keeps the caller-provided exact Session identity verbatim', async () => {
+    const { task } = await bootStartup(['--session-id', ' session-x ', 'do', 'it'])
+    expect(task).toEqual({ task: 'do it', sessionId: ' session-x ', json: false })
+  })
+
+  it('rejects a lone stdin marker mixed with other task words', async () => {
+    const { task, observed } = await bootStartup(['-', 'do', 'it'])
+    expect(observed.out).toContain('`-` must be the only task argument')
+    expect(task).toBeUndefined()
+    expect(observed.exits).toEqual([1])
+  })
+
+  it('writes the JSON error event for a --json usage error', async () => {
+    const { task, observed } = await bootStartup(['--json'], { stdinIsTty: true })
+    const first = JSON.parse(observed.out.trim().split('\n')[0] ?? '{}') as { type: string; message: string }
+    expect(first).toEqual({
+      type: 'error',
+      message: 'error: a task is required, for example: dsh --profile headless "run the tests"',
+    })
+    expect(task).toBeUndefined()
+    expect(observed.exits).toEqual([1])
+  })
+
+  it('writes the JSON error event for an empty Session identity in --json mode', async () => {
+    const { observed } = await bootStartup(['--json', '--session-id', '', 'do', 'it'])
+    const first = JSON.parse(observed.out.trim().split('\n')[0] ?? '{}') as { type: string; message: string }
+    expect(first.type).toBe('error')
+    expect(first.message).toContain('--session-id requires a non-empty session id')
+    expect(observed.exits).toEqual([1])
+  })
+
   it('reports the real process stdin terminal state by default', () => {
     const original = Object.getOwnPropertyDescriptor(process, 'stdin')
     Object.defineProperty(process, 'stdin', { value: { isTTY: true }, configurable: true })