# dsh-headless に学ぶ「エージェントを駆動する最小手順」 出典: 読み取り専用調査 / 対象 @deepseek-ai/dsh-headless 0.2.0-rc.2 / 作成日 2026-10-18 対象環境: Node v22.22.1 / pnpm 11.28.5 / `$HOME` = /home/mugi 制約遵守: **DSH インストール側は一切書き換えていない**。`dsh --profile X` は実行していない(読解のみ)。 `web_search` は API キー未設定のため未使用、`workflow` は Amaro 不在で TS 実行不可のため未使用。 本文中の `P/` は `$HOME/.local/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai/` の略記。`HOME/` は `$HOME/` の略記。 --- ## 0. 読んだファイル(実測サイズ) | # | パス | 行数 / サイズ | |---|---|---| | 1 | `P/dsh-headless/README.md` | 156 行 / 14,946 B | | 2 | `P/dsh-headless/lib/startup.js` | 93 行 / 4,471 B(依頼どおり 4,471 B と一致) | | 3 | `P/dsh-headless/lib/index.js` | 368 行 / 13,864 B(同 13,864 B と一致) | | 4 | `P/dsh-headless/cordis.patch.yml` | 34 行 / 1,240 B | | 5 | `P/dsh-headless/lib/types/{index,startup,startup-internals,runner-internals}.d.ts` | 4 ファイル | | 6 | `P/dsh-headless/lib/json-stream-BA-F3lfb.js` | 280 行(`--json` 投影の実体。`index.js` から内部 import) | | 7 | `P/dsh-headless/package.json` | 71 行 | | 8 | `P/dsh-sdk-jsonrpc-server/{README.md,lib/index.js}` | 130+ 行 / 296 行 | | 9 | `P/dsh-session/lib/types/{index,types}.d.ts` | 474 / 521 行(`SessionEvent` の実体) | | 10 | `P/dsh-agent/lib/types/{index,types,runtime-types,model-selection}.d.ts` | 379 / 95 / 410 / 49 行 | | 11 | `P/dsh-agent-default-model/lib/types/index.d.ts` | 52 行 | | 12 | `P/dsh-session-query/lib/types/{index,observation}.d.ts` | 154 / 85 行 | | 13 | `P/dsh-cmdline/lib/types/index.d.ts` | `appExit` / `parseCmdline` の宣言 | | 14 | `P/dsh-base/cordis.patch.yml` | 529 行(重ね先のベース層) | | 15 | `dsh/lib/profile-boot-BZ2ZjNWi.js`(ランチャー本体) | `provideCmdline` の結線 | | 16 | `HOME/.dsh/profiles/headless/{package.json,cordis.yml,cordis.patch.yml}` | プロファイル合成の実データ | | 17 | `HOME/.dsh/profiles/dsh-tui/node_modules/@deepseek-harness-tui/dsh-tui/` | 比較対象(既存 TUI バンドル 0.15.0) | --- ## 1. バンドルの構成 — `cordis.patch.yml` は何を挿しているか ### 1.1 挿入行数 `P/dsh-headless/cordis.patch.yml` は全 34 行。構造は 4 ブロック。 | 行 | 種別 | 対象 | |---|---|---| | 1–7 | コメント | — | | 9–13 | **id 指定の上書き**(`insert` ではない) | ベース行 `system-prompt` | | 15–18 | **id 指定の上書き** | ベース行 `tools` | | 20–31 | **`insert:`(新規挿入 = 2 行)** | `headless-startup`, `headless-runner` | | 33–34 | **id 指定の上書き** | ベース行 `hmr` | → **新規に挿しているのは 2 行**(`headless-startup` と `headless-runner`)。 残る 3 つは `dsh-base` が既に持つ行の config/disabled を差し替えるもの。 `name` を持たない patch エントリが「id 指定の上書き」であることは `P/cordis-plugin-include/lib/types/index.d.ts:27-38`(`PatchOptions { id?, insert?, name?, config?, disabled?, inject?, … }`)と `P/dsh-app-boot/lib/index.js:3497-3503`("id-targeted config overrides and `insert` lists, with `!!js` expressions allowed")で確認できる。 ### 1.2 各 id / name / config の意味 **(a) `id: system-prompt`(9–13 行)— 上書き** ```yaml - id: system-prompt config: personaSuffix: Your working directory is {{cwd}}. personaPrefix: >- You are a coding agent powered by the {{model}} model. ``` - ベース側の同 id 行は `P/dsh-base/cordis.patch.yml:504-507` で `personaPrefix: ''` のみ。 - **patch は対象行の `config` 全体を置換する**(マージではない)と `P/dsh-base/cordis.patch.yml:5-10` に明記。したがって headless は base の空 persona を捨て、prefix と suffix の両方を自分で述べ直している。 これは `P/dsh-headless/README.md:84`("sets the coding persona prefix and separate cwd suffix on the base `system-prompt` row")と一致。 - `{{cwd}}` / `{{model}}` はテンプレート変数(プロンプト組み立て時に解決される。解決器の実装は `dsh-system-prompt` 側で本調査の範囲外)。 **(b) `id: tools`(15–18 行)— 上書き** ```yaml - id: tools config: mode: !!js process.env.DSH_TOOLS_MODE ``` - env 由来の一時的なプロセス全体 PTC モード opt-in。README:84 が「Web surface と同じ temporary process-wide PTC mode opt-in」と説明。 - ベース側 `tools` 行は config を持たない(`P/dsh-base/cordis.patch.yml:499-500`)ため、置換による情報落ちは無い(確認済み)。 - `!!js` は Cordis ローダーのタグで、YAML スカラーを式として評価する(`P/cordis-plugin-loader/lib/index.js:238` "Recursively replace YAML `!!js` expression nodes with evaluated values")。 **(c) `insert:`(20–31 行)— 新規 2 行** ```yaml - insert: - id: headless-startup name: '@deepseek-ai/dsh-headless/startup' - id: headless-runner name: '@deepseek-ai/dsh-headless' inject: [headlessStartup] config: task: !!js ctx.headlessStartup.task sessionId: !!js ctx.headlessStartup.sessionId json: !!js ctx.headlessStartup.json ``` - `headless-startup` = `P/dsh-headless/lib/startup.js`(`name = "headless-startup"`, startup.js:25)。CLI を解析し `headlessStartup` サービスを provide するだけのプロバイダ。 - `headless-runner` = `P/dsh-headless/lib/index.js`(`name = "headless-runner"`, index.js:99)。実際にエージェントを駆動する。 - `inject: [headlessStartup]`(27 行)はローダー行の依存宣言(`PatchOptions.inject` / ローダーは `fiber.entry.options.inject` を解決する: `P/cordis-plugin-loader/lib/index.js:628`)。 - **config を `!!js` で遅延評価している理由**: README:84 が "The runner injects that service and reads its task and run options from lazy config" と説明。startup.js:33-35 の docstring も "The runner is an ordinary consumer whose lazy config waits for that service" と述べる。つまり「サービスが生えてから行の config を評価する」ための 2 プラグイン分割。 - `Config` スキーマは実行時 `z.object({ task: z.string(), sessionId: z.string(), json: z.boolean() })`(index.js:106-110)、型宣言は 3 フィールドとも optional(`P/dsh-headless/lib/types/index.d.ts:19-26`)。**一見矛盾するが矛盾しない**: Schemastery は値が `undefined` のキーを「欠落」として扱うため、`task`/`sessionId` が `undefined` でも検証を通り、解決後の config からはキーが落ちる。 (検証: インストール済み `schemastery` 3.18.4 に対し `z.object({task:z.string(),sessionId:z.string(),json:z.boolean()})({task:undefined,sessionId:undefined,json:false})` を strict 有無どちらでも実行し、`{"json":false}` が返ることを確認。読み取り専用の評価のみ。) **(d) `id: hmr`(33–34 行)— 上書き** ```yaml - id: hmr disabled: true ``` - ベースは `disabled: !!js "!ctx.get('profileContext')"` + `config: { root: [] }`(`P/dsh-base/cordis.patch.yml:28-32`)。headless は**無条件で HMR を無効化**する。ワンショット実行にホットリロードは不要、という判断(README:84 "disables the shared HMR row")。 ### 1.3 重ね先(`dsh-base`)との関係 - headless は `dsh-base` を**置き換えず、上に重ねる**。実データで確認: `HOME/.dsh/profiles/headless/package.json:5-12` → `"dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-headless"] } }` - プロファイル根は空配列 `[]`(`HOME/.dsh/profiles/headless/cordis.yml:1-4`「The tree is composed as patches: each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any --patch overlays」)。 - パッケージ側の登録は `P/dsh-headless/package.json:37-41` の `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`。 - したがって「エージェントを駆動する」のに必要な `llm` / `session` / `agent` / `agent-loop` / `agent-default-model` / `fs` / `tools` / `system-prompt` / 認証・資格情報は**すべて dsh-base が供給**し、headless は CLI 入口と駆動役の 2 行しか足していない。**これが「最小手順」の第一の主張**である。 --- ## 2. エージェント identity の解決 ### 2.1 使うサービスとメソッド(すべて `ctx.get(...)` 経由) | サービス | 取得箇所 | メソッド | 宣言 | |---|---|---|---| | `agents` | index.js:295 | `create(options)` / `resume(options)` / `get(id)` | `P/dsh-agent/lib/types/index.d.ts:278` / `:286` / `:343` | | `agentDefaultModel` | index.js:296 | `currentSelection()` | `P/dsh-agent-default-model/lib/types/index.d.ts:42` | | `sessions` | index.js:297 | `flush(session)` | `P/dsh-session/lib/types/index.d.ts:439` | | `sessionPersistence` | index.js:242 | (存在確認のみ) | — | | `sessionQuery` | index.js:243 | `observeSession(id, options?)` | `P/dsh-session-query/lib/types/index.d.ts:47` | | `fs` | index.js:314 | `resolve('.')` → `processPath(...)` | index.js:315 が使用 | | `loader` | index.js:294 | `await()` | README:80 | | `appExit` | index.js:356 | `(code) => void` | `P/dsh-cmdline/lib/types/index.d.ts:32-38` | runner プラグインの `inject` 宣言は `["agentDefaultModel", "agents", "sessions"]`(index.js:101-105)。 `sessionPersistence` / `sessionQuery` / `fs` / `loader` / `appExit` は inject ではなく**実行時の任意取得+明示チェック**(index.js:242-244, 294, 314, 356-357)。 ### 2.2 新規セッション ```js // index.js:313-321 const sessionId = brandString(config.sessionId ?? `session-${randomUUID()}`); const fs = ctx.get("fs"); const cwd = fs === void 0 ? process.cwd() : fs.processPath(await fs.resolve(".")); const agent = config.sessionId === void 0 ? (await agents.create({ sessionId, meta: { cwd }, agentOptions, setup })).agent : await resolveAgent(ctx, agents, sessionId, agentOptions, setup, cwd); ``` - identity は `session-`(`randomUUID()` は `node:crypto`: index.js:2)。 - `CreateAgentOptions` の型は `P/dsh-agent/lib/types/index.d.ts:48-104`: `sessionId: SessionId`(必須), `meta?: {cwd?, parentSession?, isSeeded?, origin?, delegationDepth?, agentPreset?}`(`:64-71`), `agentOptions?`, `seed?`, `signal?`, `setup?`。 - `agentOptions` は共有デフォルトモデルから起こす: ```js // index.js:302-306 const selection = defaultModel.currentSelection(); const agentOptions = { provider: selection.provider, model: selection.model }; ``` 型 `AgentOptions`(provider/model/reasoningEffort/maxTokens)は `P/dsh-agent/lib/types/runtime-types.d.ts`(`ModelSelection` は `P/dsh-agent/lib/types/model-selection.d.ts:16-23`)。headless は `reasoningEffort` を**渡していない**(推測: 既定に委ねる意図。README に明記は無い)。 - `setup` はエージェントスコープの合成専用コールバック(`AgentSetup`, `P/dsh-agent/lib/types/index.d.ts:40`)。headless はここでモデル選択を固定する: ```js // index.js:307-312 const setup = (agentCtx) => { installModelSelection(agentCtx, { current: selection, assembled: void 0 }); }; ``` `installModelSelection` は `P/dsh-agent/lib/types/model-selection.d.ts:49`。 - **`agents.create` が返すのは `AgentHandle`**(`P/dsh-agent/lib/types/index.d.ts:143-146`: `{ agent, dispose() }`)。 headless は `.agent` だけ取り出して **handle を捨てている**(index.js:316-321)。これは長命プロセスの TUI では真似すべきでない(→ §7)。 ### 2.3 既存セッションの adopt ```js // index.js:241-276(要約) if (ctx.get("sessionPersistence") === void 0) throw new Error("headless --session-id requires the sessionPersistence service; …"); const query = ctx.get("sessionQuery"); if (query === void 0) throw new Error("headless --session-id requires the sessionQuery service; dsh-base provides it"); const live = agents.get(sessionId); if (live !== void 0) { assertAdoptable(...); throw new Error(`session "${sessionId}" is live in this process, so the one-shot runner cannot own an exclusive run interval`); } const observation = await query.observeSession(sessionId); // index.js:257 assertAdoptable(observation.header, observation.events, sessionId, cwd); const { agent } = await agents.resume({ resumeSessionId: sessionId, agentOptions, setup }); // index.js:259-263 assertAdoptable(agent.session.header, liveEvents(agent.session), sessionId, cwd); // index.js:264 ``` - `observeSession` の戻り値 `SessionObservation` は `{ source: 'live'|'prepared', header: SessionHeader, inheritedEventCount, events: readonly SessionEvent[], cursor, revision?, projections?, retain() }` (`P/dsh-session-query/lib/types/observation.d.ts:7-31`、`Disposable`)。 - 存在しない id は `SessionQueryError` code `SESSION_QUERY_SESSION_NOT_FOUND` を捕まえて 「omit --session-id to start a new Session」というメッセージに翻訳(index.js:273-274)。 - `ResumeAgentOptions` は `{ resumeSessionId, parentAgent?, agentOptions?, signal?, setup? }`(`P/dsh-agent/lib/types/index.d.ts:109-128`)。 - **adopt の拒否条件**(`assertAdoptable`, index.js:221-227): 1. preset が付いている(`currentPreset` が `undefined` 以外)→ 拒否(`:222-223`) 2. `header.origin === 'subagent'` または `header.parentSession !== undefined` → 拒否(`:224`) 3. `header.cwd === undefined` → 拒否(`:225`) 4. `header.cwd !== cwd`(fs プロバイダで解決した cwd と不一致)→ 拒否(`:226`) - preset は「作成ヘッダの `agentPreset` を、最後の `agent-preset/selected` イベントで前進させたもの」(`currentPreset`, index.js:209-219)。不正な `agent-preset/selected`(非文字列 or 空文字)は例外(`:215`)。 - `SessionHeader` の実体は `P/dsh-session/lib/types/types.d.ts:58-95`(`version`, `id`, `createdAt`, `cwd?`, `parentSession?`, `isSeeded`, `origin?`, `delegationDepth?`, `agentPreset?`)。 - identity は不透明文字列で、空白も含めてそのまま使う(README:54)。空文字は startup.js:83 と index.js:299 の二段で拒否。 ### 2.4 ドライバの前提条件 ```js // index.js:294 await ctx.get("loader")?.await(); ``` 「合成済みのツール/アダプタが半分だけ載った状態で駆動しない」ため(README:80)。TUI でも boot 時に必須(→ §7)。 --- ## 3. タスクの投入 ### 3.1 組み立てと投入 ```js // index.js:329-335 agent.followup(createUserMessage({ content: [{ type: "text", text: task }], source: { kind: "user" } })); ``` - `createUserMessage` は `P/dsh-llm` から import(index.js:6)。 - 投入は **`followup`**。`steer` でも `send` でもない。 ### 3.2 なぜ `followup` か(根拠) 3 メソッドの契約は `P/dsh-agent/lib/types/runtime-types.d.ts` に宣言されている。 | メソッド | 宣言 | 契約 | |---|---|---| | `send(message, target, wakeup)` | `:175-186` | "Route identified input to an inbox boundary and optionally wake the driver."(低レベル原始操作。`target` は `'next-turn' \| 'next-step'`) | | `followup(message)` | `:187-192` | "Queue an ordinary follow-up turn and wake the driver. **The item becomes the sole ordinary message of its own turn.**" | | `steer(message)` | `:193-200` | "Submit steering for the nearest step. … a running driver consumes it at its next step boundary." | | `inject(message)` | `:201-209` | "Queue model-facing context for the next pre-step without waking the driver." | `P/dsh-agent/README.md:47` も同旨("`followup()` queues an ordinary next-turn prompt and wakes the driver; `steer()` submits next-step input…")。 headless の集計関数 `summarize` は `turn/start` を見てから `assistant/message` / `turn/end` を畳む(index.js:112-135、特に `:120-123` の `started` ゲート)。 つまり **「1 タスク = 1 ターン」が headless の出力契約**であり、その契約を保証するのは「自分の通常メッセージが自分のターンを成す」`followup` だけである。 `steer` は既存ターンの途中に混ざり得るため、`turn/start` を伴わない可能性がある。 `send` は target/wakeup を呼び出し側が決める原始操作で、`followup` はその名前付き便利形(推測: `followup` が `send(msg, 'next-turn', true)` 相当であるという実装レベルの同一視は未確認)。 参考: 既存 `dsh-tui` バンドルも同じ `followup` を使う(`HOME/.dsh/profiles/dsh-tui/node_modules/@deepseek-harness-tui/dsh-tui/lib/types/dsh-adapter/channel/agent-view-projection.js:191, :262`)。また TUI は入力の行き先を `'steer' | 'followup'` で切り替える(同 `channel/input-delivery.js:298`)— **TUI は「今のターンに割り込む」= steer、「次のターン」= followup を選べる必要がある**点が headless との差。 ### 3.3 タスク本文の解決 ```js // index.js:300-301 const task = config.task === void 0 || config.task === "-" ? await internals.readStdin() : config.task; if (task.trim() === "") throw new Error("a task is required, …"); ``` stdin は `internals.readStdin`(index.js:21-25)で全読み。CLI 側の受理判定は startup.js:76-80(`-` の混在拒否・空白のみ拒否・TTY でタスク無し拒否)。 --- ## 4. 完了の検知(quiescence) ### 4.1 結論: **イベントを待っていない。エージェントのプロミスを待っている** ```js // index.js:316-341(骨子) const agent = await agents.create({...}); // または resolveAgent(...) await agent.whenIdle(); // 322: 投入前に静穏を確認 const firstSeq = agent.session.seq; // 324: 自分の区間の開始点 … agent.followup(createUserMessage({...})); // 329 await agent.whenIdle(); // 336: 完了待ち(ここが quiescence) await sessions.flush(agent.session); // 340 const outcome = summarize(agent.session, firstSeq); // 341 ``` - headless の `lib/index.js` に `turn/end` を待つリスナーは**存在しない**(`ctx.on` は 2 箇所のみ: index.js:153 の `agent/assistant-stream` と、json-stream 側 :264 の `session/event`。どちらも完了判定用ではない)。 - `whenIdle()` の契約は `P/dsh-agent/lib/types/runtime-types.d.ts:158-164`: "Resolve after the current whole-agent activity reaches quiescence. This follows replacement work started before the observed driver retires, but does not identify the settlement of any particular message." → 「どのメッセージの決着か」は教えないので、**決着は durable log から読む**(§5.3)。 - README:80 の表現 "It waits for quiescence, then flushes the Session and folds the owned interval (`firstSeq` onward)" がこの実装の要約。 - 待つ「サービス」は `agents`(`ctx.agents` が返す `Agent` オブジェクトのメソッド)。イベントではない。 ### 4.2 投入前にも `whenIdle()` を待つ理由 index.js:322。adopt 直後のエージェントがまだ再開処理中なら、`firstSeq` の捕捉(:324)がずれる。**「自分の区間」を確定する前に静穏化する**のが目的(推測。コメントは無いが、:324 が直後にある事実が支持する)。 ### 4.3 完了後に読むもの `summarize`(index.js:112-135)は [firstSeq, session.seq) を走査し、 - 最後の**非空** `assistant/message` の text ブロック連結を最終回答に(`:125-128`) - 最後の `turn/end` の `reason` を最終結果に(`:129`) する。`turn/end` の `reason.kind === 'completed'` のときだけ exit 0(index.js:345)。 `TurnEndReason` の全 variant は `P/dsh-session/lib/types/types.d.ts:165-210` (`completed` / `aborted` / `blocked` / `error` / `max-tokens` / `interrupted` / `forked`)。 `error` のときだけ stderr に `dsh: : `(index.js:344、型は `:182-185`)。 --- ## 5. イベントの収集 ### 5.1 購読するイベント(2 系統、排他) **(a) 既定モード: `agent/assistant-stream`(process-local / 非永続)** ```js // index.js:153-189 const dispose = ctx.on("agent/assistant-stream", ({ agent: subject, frame }) => { if (subject !== agent) return; // :154 exact identity で絞る if (frame.type === "start") { close(); return; } if (frame.type === "end") { close(); return; } const chunk = frame.chunk; switch (chunk.type) { case "reasoning-delta": … // :165-172 空文字は捨て、必要なら "dsh: reasoning:\n" を前置 case "block-start": … // :174-176 reasoning 以外なら行を閉じる case "block-end": … // :177-179 同 case "usage": return; // :180 case "text-delta": case "tool-call-delta": case "finish": close(); return; // :181-185 default: return assertNever(chunk, "headless reasoning stream"); // :187 } }); ``` - イベント契約は `P/dsh-agent/lib/types/runtime-types.d.ts:357-369`: "Process-local assistant-stream publication. Chunk frames are transient; the loop appends one final v2 `assistant/message` or `assistant/attempt` with the same stream before a committed end frame." payload は `{ agent: Agent, frame: AssistantStreamFrame }`。 - **用途は reasoning を stderr に流すことだけ**。最終回答の導出には使わない(README:58 "default-mode stderr reasoning remains the only live text channel")。 - `assertNever` による閉じた union の網羅チェック(index.js:7, :187)。 **(b) `--json` モード: `session/event`(post-commit / 永続)** ```js // json-stream-BA-F3lfb.js:264 const stopSession = ctx.on("session/event", onSessionEvent); ``` イベント契約は `P/dsh-session/lib/types/index.d.ts:53-64`: "Post-commit, fire-and-forget append feed. The listener snapshot resolves before the log push, but callbacks run after it; observer failures are logged and contained without making the committed append fail." ### 5.2 `--json` の畳み方(`projectJsonRun`, json-stream:172-278) 冒頭で `session` を書き、購読を張る(`:259-264`。docstring `:158-165` が「購読開始前に書け」と明示)。 | 元イベント | 出力 | |---|---| | `turn/start` | `{type:'status', phase:'turn_start', turn}`(`:185-190`) | | `step/start` | `{type:'status', phase:'step_start', turn, step}`(`:192-198`) | | `assistant/attempt` | 出力なし。usage を加算(`:200-202`) | | `assistant/message` | content の `reasoning` → `{type:'thinking'}`、`text` → `{type:'text'}`(`:203-213`) | | `step/end` | `{type:'status', phase:'step_end', …, usage?}`(usage は全 attempt が揃った時だけ、`:214-228`) | | `turn/end` | `{type:'status', phase:'turn_end', turn, reason}`(`:229-236`) | | `tool/call` | `{type:'tool_call', callId, tool, input}`(`input` は `parseArguments` で JSON 化、`:237-244`) | | `tool/result` | `surfaceOp === 'append'` のときだけ `{type:'tool_result', callId, status, result}`(`:245-255`) | | その他 | `return`(無視。`:256`) | - 全イベントが exact identity でフィルタされる: `if (session !== agent.session) return;`(`:182-183`)。 - `tool/result` の `surfaceOp === 'append'` ガード(`:246`)は、compaction 等の `{op:'replace'}` による表面差し替えを二重計上しないため(`SurfaceOp` は `P/dsh-session/lib/types/types.d.ts:458-462`)。 - 文字列 8 KiB / 1 行 32 KiB / 深さ 64 の打ち切り(`:12`, `:14`, `:29`, `:80-94`)。最終 `final` だけは打ち切らない(`:266-272`)。 ### 5.3 既定モードの畳み方(購読せず log を走査) 前掲 `summarize`(index.js:112-135)。adopt 時の事前検証も同じ走査を使う(`liveEvents`, index.js:196-203)。 読み出し API は `Session` の `eventAt(seq)` と `get seq`(`P/dsh-session/lib/types/index.d.ts:182`, `:208`)。 `SessionSeq` は branded number で `SessionSeq(seq)` により作る(index.js:8 で import。型は `P/dsh-session/lib/types/types.d.ts:13`)。 `seq` の不変条件は「次のイベントの seq = ログ長」(`index.d.ts:207`)。 ### 5.4 `SessionEvent` の実体はどこにあるか **`P/dsh-session/lib/types/types.d.ts:489-512`** が `SessionEvent` の定義本体。 ```ts export type SessionEvent = { [K in SessionEventType]: { type: K; seq: SessionSeq; // :493 単調増加のシーケンス番号 time: number; // :495 Unix epoch ms data: SessionEventMap[K]; // :496 型別ペイロード ignorable?: true; // :497-507 未知型を安全に読み飛ばせるかのマーカー } & (K extends SurfaceEventType ? SurfaceIntent // :508 表面操作(surfaceOp / sourceEventSeqs) : { surfaceOp?: never; sourceEventSeqs?: never }); }[T]; ``` - `SessionEventMap`(`:255-433`)が全イベント型の本体。`turn/start`(`:262-264`), `turn/end`(`:273-276`), `step/start`/`step/end`(`:278-286`), `user/message`(`:294`), `assistant/message`(`:330-338`), `assistant/attempt`(`:344-348`), `tool/call`(`:354-360`), `tool/result`(`:374-388`), `request/header`(`:393-398`)など。 - `SessionEventType = keyof SessionEventMap`(`:435`)。 - `SurfaceEventType`(`:442`)と `SurfaceOp`(`:458-462`)。 - **プラグインが拡張する**: `declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { … } }` による declaration merging。実例 `P/dsh-agent/lib/types/types.d.ts:79-94`(`agent/inbox/spliced`)。他に `dsh-tools` / `dsh-compaction` / `dsh-hook-protocol` / `dsh-schedule` / `dsh-message-feedback` / `dsh-session-title*` 等が同名 interface を持つ。 - したがって **UI 側は「自分が知っている型だけ switch し、未知は `ignorable` を見て判断する」**のが正しい読み方(`:497-507` の指示)。 --- ## 6. 終了処理 ### 6.1 flush → 出力 → exit 要求 ```js // index.js:337-348 } finally { stopReasoning?.(); } await sessions.flush(agent.session); // 340 const outcome = summarize(agent.session, firstSeq); // 341 if (projection === void 0) io.stdout.write(outcome.text + "\n"); // 342 else projection.finish(outcome.text); // 343 if (outcome.reason?.kind === "error") io.stderr.write(`dsh: ${outcome.reason.error.code}: ${outcome.reason.error.message}\n`); // 344 io.exit(outcome.reason?.kind === "completed" ? 0 : 1); // 345 ``` - **flush が先、読み出しが後**。`sessions.flush(session)` は `SessionStore` の唯一の flush 入口で、 「`session/flush` を dispatch する。生の `ctx.parallel('session/flush', …)` を呼んではならない」と明記(`P/dsh-session/lib/types/index.d.ts:426-439`)。 イベント契約は `index.d.ts:65-73`(awaited parallel durability checkpoint)。戻り値は `Promise`(headless は無視)。 - stderr は reasoning 用(index.js:168)、stdout は最終回答 or `final` イベント専用(`:342-343`)。`--json` では stdout が機械可読ストリームになるため stderr は `dsh:` 診断のみ(README:58)。 - exit 0 は `completed` のときだけ。それ以外(aborted/blocked/error/max-tokens/interrupted/forked/**reason 未定義 = ターンが無かった**)は 1(index.js:345。`reason` は `let reason;` で未定義初期化: `:115`)。 README:88 が "A completed final `turn/end` exits 0; any other outcome — aborted, error, **or no turn in the owned interval** — exits 1" と明文化。 ### 6.2 プロセス終了要求の作法 ```js // index.js:355-366 function apply(ctx, config) { const exit = ctx.get("appExit"); if (exit === void 0) throw new Error("headless-runner: the launcher must provide ctx.appExit before the tree mounts"); const io = { stdout: internals.stdout, stderr: internals.stderr, exit }; run(ctx, config, io).catch((error) => { fail(io, error, config.json === true); }); } ``` - `appExit` の型は `P/dsh-cmdline/lib/types/index.d.ts:32-38`: "Request bounded process exit; the launcher wires it to its shutdown controller. `(code: number): void` — Request exit once the tree has been disposed." - 提供元はランチャー。`dsh/lib/profile-boot-BZ2ZjNWi.js:271-281`: ```js const ctx = await boot(NAME, rootConfig, readProfilePatches(...), async (hostCtx) => { app.current = hostCtx; hostCtx.provide("profileContext", profileContext); hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment); await hostCtx.plugin(PluginPackages, { resolution: composed.resolution }); provideCmdline(hostCtx, { args: options.args, exit: (code) => void shutdown.shutdown(code), ready: appReady.service }); }); ``` → `appExit` は `shutdown.shutdown(code)` に束ねられており、**終了は「ツリーを dispose してから」**行われる。 これが README:141 の制約 "Runs through the `dsh` launcher — starting the headless profile another way fails at startup, because only the launcher can request the process exit" の実体。 - **headless はエージェントを明示的に dispose しない**。`agents.create`/`resume` の handle を捨てているため(index.js:316-321, :259-265)、後始末はランチャーの root fiber dispose に乗る(`dsh/lib/profile-boot-BZ2ZjNWi.js:255-257` の `installFailLoud(NAME, process, async () => { await app.current?.fiber.dispose(); })`)。 - 直接ドライバの失敗は `fail()`(index.js:278-286)が `--json` なら `error` イベントを stdout に出し、stderr に `dsh: `、`exit(1)`。 - 参考(SDK サーバ): 同じ「セッションを開いて駆動する」処理でも、こちらは `shutdown` 応答を flush → `rootFiber.dispose()` → `exit(0)`(`P/dsh-sdk-jsonrpc-server/lib/index.js:270-278`)で、 **自分が作った handle を明示 dispose** する(`:181`, `:164-187`)。長命サーバの作法はこちらに近い。 --- ## 7. 我々(dsh-originalTUI)が真似すべき点 / 真似すべきでない点 ### 7.1 真似すべき点(根拠つき) | # | 項目 | 根拠 | |---|---|---| | 1 | **駆動前に `await ctx.get('loader')?.await()`** | index.js:294、README:80(合成途中で駆動しない) | | 2 | **identity は `agents.create({sessionId, meta:{cwd}, agentOptions, setup})` / `agents.resume({resumeSessionId, agentOptions, setup})`** | index.js:313-321, :259-263。既存 TUI も同一(`…/dsh-adapter/plugin.js:2733`, `channel/session-tree-actions.js:109`) | | 3 | **`setup` で `installModelSelection(agentCtx, {current, assembled: undefined})`** | index.js:307-312、`P/dsh-agent/lib/types/model-selection.d.ts:49`。モデル切替 UI を持つ TUI には必須 | | 4 | **cwd は `fs.resolve('.')` → `fs.processPath(...)`、fs 不在時のみ `process.cwd()`** | index.js:314-315。セッションの adopt 判定(`:226`)と同じ解決器を使う | | 5 | **`firstSeq = agent.session.seq` を投入前に捕捉し、その区間だけ畳む** | index.js:324, :112-135。過去の assistant メッセージを「今回の回答」と誤認しない | | 6 | **最終回答は durable log(`eventAt`)から導出し、ストリーム断片から組み立てない** | index.js:118, :125-128。README:58(`--json` の text は commit 時のみ) | | 7 | **購読は exact identity で絞る**(`session !== agent.session` / `subject !== agent`) | json-stream:182-183、index.js:154。複数エージェント同居の TUI では必須 | | 8 | **読み出し前に `sessions.flush(session)`** | index.js:340、`P/dsh-session/lib/types/index.d.ts:426-439`(唯一の flush 入口) | | 9 | **投入は `createUserMessage({content:[…], source:{kind:'user'}})` + `followup`** | index.js:329-335、`runtime-types.d.ts:187-192` | | 10 | **終了理由は `turn/end` の `reason.kind` で判定** | index.js:129, :345、`types.d.ts:165-210` | | 11 | **adopt 前検証(live 検出 / cwd / subagent / preset)** | index.js:221-227, :245-249。既存 TUI も「claim」で二重駆動を防いでいる(`plugin.js:2752` の `reservation.abandon()` = resume 失敗時に claim を残さない) | | 12 | **バンドルは `dsh-base` の上に patch を重ねる**(コアは再実装しない) | `HOME/.dsh/profiles/headless/package.json:5-12`、`P/dsh-base/cordis.patch.yml:1-14` | ### 7.2 真似すべきでない点(根拠つき) | # | 項目 | 根拠 / 理由 | |---|---|---| | 1 | **`appExit` でプロセスを落とす** | index.js:345, :356-357。TUI はプロセスを維持し、UI 状態は `agent/status` の idle 遷移で戻す(既存 TUI: `channel/core/binding-feed.js:24-36`) | | 2 | **完了待ちの唯一の信号に `await agent.whenIdle()` を使う** | index.js:336。既存 TUI に明示的な警告がある: "waiting for whenIdle is unsafe because it also follows replacement work and may never settle"(`…/dsh-adapter/channel/input-actions.js:310-313`)。UI の running/idle は `agent/status`(既存 TUI: `backend/session.js:155`)、`whenIdle` は「完了後に 1 回だけ畳む」用途に限定する | | 3 | **stdout/stderr への直接書き込み** | index.js:342-344、json-stream:180。TUI は描画バッファに書く(stdout を占有すると TUI が壊れる) | | 4 | **`--json` の 8 KiB / 32 KiB / 深さ 64 打ち切り** | json-stream:12-14, :29。行指向スーパーバイザ向けの安全弁であり、UI には情報を落とす。既存 TUI は打ち切らない | | 5 | **`internals` 経由の `process.stdin` 直読み** | index.js:21-25、startup.js:11-14。TUI は自前の入力系を持つ | | 6 | **commander + `parseCmdline` + `ctx.provide` の 2 プラグイン構成** | startup.js:34-42, :65-91、patch:20-31。TUI は自前の設定源を持つので、遅延 config のための分割は不要 | | 7 | **毎回の run で `loader.await()` を待つ** | index.js:294。長命 TUI では boot 時に 1 回でよい | | 8 | **`assertAdoptable` の preset 一律拒否をそのまま流用** | index.js:222-223。headless は preset を compose しないので一律拒否が正しいが、preset を持つ構成では自分のセッションまで拒否される。既存 TUI は `resolvePersistedPreset` / `composePreset` を自前で持つ(`…/dsh-adapter/plugin.js:2726-2727`) | | 9 | **`AgentHandle` を捨てる** | index.js:316-321, :259-265。長命プロセスでは handle を保持して `dispose()` する(`P/dsh-agent/lib/types/index.d.ts:129-146`。SDK サーバは実際に dispose している: `P/dsh-sdk-jsonrpc-server/lib/index.js:181`) | | 10 | **`--json` 投影関数(`projectJsonRun` / `boundJsonLine`)の直接 import** | `P/dsh-headless/package.json:16-28` の `exports` に `./json-stream` が無く、実体は `lib/json-stream-BA-F3lfb.js`(ハッシュ付き内部ファイル)。**公開 API ではない**。使うなら自前実装する | | 11 | **1 タスク 1 プロセスという前提** | README:140 "One task per run — after the task is answered the process exits; there is no interactive follow-up"。TUI は同一セッションに複数ターンを積む | --- ## 8. 我々が書くべきコードの輪郭(呼び出し順) ```text [プラグイン] name: '-runner', inject: ['agents', 'agentDefaultModel', 'sessions'] 0. boot 時に 1 回: await ctx.get('loader')?.await() (index.js:294 相当。以後は待たない) 1. identity 解決 1a. 新規: const selection = ctx.agentDefaultModel.currentSelection() // index.js:302 const fs = ctx.get('fs') const cwd = fs === undefined ? process.cwd() : fs.processPath(await fs.resolve('.')) // :314-315 const handle = await ctx.agents.create({ // :316-321 sessionId: brandString(`session-${randomUUID()}`), // :313 meta: { cwd }, agentOptions: { provider: selection.provider, model: selection.model }, setup: (agentCtx) => { installModelSelection(agentCtx, { current: selection, assembled: undefined }) }, // :307-312 }) 1b. 再開: const live = ctx.agents.get(id) // :245 (live なら拒否)/ 自前の preset・cwd 検証 // :221-227 を自構成に合わせて書き換え const handle = await ctx.agents.resume({ resumeSessionId: id, agentOptions, setup }) // :259-263 (事前観察が要るなら ctx.sessionQuery.observeSession(id): :257) ※ handle は保持する(headless と違い捨てない) // §7.2-9 2. 実行区間の確定 await handle.agent.whenIdle() // index.js:322 const firstSeq = handle.agent.session.seq // :324 3. 購読を張る(投入より前) ctx.on('session/event', (session, event) => { if (session !== handle.agent.session) return; … }) // json-stream:182-183, :264 ctx.on('agent/status', …) // UI の running/idle 用(headless には無い。既存 TUI: binding-feed.js:32) 4. 投入 handle.agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })) // index.js:329-335 (「今のターンに割り込む」を出すなら別途 steer: runtime-types.d.ts:200) 5. 完了 await handle.agent.whenIdle() // :336 await ctx.sessions.flush(handle.agent.session) // :340 const { text, reason } = summarize(handle.agent.session, firstSeq) // :112-135, :341 6. 後始末(プロセスは落とさない) 購読 disposer を呼ぶ / 画面を畳む セッションを閉じる時だけ: await handle.dispose() // P/dsh-agent/lib/types/index.d.ts:143-146 プロセス終了は TUI の終了操作から(appExit を自前で叩かない) ``` `inject` すべきサービスは **`agents` / `agentDefaultModel` / `sessions`** の 3 つ(index.js:101-105 と同一)。 `sessionQuery` / `sessionPersistence` は再開機能を出す場合のみ(index.js:242-244)。 `fs` / `loader` / `appExit` は任意取得(`:294`, `:314`, `:356`)。 --- ## 不明点リスト 1. **`dsh-agent-loop` の `whenIdle()` 実装本体は未読**。quiescence が「サブエージェントやバックグラウンドジョブの完了まで含むか」は `runtime-types.d.ts:158-164`("no active driver or maintenance task remains")以上のことは分からない。→ 不明。 2. **`followup` が内部で `send(msg, 'next-turn', true)` と等価か**は未確認(`dsh-agent-loop` 側の実装)。両者の差が実運用で出るかは不明。 3. **`brandString`(`dsh-brand`)の検証内容**は未読。空文字/空白のみの扱いは headless 側で先に拒否しているため実害は見えないが、ブランド関数自体の仕様は不明。 4. **adopt の二重検証(`observeSession` 後と `resume` 後: index.js:258, :264)の意図**はコメントが無く不明。TOCTOU 対策なのか、resume が別スナップショットを読む前提なのかは特定できない。 5. **`reasoningEffort` を `agentOptions` に載せない理由**は README に記述が無い(index.js:303-306 は provider/model のみ)。意図は不明。 6. **`--json` 投影が live の reasoning を出さず commit 後の `assistant/message` だけを使う設計判断の背景**は README:58 と json-stream:3-10 の docstring 以上の一次資料が無い。retry/discard を避ける目的と読めるが、明文化はされていない。 7. **ランチャー `shutdown` オブジェクトの全体実装は未読**(`dsh/lib/profile-boot-BZ2ZjNWi.js:278` の `shutdown.shutdown(code)` と呼び出し箇所のみ確認)。終了要求から実際の `process.exit` までの順序は不明。 8. **`system-prompt` の `{{cwd}}` / `{{model}}` テンプレートの解決器**は本調査の範囲外(`dsh-system-prompt` 未読)。 9. **`dsh-headless` の `Config` が Schemastery で `undefined` を許容すること**はインストール済み 3.18.4 への実行で確認したが、**ローダーが実際に `!!js` の `undefined` をどう渡すか**(キーごと落とすのか `undefined` 値で渡すのか)は未確認。どちらでも通ることは確認済み。 10. **`--session-id` で adopt した既存セッションに、過去の未完了ターンが残っていた場合の挙動**(`interrupted` の合成など)は未検証。`summarize` は `firstSeq` 以降しか見ないため、最終回答が空になる可能性がある(推測)。 11. **`P/dsh-headless/lib/types/runner-internals.d.ts` と `startup-internals.d.ts` はテスト差し替え用**であり、`package.json:29-35` の `files` に `lib/types/**/*.d.ts` として含まれる。これらが公開 API として扱われるべきかは不明(`exports` に個別サブパスが無い)。