Parcourir la source

feat(headless): require --session-id to name an existing Session

`--session-id` now adopts only: an id with no stored Session fails before the
task runs instead of creating an empty history the caller believes it is
resuming. A first round omits the flag, and the runtime reports the minted
`session-<uuid>` identity in the opening `session` event, so a supervisor
persists it and names it on every later wake.

Updates the help text, the README pair, the Agent Note pair, the generated
config catalog, the unit suite, and the product-profile e2e.
lsdsjy il y a 2 semaines
Parent
commit
ea84ad31b6

+ 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: 4b9887c9935b34fc4019f6136bb2c8e268a447a3
-2026-09-09-headless-machine-readable-run-surface.zh.md: f9b0aa36625f1ef9615667089f11d229c151d095
+2026-09-09-headless-machine-readable-run-surface.md: bf98ea7976aff9a79c159c43133b0cfd04ddb929
+2026-09-09-headless-machine-readable-run-surface.zh.md: 68fbeea65a131bde3c68a5e2cab995f3e6cdb9ae

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

@@ -17,7 +17,7 @@ The `dsh-headless` bundle owns an opt-in machine-readable run surface. The defau
 Three additions extend the app-owned command line that [Apps own their command lines](../../archived/architecture/2026-08-06-app-owned-command-line.md) 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 when it exists, otherwise create it. Without the flag the run mints `session-<uuid>` as before.
+- `--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.
@@ -63,18 +63,18 @@ 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)). The id is opaque, so the runner validates non-emptiness on the trimmed value and passes the caller's exact string through, whitespace included.
+`--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](../../implemented/architecture/2026-06-14-session-persistence.md)) 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](../../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 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. All checks run when a live Agent already holds the requested id, and again after the runner's idle wait, so a live identity cannot bypass them and 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 — including when a live Agent already holds the id, because a later process has to find it — or when the requested identity would lack the `sessionPersistence` service that makes it durable; a live identity must also carry a stored record, because one registered only in memory would flush nothing.
 
 ## 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 or creates the exact session, 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.
+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. 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 live identity has no stored record, exits 1 with a diagnostic, whether the identity is live or persisted.
+- 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 live identity has no stored record, exits 1 with a diagnostic, whether the identity is live or persisted.
 - 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.
 
@@ -91,10 +91,10 @@ Deferred and open:
 
 **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 create-or-adopt 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.
+**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.
 
-**Create-only `--session-id`.** The second wake would fail against the existing log, which is the opposite of the continuity the flag exists for.
+**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.

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

@@ -17,7 +17,7 @@ Status: implemented
 三项新增扩展 [Apps own their command lines](../../archived/architecture/2026-08-06-app-owned-command-line.md) 确立的 app 自有命令行:
 
 - `--json` 把 stdout 负载换成逐行 JSON 运行事件。推理变成一条事件而不再写 stderr,因此该模式下 stderr 只承载 `dsh:` 诊断。
-- `--session-id <id>` 选定精确的会话身份:已存在持久化会话就采用它,否则创建。不带该 flag 时,运行仍像以前一样生成 `session-<uuid>`。
+- `--session-id <id>` 选定精确的会话身份:采用具有该 id 的持久化会话,不存在时就失败。不带该 flag 时,运行仍像以前一样生成 `session-<uuid>`。
 - 没有位置参数、或者位置参数为 `-` 时,任务文本改从 stdin 读取。
 
 每次运行的 `--model` 覆盖被明确排除在范围之外;组合默认模型仍然权威。
@@ -63,18 +63,18 @@ 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))。标识是不透明的,因此 runner 只在 trim 后的值上校验非空,并把调用方的原始字符串(含空白字符)原样传下去。
+`--session-id <id>` 是只采用:先观察持久化会话并 resume,日志不存在时失败。首轮不传该 flag,由运行时生成身份并在 `session` 事件里报告;后续每一轮都用该值指名并续接历史。请求的 id 没有持久化日志时报错,而不是开一段新会话,因此写错或过期的 id 不会静默开出一段调用方自以为在续接的空历史,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,而不是静默续接一个根目录在别处的会话;未记录 cwd 的会话出于同样理由被拒绝。运行在 agent preset 下的会话被拒绝,因为本 bundle 不组合任何 preset roster:在这里 resume 它,会用 headless 的工具与提示词运行它,而不是它日志当前记录的组合。该检查读取日志当前记录的 preset——创建 header 再叠加任何 `agent-preset/selected` 事件——因为空白会话可能在创建后切换 preset,而 header 始终只是创建事实;畸形的选择记录会失败关闭,而不会读成「无 preset」。带父会话或子 agent 关联的会话——包括用户 fork 出的会话——被拒绝。当某个存活 Agent 已经持有请求的 id 时,上述检查全部执行,并在 runner 等待 idle 后再次执行,因此存活身份无法绕过它们,在该窗口内选中的 preset 也仍会被拒绝。纯空白的 `sessionId` 在 CLI 与直接配置两条路径上都会被拒绝。两个存活进程不能写同一个 id;存储的写租约已经会拒绝第二个写入者。runner 通过已组合的 `sessionQuery` 服务读取观察结果,并在请求 `--session-id` 却没有该服务时显式失败——即便本进程已有存活 Agent 持有该 id,因为后续进程仍需找到它;若所请求的身份缺少让它持久化的 `sessionPersistence` 服务,同样显式失败;存活身份还必须已有持久化记录,因为仅注册在内存中的身份不会写入任何内容。
 
 ## 后果
 
-实际落地:`src/startup.ts` 解析 `--json` 与 `--session-id <id>`,把缺失或为 `-` 的任务视为"从 stdin 读取",并且只在 stdin 是终端时抛出用法错误。`src/index.ts` 解析任务、采用或创建精确会话,并接上 stderr 推理投影或新的 `src/json-stream.ts` 投影。`cordis.patch.yml` 转发这两个新设置。`package.json` 发布两个入口共同引用的共享 chunk `lib/json-stream-*.js`,因此安装后的 tarball 可以加载。
+实际落地:`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` 运行共享历史。cwd 不一致、未记录 cwd、属于子 agent 或 fork 会话、运行在 agent preset 下、preset 记录畸形,或存活身份没有持久化记录的运行都以诊断退出 1,无论身份是存活还是持久化的。
+- 两次连续的相同 `--session-id` 运行共享历史,而请求一个没有持久化日志的 id 会在任务运行前退出 1。cwd 不一致、未记录 cwd、属于子 agent 或 fork 会话、运行在 agent preset 下、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 期望测试端到端覆盖两种输出模式。
 
@@ -91,10 +91,10 @@ dsh --profile headless [--json] [--session-id <id>] [<task>... | -]
 
 **直接倾倒原始会话事件。** 它们在增量之外重复整条已组装消息,回显 `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,相对一个真实轮次很小。
+**用长驻 SDK 进程代替每次唤醒一个进程。** SDK 已经有结构化事件和显式的会话身份语义,但它会替换掉监督进程所依赖的"一次唤醒一个进程"模型。实测 headless profile 的冷启动约为热态 0.45 s、冷态 1.2 s,相对一个真实轮次很小。
 
 **让监督进程生成会话 id。** 身份属于拥有日志的运行时。监督进程记录第一条事件报告的值即可。
 
-**只创建语义的 `--session-id`。** 第二次唤醒会撞上已存在的日志,与该 flag 存在的目的正好相反。
+**采用或创建语义的 `--session-id`。** 在日志不存在时创建所请求的 id,会让写错或过期的 id 静默开出一段监督进程自以为在续接的空历史;首轮本就不传该 flag 并从 `session` 事件读到生成的 id,因此没有任何调用方需要用 `--session-id` 来创建。
 
 **任务只走 argv。** 长提示词会超出 `ARG_MAX`,并且把提示词暴露在进程列表里。

+ 30 - 3
apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts

@@ -251,7 +251,7 @@ describe('headless stream-json snapshots', () => {
     expect(result.stderr).toBe(await readFile(headlessReasoningExpected, 'utf8'))
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 
-  it('projects the same run as JSON events with an exact session identity', async () => {
+  it('projects the same run as JSON events under a generated session identity', async () => {
     const task = 'Prove the machine-readable product headless profile path.'
     const result = await runLoaderSmoke({
       label: 'product headless profile json snapshot',
@@ -260,7 +260,7 @@ describe('headless stream-json snapshots', () => {
       configPath: headlessOverlayPath,
       binArgs: [
         '--profile', 'headless', '--patch', headlessOverlayPath,
-        '--json', '--session-id', 'headless-json-session', task,
+        '--json', task,
       ],
       tsconfigPath,
       env: {
@@ -271,7 +271,8 @@ describe('headless stream-json snapshots', () => {
     })
 
     const events = result.stdout.trim().split('\n').map(line => JSON.parse(line) as JsonObject)
-    expect(events[0]).toMatchObject({ type: 'session', sessionId: 'headless-json-session' })
+    expect(events[0]).toMatchObject({ type: 'session' })
+    expect(events[0]?.sessionId).toMatch(/^session-/)
     expect(typeof events[0]?.cwd).toBe('string')
     expect(events.at(-1)).toMatchObject({ type: 'final', text: 'CLI tool round trip complete: CLI_TOOL_ROUND_TRIP' })
     expect(events.map(event => event.type)).toContain('thinking')
@@ -281,6 +282,32 @@ describe('headless stream-json snapshots', () => {
     expect(result.stderr).toBe('')
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 
+  it('fails the JSON run when --session-id names no stored Session', async () => {
+    const result = await runLoaderSmoke({
+      label: 'product headless profile unknown session',
+      tempDirPrefix: 'headless-snapshot-profile-unknown-session-',
+      binScript: dshBinScript,
+      configPath: headlessOverlayPath,
+      binArgs: [
+        '--profile', 'headless', '--patch', headlessOverlayPath,
+        '--json', '--session-id', 'headless-unknown-session', 'Continue the conversation.',
+      ],
+      tsconfigPath,
+      expectedExitCode: 1,
+      env: {
+        DSH_TELEMETRY_DISABLED: '1',
+        NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
+      },
+    })
+
+    const events = result.stdout.trim().split('\n').map(line => JSON.parse(line) as JsonObject)
+    expect(events).toEqual([{
+      type: 'error',
+      message: 'session "headless-unknown-session" does not exist; omit --session-id to start a new Session',
+    }])
+    expect(result.stderr).toContain('omit --session-id to start a new Session')
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
+
   it('prints a terminal model failure through the product headless profile command', async () => {
     const result = await runLoaderSmoke({
       label: 'product headless profile model failure snapshot',

+ 2 - 2
docs/config-catalog.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 docs/config-catalog.md
-config-catalog.md: 11ad98221e257bf7e9bc404347f9f7462603c2ea
-config-catalog.zh.md: 622213aaccd503f634e278a875031553f99ec36b
+config-catalog.md: 673ed9757f5cc07955506cfaf8854b8d4d769753
+config-catalog.zh.md: c34568a3cc80394dbac2f8b007eb4c707d76b23c

+ 1 - 1
docs/config-catalog.md

@@ -821,7 +821,7 @@ Requires: `agentDefaultModel` · `agents` · `sessions`
 export interface Config {
   /** The prompt text for the single run; absent when the task arrives on stdin. */
   task?: string
-  /** Exact Session identity to adopt or create; absent for a fresh random identity. */
+  /** Exact Session identity to adopt; absent for a fresh random identity. An id with no stored Session fails. */
   sessionId?: string
   /** Whether stdout carries the machine-readable event stream instead of final text. */
   json?: boolean

+ 1 - 1
docs/config-catalog.zh.md

@@ -823,7 +823,7 @@ export interface Config {
 export interface Config {
   /** The prompt text for the single run; absent when the task arrives on stdin. */
   task?: string
-  /** Exact Session identity to adopt or create; absent for a fresh random identity. */
+  /** Exact Session identity to adopt; absent for a fresh random identity. An id with no stored Session fails. */
   sessionId?: string
   /** Whether stdout carries the machine-readable event stream instead of final text. */
   json?: boolean

+ 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: 8bdad30de4de3e1e2c4921c2f879454c3440822f
-README.zh.md: c90ed1c44d68b6bbb15f69695058922a3dc05db1
+README.md: bd218759ec9b9e8315e61f25c1310fb320aa2a89
+README.zh.md: 395dd8fdba0e8a44ec2b0544e00adb8a1f71b489

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

@@ -44,14 +44,14 @@ The task and run options are supplied through three settings:
 | Field | Default | Meaning |
 |---|---|---|
 | `task` | stdin | The task text; stdin supplies it when omitted or `-` |
-| `sessionId` | `session-<uuid>` | Exact Session identity to adopt or create |
+| `sessionId` | `session-<uuid>` | Exact Session identity to adopt; an unknown id fails |
 | `json` | `false` | Project the run as newline-delimited events on stdout |
 
 The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-headless) is the exhaustive source for every accepted field and its JSDoc.
 
 ### 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. Both paths require the composed `sessionPersistence` and `sessionQuery` services, so a profile that omits either fails loudly instead of returning an id whose history dies with the process; a live identity must also carry a stored record, because an Agent registered only in memory would flush nothing. 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, that runs under an agent preset this profile does not compose, or whose preset record is malformed — the check reads the preset the Session log currently records, so a Session that switched preset while blank is rejected too. A supervisor therefore cannot silently drive someone else's conversation under a different composition; any mismatch fails before the task runs.
+Every invocation defaults to a fresh `session-<uuid>` identity, which `--json` reports in its opening `session` event. Pass `--session-id <id>` to continue that conversation: the runner adopts the persisted Session with that id, and an id with no stored Session fails before the task runs rather than quietly opening an empty history. Adoption requires the composed `sessionPersistence` and `sessionQuery` services, so a profile that omits either fails loudly instead of returning an id whose history dies with the process; a live identity must also carry a stored record, because an Agent registered only in memory would flush nothing. 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, that runs under an agent preset this profile does not compose, or whose preset record is malformed — the check reads the preset the Session log currently records, so a Session that switched preset while blank is rejected too. A supervisor therefore cannot silently drive someone else's conversation under a different composition; any mismatch fails before the task runs.
 
 ### Machine-readable output
 

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

@@ -44,14 +44,14 @@ agent 会完成该任务,把提供方的每个非空推理(reasoning)增
 | 字段 | 默认值 | 含义 |
 |---|---|---|
 | `task` | stdin | 任务文本;省略或传 `-` 时由 stdin 提供 |
-| `sessionId` | `session-<uuid>` | 要沿用或创建的精确 Session 标识 |
+| `sessionId` | `session-<uuid>` | 要沿用的精确 Session 标识;未知 id 会失败 |
 | `json` | `false` | 把本次运行投影为 stdout 上的按行 JSON 事件 |
 
 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-headless)是所有受支持字段及其 JSDoc 的完整真源。
 
 ### 选择 Session 标识
 
-每次调用默认使用全新的 `session-<uuid>` 标识。传入 `--session-id <id>` 可自行命名:该 id 对应的持久化 Session 存在时 runner 会沿用,否则创建;两条路径都要求已组合 `sessionPersistence` 与 `sessionQuery` 服务,因此缺少任一服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id;存活身份还必须已有持久化记录,因为仅注册在内存中的 Agent 不会写入任何内容。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话、运行在本 profile 不组合的 agent preset 下的会话,以及 preset 记录畸形的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。
+每次调用默认使用全新的 `session-<uuid>` 标识,`--json` 会在开头的 `session` 事件里报告它。传入 `--session-id <id>` 延续这段对话:runner 沿用该 id 对应的持久化 Session,而该 id 没有持久化 Session 时会在任务运行前失败,而不是悄悄开出一段空历史。沿用要求已组合 `sessionPersistence` 与 `sessionQuery` 服务,因此缺少任一服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id;存活身份还必须已有持久化记录,因为仅注册在内存中的 Agent 不会写入任何内容。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话、运行在本 profile 不组合的 agent preset 下的会话,以及 preset 记录畸形的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。
 
 ### 机器可读输出
 

+ 10 - 11
packages/bundle/headless/src/index.ts

@@ -40,7 +40,7 @@ export const inject = ['agentDefaultModel', 'agents', 'sessions']
 export interface Config {
   /** The prompt text for the single run; absent when the task arrives on stdin. */
   task?: string
-  /** Exact Session identity to adopt or create; absent for a fresh random identity. */
+  /** Exact Session identity to adopt; absent for a fresh random identity. An id with no stored Session fails. */
   sessionId?: string
   /** Whether stdout carries the machine-readable event stream instead of final text. */
   json?: boolean
@@ -241,11 +241,12 @@ function assertAdoptable(header: AdoptableHeader, events: Iterable<SessionEvent>
 }
 
 /**
- * Resolve the Agent for one run: reuse a live identity, adopt the persisted
- * Session with the requested id, or create that exact id when no log exists.
+ * Resolve the Agent for one run: reuse a live identity or adopt the persisted
+ * Session with the requested id. The identity must already exist; a first round
+ * omits the option instead, so a typo cannot pass as a brand-new conversation.
  * @param ctx - plugin context carrying the Session query service.
  * @param agents - the core Agent registry.
- * @param sessionId - exact Session identity to adopt or create.
+ * @param sessionId - exact Session identity to adopt.
  * @param agentOptions - provider/model pair for this run.
  * @param setup - per-Agent scope setup installing the model selection.
  * @returns the live, resumed, or freshly created Agent.
@@ -295,14 +296,12 @@ async function resolveAgent(
     return agent
   } catch (error: unknown) {
     if (!(error instanceof SessionQueryError) || error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error
+    // --session-id resumes a conversation that already exists; starting a new
+    // one is the no-id path, which generates its own identity and reports it in
+    // the `session` event. Creating the requested id here would turn a typo
+    // into a brand-new empty history the caller believes it is continuing.
+    throw new Error(`session "${sessionId}" does not exist; omit --session-id to start a new Session`)
   }
-  const { agent } = await agents.create({
-    sessionId,
-    meta: { cwd: process.cwd() },
-    agentOptions,
-    setup,
-  })
-  return agent
 }
 
 /** Report an unexpected direct-driver failure and request a failing exit. */

+ 3 - 3
packages/bundle/headless/src/startup.ts

@@ -24,7 +24,7 @@ export const HEADLESS_STARTUP_SERVICE = 'headlessStartup'
 export interface HeadlessStartupValues {
   /** The task text this invocation asked for; absent when the runner reads stdin. */
   task: string | undefined
-  /** Exact Session identity to adopt or create; absent for a fresh random identity. */
+  /** Exact Session identity to adopt; absent for a fresh random identity. */
   sessionId: string | undefined
   /** Whether stdout carries the machine-readable event stream instead of final text. */
   json: boolean
@@ -49,14 +49,14 @@ function headlessCommand(): Command {
     .description('Answer one task and exit; the answer goes to stdout and diagnostics to stderr.')
     .helpOption('-h, --help', 'show this help')
     .option('--json', 'write newline-delimited run events to stdout instead of the final message')
-    .option('--session-id <id>', 'adopt the persisted Session with this id, or create it when absent')
+    .option('--session-id <id>', 'adopt the persisted Session with this id; an unknown id is an error')
     .argument('[task...]', 'the task text; multiple words are joined by spaces, and `-` reads stdin')
     .addHelpText('after', `
 Examples:
   dsh --profile headless "run the tests"          answer one task and exit
   echo "run the tests" | dsh --profile headless   read the task from stdin
   dsh --profile headless --json "run the tests"   emit machine-readable run events
-  dsh --profile headless --session-id session-… "continue"   adopt a Session
+  dsh --profile headless --session-id session-… "continue"   resume an existing Session
 `)
 }
 

+ 6 - 3
packages/bundle/headless/tests/headless.spec.ts

@@ -505,7 +505,7 @@ describe('headless runner', () => {
     await test.ctx.fiber.dispose()
   })
 
-  it('creates the exact requested Session when the query reports it missing', async () => {
+  it('rejects --session-id when the query reports the id missing', async () => {
     const seen: string[] = []
     const test = await bench({
       afterPrompt(session, message) {
@@ -516,8 +516,11 @@ describe('headless runner', () => {
       sessionId: 'session-exact',
       observe: () => Promise.reject(new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND')),
     })
-    expect(await test.run()).toMatchObject({ code: 0, out: 'created\n', err: '' })
-    expect(seen).toEqual(['session-exact'])
+    const result = await test.run()
+    expect(result.code).toBe(1)
+    expect(result.err).toContain('session "session-exact" does not exist; omit --session-id to start a new Session')
+    expect(result.out).toBe('')
+    expect(seen).toEqual([])
     await test.ctx.fiber.dispose()
   })