dsh-loop-engine 0.1.7-rc3 → 0.1.7-rc5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,76 +1,77 @@
1
- # dsh-loop-engine
2
-
3
- [![npm version](https://img.shields.io/npm/v/dsh-loop-engine?color=cb3837)](https://www.npmjs.com/package/dsh-loop-engine)
4
-
5
- Pick the agent loop engine of **dsh web** the same way you pick a model — **per session**: the built-in `in-process` loop (the default), or one of the four hosted engines `claude-code`, `codex`, `pi`, `kimi`. The engine is the plugin's own per-session record, so **any open, idle session can be moved to another engine** — between two hosted engines it is an **in-place handover**, and a move involving `in-process` releases that session's agent and reloads the page (the `dsh web` process never restarts). Sessions are independent, so one chat can run Codex while another runs Kimi. None of this changes anything in the main repository.
6
-
7
- ## Install
8
-
9
- ```sh
10
- dsh plugin --profile web add dsh-loop-engine
11
- ```
12
-
13
- Boot `dsh web` so the profile recomposes with the plugin, then open **Settings → Loop engine**. One boot is enough: the router retries for a bounded window while the base bundle's `agent-loop` row still holds the factory slot.
14
-
15
- > Installing rewrites one **engine-agnostic** managed block in `cordis.patch.yml`: it disables the base bundle's `agent-loop` row so the plugin's own router can own the process's single agent-factory slot, and every other byte you wrote in that file is preserved. Upgrading from an earlier release needs no hand edit — a block that named the one pinned engine is rewritten to this form on the next boot, and seeds the Settings default with that engine.
16
-
17
- > **pnpm users:** pnpm 10+ blocks dependency build scripts by default, so the install may fail with `ERR_PNPM_IGNORED_BUILDS` naming `esbuild`, `@google/genai`, and `protobufjs` (all reached through the engine SDKs). Allow them and retry — `pnpm approve-builds`, or `allowBuilds` in `pnpm-workspace.yaml`. Only the installing project can grant this; the plugin cannot pre-approve its own dependencies.
18
-
19
- ### Running against a harness source checkout
20
-
21
- A **published** dsh needs no extra setup. Booting the harness from its **source checkout** (`cd deepseek-harness && pnpm dsh web`) takes one more step: bridge the profile's harness peer packages to the checkout source with `file:` shims, so both halves share one module instance (a scope minted through one instance is invisible to the other, and session resume fails with `agent-presets: refusing to compose an unscoped context`). The peer list includes `@deepseek-ai/dsh-agent-loop`. The runnable shim steps are in [docs/source-checkout.md](docs/source-checkout.md); why every `@deepseek-ai/*` package must stay a single instance is in [docs/architecture.md](docs/architecture.md). Installing the plugin as a local **`link:`** checkout sidesteps this entirely.
22
-
23
- ## Version compatibility
24
-
25
- dsh-loop-engine is versioned **in lockstep with the harness it targets**: `<harness version>-rcN`. From `0.1.7-rc1` on, **one release serves two harness generations**: the 0.1.5 line (`>=0.1.5-rc.1 <0.1.6-0`, all sharing the old settings API) and the 0.1.7 line (`>=0.1.7-rc.1 <0.1.8-0`, whose `rc.1` and `rc.2` are both verified). It detects the running generation at load and takes the matching code path, so a single published package installs onto either. Every harness package it consumes declares that union range in `peerDependencies`; a harness outside it fails loudly at boot or session resume. Within a covered line a new `rc` needs no plugin release unless the API surfaces it depends on moved — `docs/compatibility.md` §1 lists the surfaces and the diff command that decides it. `0.1.5-rc3` … `0.1.5-rc5` target harness `0.1.5-rc.2`; `0.1.5-rc1`/`0.1.5-rc2` target `0.1.5-rc.1`; `1.0.0-rc8` … `1.0.0-rc15` target `0.1.2-rc.1`; `1.0.0-rc7` and earlier target `0.1.1-rc.2`.
26
-
27
- ### Requirements
28
-
29
- - **Claude Code**: the Claude Code CLI installed and logged in on the host.
30
- - **Codex**: authenticated via `codex login` on the host, or a `CODEX_API_KEY` environment entry.
31
- - **Pi**: authenticated the way `pi` expects (its own `~/.pi/agent/auth.json`, or the provider's API-key environment variable such as `ANTHROPIC_API_KEY`).
32
- - **Kimi Code**: the `kimi` CLI installed and logged in (e.g. `kimi login`), and reachable on `PATH` (or pinned to an absolute path via `kimiBin` in the composition entry).
33
- - Running the harness from a source checkout needs the extra `dsh-agent-loop` `file:` shim above.
34
-
35
- ## Usage
36
-
37
- **The engine is chosen per session.** Pick `Claude Code`, `Codex`, `Pi`, or `Kimi Code` on the new-session screen's preset chip (beside the workspace picker); keep the deployment's own default (usually `standard`) to run the built-in in-process loop. Different sessions can run different engines at the same time, and a child agent inherits the engine of the agent that spawned it.
38
-
39
- - **Settings → Loop engine** sets the default for **new** sessions only — it lands immediately, with no restart and no page reload, and sessions already running are unaffected. Choosing **In-process** restores whatever default the plugin replaced.
40
- - **Moving a session you are in** uses the composer's engine selector (enable *Show the engine selector in the chat page*), which calls the plugin's own `remote.loopEngine.select`. Any open, idle session can move — including one that has already run many turns.
41
- - **Between two hosted engines the move is an in-place handover**: the session stays open, only the agent is replaced — it applies on the spot, with nothing to confirm.
42
- - **A move involving `in-process` cannot be handed over in place** (the harness loop neither accepts a session it did not create nor hands a live one over), so the composer asks first: the switch reloads the page, and this page's scroll position and unsent draft go with it (the conversation record does not). On confirm the host RELEASES that session's agent and answers `reload: true`; the page reloads itself and reopens the same session, which is what makes the host build it on the recorded engine. The process never restarts.
43
- - A session that is **mid-turn is refused** rather than interrupted, and a **subagent's** session cannot be moved at all.
44
- - **Models**: every hosted engine shares ONE `external` group in the model menu, holding exactly one entry, `default` — meaning "the engine decides". Picking a **real dsh model** hands it to the engine **with its endpoint and credential** (whether the engine can serve it is the engine's business — a refusal is reported, not swallowed). `in-process` sessions use dsh's models as usual.
45
- - **Uninstall**: `dsh plugin --profile web remove dsh-loop-engine`, then manually delete the plugin's managed block from the profile's `cordis.patch.yml` — the block outlives the plugin, and while it is present the base `agent-loop` row stays disabled, leaving the profile with no agent factory.
46
-
47
- ### What a hosted engine takes over
48
-
49
- - Its preset is a copy of `standard` minus the dsh-native rows an external engine replaces — dsh's `/plan`, `/compact` (and auto-compaction), the model-facing goal tool, the human `/goal` command, and the dsh skill rows (one stripped preset per engine, under `$DSH_HOME/.agent-presets/loop-engine-<engine>/`).
50
- - The engine's own slash commands and skill catalog are registered into **that agent's own scope**, so two sessions on different engines never see each other's menus, and the whole surface is released with the agent.
51
- - Engine-agnostic dsh commands (`/export`, `/feedback`, `/permission`) keep working and stay.
52
-
53
- ## Known limitations
54
-
55
- - **The engine record lives in the plugin's sidecar, `$DSH_HOME/.loop-engine/engines.json`, not in the session log.** Changing machine or `DSH_HOME` loses it, and the session falls back gracefully to the preset mapping. See [docs/per-session-engine.md](docs/per-session-engine.md) §5.5 and [docs/architecture.md](docs/architecture.md) §3.9.
56
- - **A move involving `in-process` reloads the page**, because the harness loop neither hands over a live session nor adopts one it did not create. See [docs/per-session-engine.md](docs/per-session-engine.md) §5.2/§5.4.
57
- - **The engine is settled at session creation / blank period**; once a turn has run, changing engines rebuilds that session's agent (idle only). See [docs/per-session-engine.md](docs/per-session-engine.md) §5.
58
- - **Old sessions carrying the legacy single preset id (`loop-engine`)** show as "legacy hosted engine" and need one rebuild before the new semantics take over. See [docs/per-session-engine.md](docs/per-session-engine.md) §7.
59
- - **Under a hosted engine a dsh model pick matters only if you pick a real dsh model** — `default` means "hand it back to the engine". See [docs/per-session-engine.md](docs/per-session-engine.md) §5.2.
60
- - **Same-engine sessions share that CLI's own auth directory**, with no lock added by the plugin. See [docs/per-session-engine.md](docs/per-session-engine.md) §6.
61
- - **Switching to `in-process` leaves the shared `external` provider group in the model menu** (the catalog is not scoped per session). See [docs/architecture.md](docs/architecture.md) §3.6.
62
-
63
- ## Where the details live
64
-
65
- - [docs/per-session-engine.md](docs/per-session-engine.md) — the full user-visible behavior of per-session engines.
66
- - [docs/source-checkout.md](docs/source-checkout.md) — the `file:` shims a source-launched harness needs.
67
- - [docs/architecture.md](docs/architecture.md) — plugin core: the single factory slot, the managed block, routing, per-session engine facts, the provider route.
68
- - [docs/compatibility.md](docs/compatibility.md) — **start here when the harness upgrades**: every generation-dependent code site, the four techniques for spanning them, and the checklist for adding the next generation.
69
- - [docs/driver-core.md](docs/driver-core.md) — the shared driver infrastructure.
70
- - [docs/engine-claude.md](docs/engine-claude.md), [docs/engine-codex.md](docs/engine-codex.md), [docs/engine-kimi.md](docs/engine-kimi.md), [docs/engine-pi.md](docs/engine-pi.md) — per-engine internals.
71
- - [docs/optimization-backlog.md](docs/optimization-backlog.md) — known issues and the optimization list.
72
- - [docs/proposals/](docs/proposals/) — main-repo proposals: `append-ignorable-events.md`, `harness-agent-handover.md`, and the two model-selection ones (`dsh-model-into-hosted-engines.md`, `per-session-model-for-hosted-engines.md`).
73
-
74
- ## License
75
-
76
- MIT
1
+ # dsh-loop-engine
2
+
3
+ [![npm version](https://img.shields.io/npm/v/dsh-loop-engine?color=cb3837)](https://www.npmjs.com/package/dsh-loop-engine)
4
+
5
+ Pick the agent loop engine of **dsh web** the same way you pick a model — **per session**: the built-in `in-process` loop (the default), or one of the four hosted engines `claude-code`, `codex`, `pi`, `kimi`. The engine is the plugin's own per-session record, so **any open, idle session can be moved to another engine** — between two hosted engines it is an **in-place handover**, and a move involving `in-process` releases that session's agent and reloads the page (the `dsh web` process never restarts). Sessions are independent, so one chat can run Codex while another runs Kimi. None of this changes anything in the main repository.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ dsh plugin --profile web add dsh-loop-engine
11
+ ```
12
+
13
+ Boot `dsh web` so the profile recomposes with the plugin, then open **Settings → Loop engine**. One boot is enough: the router retries for a bounded window while the base bundle's `agent-loop` row still holds the factory slot.
14
+
15
+ > Installing rewrites one **engine-agnostic** managed block in `cordis.patch.yml`: it disables the base bundle's `agent-loop` row so the plugin's own router can own the process's single agent-factory slot, and every other byte you wrote in that file is preserved. Upgrading from an earlier release needs no hand edit — a block that named the one pinned engine is rewritten to this form on the next boot, and seeds the Settings default with that engine.
16
+
17
+ > **pnpm users:** pnpm 10+ blocks dependency build scripts by default, so the install may fail with `ERR_PNPM_IGNORED_BUILDS` naming `esbuild`, `@google/genai`, and `protobufjs` (all reached through the engine SDKs). Allow them and retry — `pnpm approve-builds`, or `allowBuilds` in `pnpm-workspace.yaml`. Only the installing project can grant this; the plugin cannot pre-approve its own dependencies.
18
+
19
+ ### Running against a harness source checkout
20
+
21
+ A **published** dsh needs no extra setup. Booting the harness from its **source checkout** (`cd deepseek-harness && pnpm dsh web`) takes one more step: bridge the profile's harness peer packages to the checkout source with `file:` shims, so both halves share one module instance (a scope minted through one instance is invisible to the other, and session resume fails with `agent-presets: refusing to compose an unscoped context`). The peer list includes `@deepseek-ai/dsh-agent-loop`. The runnable shim steps are in [docs/source-checkout.md](docs/source-checkout.md); why every `@deepseek-ai/*` package must stay a single instance is in [docs/architecture.md](docs/architecture.md). Installing the plugin as a local **`link:`** checkout sidesteps this entirely.
22
+
23
+ ## Version compatibility
24
+
25
+ dsh-loop-engine is versioned **in lockstep with the harness it targets**: `<harness version>-rcN`. From `0.1.7-rc1` on, **one release serves two harness generations**: the 0.1.5 line (`>=0.1.5-rc.1 <0.1.6-0`, all sharing the old settings API) and the 0.1.7 line (`>=0.1.7-rc.1 <0.1.8-0`, whose `rc.1` and `rc.2` are both verified). It detects the running generation at load and takes the matching code path, so a single published package installs onto either. Every harness package it consumes declares that union range in `peerDependencies`; a harness outside it fails loudly at boot or session resume. Within a covered line a new `rc` needs no plugin release unless the API surfaces it depends on moved — `docs/compatibility.md` §1 lists the surfaces and the diff command that decides it. Read `rcN` as **this plugin's own release counter for that harness line**, not as the harness's `rc` number (`0.1.7-rc1` … `0.1.7-rc5` all serve `0.1.7-rc.1` and `0.1.7-rc.2`). `0.1.7-rc1` … `0.1.7-rc5` target harness `0.1.7-rc.2`; `0.1.5-rc3` … `0.1.5-rc5` target harness `0.1.5-rc.2`; `0.1.5-rc1`/`0.1.5-rc2` target `0.1.5-rc.1`; `1.0.0-rc8` … `1.0.0-rc15` target `0.1.2-rc.1`; `1.0.0-rc7` and earlier target `0.1.1-rc.2`.
26
+
27
+ ### Requirements
28
+
29
+ - **Claude Code**: the Claude Code CLI installed and logged in on the host.
30
+ - **Codex**: authenticated via `codex login` on the host, or a `CODEX_API_KEY` environment entry.
31
+ - **Pi**: authenticated the way `pi` expects (its own `~/.pi/agent/auth.json`, or the provider's API-key environment variable such as `ANTHROPIC_API_KEY`).
32
+ - **Kimi Code**: the `kimi` CLI installed and logged in (e.g. `kimi login`), and reachable on `PATH` (or pinned to an absolute path via `kimiBin` in the composition entry).
33
+ - Running the harness from a source checkout needs the extra `dsh-agent-loop` `file:` shim above.
34
+
35
+ ## Usage
36
+
37
+ **The engine is chosen per session.** Pick `Claude Code`, `Codex`, `Pi`, or `Kimi Code` on the new-session screen's preset chip (beside the workspace picker); keep the deployment's own default (usually `standard`) to run the built-in in-process loop. Different sessions can run different engines at the same time, and a child agent inherits the engine of the agent that spawned it.
38
+
39
+ - **Settings → Loop engine** sets the default for **new** sessions only — it lands immediately, with no restart and no page reload, and sessions already running are unaffected. Choosing **In-process** restores whatever default the plugin replaced.
40
+ - **Moving a session you are in** uses the composer's engine selector (enable *Show the engine selector in the chat page*), which calls the plugin's own `remote.loopEngine.select`. Any open, idle session can move — including one that has already run many turns.
41
+ - **Between two hosted engines the move is an in-place handover**: the session stays open, only the agent is replaced — it applies on the spot, with nothing to confirm.
42
+ - **A move involving `in-process` cannot be handed over in place** (the harness loop neither accepts a session it did not create nor hands a live one over), so the composer asks first: the switch reloads the page, and this page's scroll position and unsent draft go with it (the conversation record does not). On confirm the host RELEASES that session's agent and answers `reload: true`; the page reloads itself and reopens the same session, which is what makes the host build it on the recorded engine. The process never restarts.
43
+ - A session that is **mid-turn is refused** rather than interrupted, and a **subagent's** session cannot be moved at all.
44
+ - **Models**: every hosted engine shares ONE `external` group in the model menu, holding exactly one entry, `default` — meaning "the engine decides". Picking a **real dsh model** hands it to the engine **with its endpoint and credential** (whether the engine can serve it is the engine's business — a refusal is reported, not swallowed). `in-process` sessions use dsh's models as usual.
45
+ - **Uninstall**: `dsh plugin --profile web remove dsh-loop-engine`, then manually delete the plugin's managed block from the profile's `cordis.patch.yml` — the block outlives the plugin, and while it is present the base `agent-loop` row stays disabled, leaving the profile with no agent factory.
46
+
47
+ ### What a hosted engine takes over
48
+
49
+ - Its preset is a copy of `standard` minus the dsh-native rows an external engine replaces — dsh's `/plan`, `/compact` (and auto-compaction), the model-facing goal tool, the human `/goal` command, and the dsh skill rows (one stripped preset per engine). The preset is a harness-generation-dependent artifact: a directory under `$DSH_HOME/.agent-presets/loop-engine-<engine>/` on the 0.1.5 line, and an `@deepseek-ai/dsh-agent-preset` row inserted into the profile patch on the 0.1.7 line, which no longer reads that directory. See [docs/architecture.md](docs/architecture.md) §3.5.
50
+ - The engine's own slash commands and skill catalog are registered into **that agent's own scope**, so two sessions on different engines never see each other's menus, and the whole surface is released with the agent.
51
+ - **Kimi Code and Codex keep ONE child process per session** (`kimi acp` / `codex app-server`), reused across steps — that is what saves a spawn per step, and it also means a session that finished a turn and is left alone keeps paying for that process (the harness does not release an agent at turn end). Claude Code and Pi spawn per step and hold nothing. Set `childIdleMs` (milliseconds) in the composition entry — the plugin's row in the profile's `cordis.patch.yml` — to shut an idle session's child down, respawning it on the next step; `0` or omitted keeps the child for the session's whole life, which is the default. It closes the child only: the agent, the session, and the page are untouched, so nothing reloads and `session/disposed` is never emitted. See [docs/driver-core.md](docs/driver-core.md) §7.8.
52
+ - Engine-agnostic dsh commands (`/export`, `/feedback`, `/permission`) keep working and stay.
53
+
54
+ ## Known limitations
55
+
56
+ - **The engine record lives in the plugin's sidecar, `$DSH_HOME/.loop-engine/engines.json`, not in the session log.** Changing machine or `DSH_HOME` loses it, and the session falls back gracefully to the preset mapping. See [docs/per-session-engine.md](docs/per-session-engine.md) §5.5 and [docs/architecture.md](docs/architecture.md) §3.9.
57
+ - **A move involving `in-process` reloads the page**, because the harness loop neither hands over a live session nor adopts one it did not create. See [docs/per-session-engine.md](docs/per-session-engine.md) §5.2/§5.4.
58
+ - **The engine is settled at session creation / blank period**; once a turn has run, changing engines rebuilds that session's agent (idle only). See [docs/per-session-engine.md](docs/per-session-engine.md) §5.
59
+ - **Old sessions carrying the legacy single preset id (`loop-engine`)** show as "legacy hosted engine" and need one rebuild before the new semantics take over. See [docs/per-session-engine.md](docs/per-session-engine.md) §7.
60
+ - **Under a hosted engine a dsh model pick matters only if you pick a real dsh model** — `default` means "hand it back to the engine". See [docs/per-session-engine.md](docs/per-session-engine.md) §5.2.
61
+ - **Same-engine sessions share that CLI's own auth directory**, with no lock added by the plugin. See [docs/per-session-engine.md](docs/per-session-engine.md) §6.
62
+ - **Switching to `in-process` leaves the shared `external` provider group in the model menu** (the catalog is not scoped per session). See [docs/architecture.md](docs/architecture.md) §3.6.
63
+
64
+ ## Where the details live
65
+
66
+ - [docs/per-session-engine.md](docs/per-session-engine.md) — the full user-visible behavior of per-session engines.
67
+ - [docs/source-checkout.md](docs/source-checkout.md) — the `file:` shims a source-launched harness needs.
68
+ - [docs/architecture.md](docs/architecture.md) — plugin core: the single factory slot, the managed block, routing, per-session engine facts, the provider route.
69
+ - [docs/compatibility.md](docs/compatibility.md) — **start here when the harness upgrades**: every generation-dependent code site, the four techniques for spanning them, and the checklist for adding the next generation.
70
+ - [docs/driver-core.md](docs/driver-core.md) — the shared driver infrastructure.
71
+ - [docs/engine-claude.md](docs/engine-claude.md), [docs/engine-codex.md](docs/engine-codex.md), [docs/engine-kimi.md](docs/engine-kimi.md), [docs/engine-pi.md](docs/engine-pi.md) — per-engine internals.
72
+ - [docs/optimization-backlog.md](docs/optimization-backlog.md) — known issues and the optimization list.
73
+ - [docs/proposals/](docs/proposals/) — main-repo proposals: `append-ignorable-events.md`, `harness-agent-handover.md`, and the two model-selection ones (`dsh-model-into-hosted-engines.md`, `per-session-model-for-hosted-engines.md`).
74
+
75
+ ## License
76
+
77
+ MIT
package/README.zh.md CHANGED
@@ -22,7 +22,7 @@ dsh plugin --profile web add dsh-loop-engine
22
22
 
23
23
  ## 版本兼容
24
24
 
25
- `dsh-loop-engine` 与它针对的 harness **同版本对齐**:`<harness version>-rcN`。自 `0.1.7-rc1` 起,**一个发布版本同时服务两代 harness**:0.1.5 线(`>=0.1.5-rc.1 <0.1.6-0`,三段 `rc` 共用同一套旧 settings API)与 0.1.7 线(`>=0.1.7-rc.1 <0.1.8-0`,其中 `rc.1` 与 `rc.2` 都已核对)。它在加载期探测当前是哪一代、走对应分支,所以同一份已发布产物在两代上都能安装运行。它消费的每个 harness 包都在 `peerDependencies` 里声明这个并集范围;超出范围会在启动或会话恢复时响亮地失败。在被覆盖的那条线里,新的 `rc` **不需要**插件跟版——除非它依赖的 API 面动了;`docs/compatibility.md` §1 列出了这些面与"要不要改代码"的那条判定命令。`0.1.5-rc3` … `0.1.5-rc5` 针对 harness `0.1.5-rc.2`;`0.1.5-rc1`/`0.1.5-rc2` 针对 `0.1.5-rc.1`;`1.0.0-rc8` … `1.0.0-rc15` 针对 `0.1.2-rc.1`;`1.0.0-rc7` 及更早针对 `0.1.1-rc.2`。
25
+ `dsh-loop-engine` 与它针对的 harness **同版本对齐**:`<harness version>-rcN`。自 `0.1.7-rc1` 起,**一个发布版本同时服务两代 harness**:0.1.5 线(`>=0.1.5-rc.1 <0.1.6-0`,三段 `rc` 共用同一套旧 settings API)与 0.1.7 线(`>=0.1.7-rc.1 <0.1.8-0`,其中 `rc.1` 与 `rc.2` 都已核对)。它在加载期探测当前是哪一代、走对应分支,所以同一份已发布产物在两代上都能安装运行。它消费的每个 harness 包都在 `peerDependencies` 里声明这个并集范围;超出范围会在启动或会话恢复时响亮地失败。在被覆盖的那条线里,新的 `rc` **不需要**插件跟版——除非它依赖的 API 面动了;`docs/compatibility.md` §1 列出了这些面与"要不要改代码"的那条判定命令。`rcN` 读作**本插件对那条 harness 线的第 N 次发布号**,不是 harness 自己的 `rc` 号(`0.1.7-rc1` … `0.1.7-rc5` 都服务 `0.1.7-rc.1` 与 `0.1.7-rc.2`)。`0.1.7-rc1` … `0.1.7-rc5` 针对 harness `0.1.7-rc.2`;`0.1.5-rc3` … `0.1.5-rc5` 针对 harness `0.1.5-rc.2`;`0.1.5-rc1`/`0.1.5-rc2` 针对 `0.1.5-rc.1`;`1.0.0-rc8` … `1.0.0-rc15` 针对 `0.1.2-rc.1`;`1.0.0-rc7` 及更早针对 `0.1.1-rc.2`。
26
26
 
27
27
  ### 环境要求
28
28
 
@@ -46,7 +46,7 @@ dsh plugin --profile web add dsh-loop-engine
46
46
 
47
47
  ### 托管引擎接管什么
48
48
 
49
- - 它的预设是 `standard` 的副本,去掉了外部引擎会替代掉的 dsh 原生行——dsh 的 `/plan`、`/compact`(与自动压缩)、模型可见的 goal 工具、人类 `/goal` 命令、以及 dsh skill 行(每引擎一份剥离后的 preset,位于 `$DSH_HOME/.agent-presets/loop-engine-<engine>/`)。
49
+ - 它的预设是 `standard` 的副本,去掉了外部引擎会替代掉的 dsh 原生行——dsh 的 `/plan`、`/compact`(与自动压缩)、模型可见的 goal 工具、人类 `/goal` 命令、以及 dsh skill 行(每引擎一份剥离后的 preset)。preset 的**载体随 harness 代际而变**:0.1.5 上是 `$DSH_HOME/.agent-presets/loop-engine-<engine>/` 目录,0.1.7 上是插进 profile patch 的一行 `@deepseek-ai/dsh-agent-preset`(0.1.7 已不再读那个目录)。详见 [docs/architecture.md](docs/architecture.md) §3.5。
50
50
  - 引擎自己的斜杠命令与技能目录会注册进**这个 agent 自己的 scope**,因此两个跑不同引擎的会话互相看不到对方的菜单,整份表面随 agent 一起回收。
51
51
  - 与引擎无关的 dsh 命令(`/export`、`/feedback`、`/permission`)照常可用、保留在菜单里。
52
52
 
package/lib/index.js CHANGED
@@ -2389,6 +2389,34 @@ import { LlmError as LlmError3, createAssistantMessage as createAssistantMessage
2389
2389
  import { createScope as createScope2 } from "@deepseek-ai/dsh-scope";
2390
2390
  import { canonicalHeader as canonicalHeader2 } from "@deepseek-ai/dsh-session";
2391
2391
 
2392
+ // src/driver-core/idle-child.ts
2393
+ function createIdleChildCloser(options) {
2394
+ const { idleMs, close, warn } = options;
2395
+ let timer;
2396
+ const cancel = () => {
2397
+ if (timer === void 0) return;
2398
+ clearTimeout(timer);
2399
+ timer = void 0;
2400
+ };
2401
+ return {
2402
+ arm: () => {
2403
+ if (!(idleMs > 0)) return;
2404
+ cancel();
2405
+ timer = setTimeout(() => {
2406
+ timer = void 0;
2407
+ try {
2408
+ close();
2409
+ } catch (error) {
2410
+ warn(`loop-engine: idle shutdown failed to close a child process: ${String(error)}`);
2411
+ }
2412
+ }, idleMs);
2413
+ timer.unref?.();
2414
+ },
2415
+ cancel,
2416
+ dispose: cancel
2417
+ };
2418
+ }
2419
+
2392
2420
  // src/engine-codex/model-handover.ts
2393
2421
  var CODEX_DSH_PROVIDER = "dsh";
2394
2422
  var CODEX_DSH_API_KEY_ENV = "DSH_LOOP_ENGINE_API_KEY";
@@ -2928,9 +2956,18 @@ var CodexAgent = class {
2928
2956
  this.phase = { kind: "idle", lastTurn };
2929
2957
  this.scope = createScope2(loopCtx, this);
2930
2958
  this.ctx = this.scope.ctx.extend({ agent: this });
2959
+ this.idleChild = createIdleChildCloser({
2960
+ idleMs: this.config.childIdleMs,
2961
+ close: () => {
2962
+ this.closeChild();
2963
+ },
2964
+ warn: (message) => {
2965
+ loopCtx.logger.warn(message);
2966
+ }
2967
+ });
2931
2968
  this.scope.ctx.effect(() => () => {
2932
- this.appServer?.dispose();
2933
- this.appServer = void 0;
2969
+ this.idleChild.dispose();
2970
+ this.closeChild();
2934
2971
  }, "codex.appServerClient()");
2935
2972
  }
2936
2973
  loopCtx;
@@ -2982,6 +3019,17 @@ var CodexAgent = class {
2982
3019
  * must respawn the child rather than reuse one configured for another endpoint.
2983
3020
  */
2984
3021
  appServerConfig;
3022
+ /**
3023
+ * Countdown that shuts the child down once this agent has been idle past the
3024
+ * configured window, so an idle session stops paying for a process nothing
3025
+ * is driving. Armed and cancelled by {@link setPhase}.
3026
+ */
3027
+ idleChild;
3028
+ /** Shut the cached child down; the next step's {@link appServerClient} spawns a fresh one. */
3029
+ closeChild() {
3030
+ this.appServer?.dispose();
3031
+ this.appServer = void 0;
3032
+ }
2985
3033
  /**
2986
3034
  * Return the cached app-server client, spawning one on first use, after a dead
2987
3035
  * process, or when the resolved dsh endpoint changed since the last spawn.
@@ -3075,6 +3123,8 @@ var CodexAgent = class {
3075
3123
  setPhase(next) {
3076
3124
  const previousStatus = this.status;
3077
3125
  this.phase = next;
3126
+ if (next.kind === "idle") this.idleChild.arm();
3127
+ else this.idleChild.cancel();
3078
3128
  const status = this.status;
3079
3129
  if (status !== previousStatus) {
3080
3130
  this.dispatch.emit("agent/status", { status });
@@ -3610,14 +3660,16 @@ var Config2 = z2.object({
3610
3660
  sandboxMode: z2.union([...CODEX_SANDBOX_MODES]),
3611
3661
  approvalPolicy: z2.union([...CODEX_APPROVAL_POLICIES]),
3612
3662
  env: z2.dict(z2.string()).default({}),
3613
- model: z2.string()
3663
+ model: z2.string(),
3664
+ childIdleMs: z2.number()
3614
3665
  });
3615
3666
  function resolveConfig2(config) {
3616
3667
  return {
3617
3668
  sandboxMode: config.sandboxMode,
3618
3669
  approvalPolicy: config.approvalPolicy,
3619
3670
  env: config.env ?? {},
3620
- model: config.model
3671
+ model: config.model,
3672
+ childIdleMs: config.childIdleMs ?? 0
3621
3673
  };
3622
3674
  }
3623
3675
  var CODEX_ENGINE_LABEL = "agentLoopCodex";
@@ -5122,9 +5174,18 @@ var KimiAgent = class {
5122
5174
  this.phase = { kind: "idle", lastTurn };
5123
5175
  this.scope = createScope4(loopCtx, this);
5124
5176
  this.ctx = this.scope.ctx.extend({ agent: this });
5177
+ this.idleChild = createIdleChildCloser({
5178
+ idleMs: this.config.childIdleMs,
5179
+ close: () => {
5180
+ this.closeChild();
5181
+ },
5182
+ warn: (message) => {
5183
+ loopCtx.logger.warn(message);
5184
+ }
5185
+ });
5125
5186
  this.scope.ctx.effect(() => () => {
5126
- this.acp?.dispose();
5127
- this.acp = void 0;
5187
+ this.idleChild.dispose();
5188
+ this.closeChild();
5128
5189
  }, "kimi.acpClient()");
5129
5190
  }
5130
5191
  loopCtx;
@@ -5157,6 +5218,17 @@ var KimiAgent = class {
5157
5218
  * every step.
5158
5219
  */
5159
5220
  handoverEnvCache;
5221
+ /**
5222
+ * Countdown that shuts the child down once this agent has been idle past the
5223
+ * configured window, so an idle session stops paying for a process nothing
5224
+ * is driving. Armed and cancelled by {@link setPhase}.
5225
+ */
5226
+ idleChild;
5227
+ /** Shut the cached child down; the next step's {@link acpClient} spawns a fresh one. */
5228
+ closeChild() {
5229
+ this.acp?.dispose();
5230
+ this.acp = void 0;
5231
+ }
5160
5232
  get status() {
5161
5233
  return this.phase.kind === "idle" || this.phase.kind === "maintenance" ? "idle" : "running";
5162
5234
  }
@@ -5164,6 +5236,8 @@ var KimiAgent = class {
5164
5236
  setPhase(next) {
5165
5237
  const previousStatus = this.status;
5166
5238
  this.phase = next;
5239
+ if (next.kind === "idle") this.idleChild.arm();
5240
+ else this.idleChild.cancel();
5167
5241
  const status = this.status;
5168
5242
  if (status !== previousStatus) {
5169
5243
  this.dispatch.emit("agent/status", { status });
@@ -5732,13 +5806,15 @@ var KIMI_DISPOSE_GRACE_MS = 3e3;
5732
5806
  var Config4 = z4.object({
5733
5807
  model: z4.string(),
5734
5808
  env: z4.dict(z4.string()).default({}),
5735
- bin: z4.string()
5809
+ bin: z4.string(),
5810
+ childIdleMs: z4.number()
5736
5811
  });
5737
5812
  function resolveConfig4(config) {
5738
5813
  return {
5739
5814
  model: config.model,
5740
5815
  env: config.env ?? {},
5741
- bin: kimiBinResolver(config.bin)
5816
+ bin: kimiBinResolver(config.bin),
5817
+ childIdleMs: config.childIdleMs ?? 0
5742
5818
  };
5743
5819
  }
5744
5820
  var KIMI_ENGINE_LABEL = "agentLoopKimi";
@@ -5856,6 +5932,64 @@ var MANAGED_HEADER = `# Managed by dsh-loop-engine: the deployment's "${SOURCE_P
5856
5932
  # from "${SOURCE_PRESET_ID}" on boot \u2014 hand edits are overwritten. The preset id
5857
5933
  # names the engine this session runs.
5858
5934
  `;
5935
+ var PRESET_ROWS_BEGIN = "# -- dsh-loop-engine presets --";
5936
+ var PRESET_ROWS_END = "# -- /dsh-loop-engine presets --";
5937
+ var ENGINE_PRESET_ORDER = 100;
5938
+ var PLUGIN_LIST_INDENT = " ".repeat(10);
5939
+ function pluginListLines(composition) {
5940
+ const text = composition.replace(/\n+$/, "");
5941
+ const lines = text.split("\n");
5942
+ if (!lines.some((line) => line.startsWith("- "))) return [" plugins: []"];
5943
+ return [" plugins:", ...lines.map((line) => line.trim() === "" ? "" : `${PLUGIN_LIST_INDENT}${line}`)];
5944
+ }
5945
+ function renderEnginePresetRow(engine, composition) {
5946
+ const id = enginePresetId(engine);
5947
+ return [
5948
+ "- insert:",
5949
+ ` - id: preset-${id}`,
5950
+ ` name: '@deepseek-ai/dsh-agent-preset'`,
5951
+ " config:",
5952
+ ` id: ${id}`,
5953
+ ` order: ${ENGINE_PRESET_ORDER}`,
5954
+ ...pluginListLines(stripPresetRows(composition))
5955
+ ].join("\n");
5956
+ }
5957
+ function renderPresetRows(composition) {
5958
+ return [
5959
+ PRESET_ROWS_BEGIN,
5960
+ "# One `@deepseek-ai/dsh-agent-preset` declaration per hosted engine: the",
5961
+ `# deployment's "${SOURCE_PRESET_ID}" preset minus the dsh-native command/skill rows a`,
5962
+ `# hosted loop engine replaces. Regenerated from "${SOURCE_PRESET_ID}" on boot \u2014 hand`,
5963
+ "# edits are overwritten. The preset id names the engine a session runs.",
5964
+ ...HOSTED_ENGINE_IDS.map((engine) => renderEnginePresetRow(engine, composition)),
5965
+ `${PRESET_ROWS_END}
5966
+ `
5967
+ ].join("\n");
5968
+ }
5969
+ function presetRowsSpan(text) {
5970
+ const begin = text.indexOf(PRESET_ROWS_BEGIN);
5971
+ if (begin === -1) return { head: text, tail: "", present: false, blankBefore: false };
5972
+ const endAt = text.indexOf(PRESET_ROWS_END, begin);
5973
+ const spanEnd = endAt === -1 ? text.length : endAt + PRESET_ROWS_END.length + 1;
5974
+ const before = text.slice(0, begin);
5975
+ const blankBefore = before.endsWith("\n\n");
5976
+ return {
5977
+ head: blankBefore ? before.slice(0, -1) : before,
5978
+ tail: text.slice(spanEnd),
5979
+ present: true,
5980
+ blankBefore
5981
+ };
5982
+ }
5983
+ function applyEnginePresetRows(text, composition) {
5984
+ const region = renderPresetRows(composition);
5985
+ const span = presetRowsSpan(text);
5986
+ if (!span.present) {
5987
+ if (text === "") return region;
5988
+ return dropSeedPlaceholder(`${ensureTrailingNewline(text)}
5989
+ ${region}`);
5990
+ }
5991
+ return dropSeedPlaceholder(`${span.head}${span.blankBefore ? "\n" : ""}${region}${span.tail}`);
5992
+ }
5859
5993
  function managedMetadata(engine) {
5860
5994
  return `name: ${ENGINE_DISPLAY_NAMES[engine]}
5861
5995
  description: ${ENGINE_DISPLAY_NAMES[engine]}, with the dsh-native commands and skills it replaces removed.
@@ -5937,6 +6071,18 @@ ${stripPresetRows(composition)}`;
5937
6071
  }
5938
6072
  return changed;
5939
6073
  }
6074
+ async function readTextOrEmpty(path) {
6075
+ try {
6076
+ return await readFile(path, "utf8");
6077
+ } catch {
6078
+ return "";
6079
+ }
6080
+ }
6081
+ async function ensureEnginePresetRows(patchPath, source) {
6082
+ const composition = await readComposition(source, SOURCE_PRESET_ID);
6083
+ const current = await readTextOrEmpty(patchPath);
6084
+ return writeIfDifferent(patchPath, applyEnginePresetRows(current, composition));
6085
+ }
5940
6086
 
5941
6087
  // src/router-loop.ts
5942
6088
  import AgentLoop, { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from "@deepseek-ai/dsh-agent-loop";
@@ -7428,6 +7574,7 @@ var Config5 = z6.object({
7428
7574
  piProvider: z6.string(),
7429
7575
  piThinking: z6.string(),
7430
7576
  kimiBin: z6.string(),
7577
+ childIdleMs: z6.number(),
7431
7578
  // The live fields exist only on the 0.1.7 line; the 0.1.5 line carries the
7432
7579
  // selection in a settings section instead. The empty arm never runs when the
7433
7580
  // coverage job is on 0.1.7; the 0.1.5 dep set is exercised by
@@ -7444,6 +7591,10 @@ function resolvePatchPath(config) {
7444
7591
  config.patchFilename ?? "cordis.patch.yml"
7445
7592
  );
7446
7593
  }
7594
+ function authorHostedPresets(patchPath, presets) {
7595
+ if (LEGACY_HARNESS) return ensureEnginePresets(resolveDshHome2(), presets);
7596
+ return ensureEnginePresetRows(patchPath, presets);
7597
+ }
7447
7598
  function isMissing2(error) {
7448
7599
  return error?.code === "ENOENT";
7449
7600
  }
@@ -7494,7 +7645,8 @@ function codexConfig(config) {
7494
7645
  ...config.sandboxMode === void 0 ? {} : { sandboxMode: config.sandboxMode },
7495
7646
  ...config.approvalPolicy === void 0 ? {} : { approvalPolicy: config.approvalPolicy },
7496
7647
  ...config.env === void 0 ? {} : { env: config.env },
7497
- ...config.model === void 0 ? {} : { model: config.model }
7648
+ ...config.model === void 0 ? {} : { model: config.model },
7649
+ ...config.childIdleMs === void 0 ? {} : { childIdleMs: config.childIdleMs }
7498
7650
  };
7499
7651
  }
7500
7652
  function piConfig(config) {
@@ -7510,7 +7662,8 @@ function kimiConfig(config) {
7510
7662
  return {
7511
7663
  ...config.model === void 0 ? {} : { model: config.model },
7512
7664
  ...config.env === void 0 ? {} : { env: config.env },
7513
- ...config.kimiBin === void 0 ? {} : { bin: config.kimiBin }
7665
+ ...config.kimiBin === void 0 ? {} : { bin: config.kimiBin },
7666
+ ...config.childIdleMs === void 0 ? {} : { childIdleMs: config.childIdleMs }
7514
7667
  };
7515
7668
  }
7516
7669
  function apply(ctx, config) {
@@ -7611,7 +7764,7 @@ function apply(ctx, config) {
7611
7764
  let lastError;
7612
7765
  for (let attempt2 = 0; attempt2 <= PRESET_SOURCE_ATTEMPTS; attempt2 += 1) {
7613
7766
  try {
7614
- return await ensureEnginePresets(resolveDshHome2(), presets);
7767
+ return await authorHostedPresets(patchPath, presets);
7615
7768
  } catch (error) {
7616
7769
  lastError = error;
7617
7770
  if (attempt2 < PRESET_SOURCE_ATTEMPTS) {
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Idle shutdown for a driver's persistent child process.
3
+ *
4
+ * Two engines keep one child alive per session so a step never pays a spawn:
5
+ * Kimi's `kimi acp` and Codex's app-server. Nothing in the harness releases an
6
+ * agent when a turn ends — an agent lives as long as its session's scope — so
7
+ * that child outlives the work that needed it and an idle session keeps
8
+ * costing what its child costs (tens to hundreds of MB for a Node CLI). The
9
+ * closer arms a countdown whenever its owner goes idle and runs the owner's
10
+ * close when the countdown expires, so the next step's own lazy accessor
11
+ * spawns a fresh child.
12
+ *
13
+ * The closer is inert unless `idleMs` is positive: a deployment that does not
14
+ * configure idle shutdown keeps every child for the process's lifetime, which
15
+ * is the behaviour that predates this reasoner.
16
+ *
17
+ * This is deliberately NOT an agent release: disposing the agent emits
18
+ * `session/disposed`, which the browser half reads as "this session is gone"
19
+ * with no way back in that page's lifetime (`router-loop.ts` `move`). Closing
20
+ * the child leaves the agent, the session, and the page exactly where they are.
21
+ *
22
+ * @module dsh-loop-engine/driver-core/idle-child
23
+ */
24
+ /** An armed-on-idle countdown that closes one child process. */
25
+ export interface IdleChildCloser {
26
+ /**
27
+ * (Re)start the countdown. Called when the owner settles into an idle phase;
28
+ * a no-op while idle shutdown is disabled.
29
+ */
30
+ arm(): void;
31
+ /**
32
+ * Cancel a pending countdown, for the moment the child is needed again.
33
+ * Cancelling a countdown that never armed is a no-op.
34
+ */
35
+ cancel(): void;
36
+ /**
37
+ * Cancel the countdown and drop the timer. Called from the owner's teardown,
38
+ * so a disposed agent never closes a child through a stale callback.
39
+ */
40
+ dispose(): void;
41
+ }
42
+ /** Input of {@link createIdleChildCloser}. */
43
+ export interface IdleChildCloserOptions {
44
+ /** Idle window in milliseconds; zero or less leaves the child alone forever. */
45
+ readonly idleMs: number;
46
+ /** Close the child; the owner's next accessor call spawns a fresh one. */
47
+ readonly close: () => void;
48
+ /** Report a failing close without letting it escape the countdown. */
49
+ readonly warn: (message: string) => void;
50
+ }
51
+ /**
52
+ * Build one owner's idle countdown.
53
+ * @param options - the idle window, the close to run, and a diagnostic sink.
54
+ * @returns the closer, inert while the idle window is not positive.
55
+ */
56
+ export declare function createIdleChildCloser(options: IdleChildCloserOptions): IdleChildCloser;
57
+ //# sourceMappingURL=idle-child.d.ts.map
@@ -70,7 +70,15 @@ export declare class CodexAgent implements Agent {
70
70
  * must respawn the child rather than reuse one configured for another endpoint.
71
71
  */
72
72
  private appServerConfig;
73
+ /**
74
+ * Countdown that shuts the child down once this agent has been idle past the
75
+ * configured window, so an idle session stops paying for a process nothing
76
+ * is driving. Armed and cancelled by {@link setPhase}.
77
+ */
78
+ private readonly idleChild;
73
79
  constructor(loopCtx: Context, id: SessionId, options: AgentOptions, session: Session, config: ResolvedConfig);
80
+ /** Shut the cached child down; the next step's {@link appServerClient} spawns a fresh one. */
81
+ private closeChild;
74
82
  /**
75
83
  * Return the cached app-server client, spawning one on first use, after a dead
76
84
  * process, or when the resolved dsh endpoint changed since the last spawn.
@@ -40,6 +40,12 @@ export interface Config {
40
40
  env?: Record<string, string>;
41
41
  /** Fallback model for the app-server thread, used when the session selects none; Codex native settings own the model when omitted. */
42
42
  model?: string;
43
+ /**
44
+ * Idle window in milliseconds before a session's `codex app-server` child is
45
+ * shut down, spawned again by the next step. Omitted or non-positive keeps
46
+ * the child for the session's whole life.
47
+ */
48
+ childIdleMs?: number;
43
49
  }
44
50
  /** Schema of the Codex loop plugin configuration. */
45
51
  export declare const Config: z<Config>;
@@ -15,5 +15,12 @@ export interface ResolvedConfig {
15
15
  readonly approvalPolicy: CodexApprovalPolicy | undefined;
16
16
  readonly env: Record<string, string>;
17
17
  readonly model: string | undefined;
18
+ /**
19
+ * How long an idle session keeps its `codex app-server` child, in
20
+ * milliseconds; zero or less keeps it for the session's whole life. See
21
+ * `driver-core/idle-child.ts` for why the child, and not the agent, is what
22
+ * gets released.
23
+ */
24
+ readonly childIdleMs: number;
18
25
  }
19
26
  //# sourceMappingURL=types.d.ts.map
@@ -56,7 +56,15 @@ export declare class KimiAgent implements Agent {
56
56
  * every step.
57
57
  */
58
58
  private handoverEnvCache;
59
+ /**
60
+ * Countdown that shuts the child down once this agent has been idle past the
61
+ * configured window, so an idle session stops paying for a process nothing
62
+ * is driving. Armed and cancelled by {@link setPhase}.
63
+ */
64
+ private readonly idleChild;
59
65
  constructor(loopCtx: Context, id: SessionId, options: AgentOptions, session: Session, config: ResolvedConfig, spawn: KimiSpawnCapability, bin: string);
66
+ /** Shut the cached child down; the next step's {@link acpClient} spawns a fresh one. */
67
+ private closeChild;
60
68
  get status(): AgentStatus;
61
69
  /** Commit a phase and publish its externally visible status transition. */
62
70
  private setPhase;
@@ -32,6 +32,12 @@ export interface Config {
32
32
  env?: Record<string, string>;
33
33
  /** Kimi CLI executable; `'kimi'` resolves through PATH when not pinned to an absolute path. */
34
34
  bin?: string;
35
+ /**
36
+ * Idle window in milliseconds before a session's `kimi acp` child is shut
37
+ * down, spawned again by the next step. Omitted or non-positive keeps the
38
+ * child for the session's whole life.
39
+ */
40
+ childIdleMs?: number;
35
41
  }
36
42
  /** Schema of the Kimi loop plugin configuration. */
37
43
  export declare const Config: z<Config>;
@@ -19,5 +19,12 @@ export interface ResolvedConfig {
19
19
  readonly env: Record<string, string>;
20
20
  /** Kimi CLI executable; `'kimi'` resolves through PATH when not pinned to an absolute path. */
21
21
  readonly bin: string;
22
+ /**
23
+ * How long an idle session keeps its `kimi acp` child, in milliseconds; zero
24
+ * or less keeps it for the session's whole life. See
25
+ * `driver-core/idle-child.ts` for why the child, and not the agent, is what
26
+ * gets released.
27
+ */
28
+ readonly childIdleMs: number;
22
29
  }
23
30
  //# sourceMappingURL=types.d.ts.map
@@ -15,16 +15,20 @@
15
15
  * Because the router owns the slot, the plugin keeps the base bundle's
16
16
  * `agent-loop` row disabled for as long as it is composed. That managed block
17
17
  * lives in the profile's `cordis.patch.yml` (see `patch-manager.ts`) and is the
18
- * plugin's only footprint in the harness's configuration; the block names no
19
- * engine, because the engine is a per-session decision.
18
+ * plugin's only footprint in the harness's configuration alongside the preset
19
+ * rows below; the block names no engine, because the engine is a per-session
20
+ * decision.
20
21
  *
21
- * The plugin authors one preset per hosted engine into the user preset root
22
- * (`$DSH_HOME/.agent-presets/loop-engine-<engine>`, see `preset.ts`), each a
23
- * copy of `standard` minus the dsh-native command and skill rows an external
24
- * engine replaces, and it serves every hosted engine's provider route label in
25
- * the llm registry (`provider-route.ts`): an engine logs its own label into
26
- * each session's request/header, and the web host refuses a turn whose session
27
- * selection names a provider no adapter serves. The preset is now only the
22
+ * The plugin authors one preset per hosted engine — each a copy of `standard`
23
+ * minus the dsh-native command and skill rows an external engine replaces — and
24
+ * it serves every hosted engine's provider route label in the llm registry
25
+ * (`provider-route.ts`): an engine logs its own label into each session's
26
+ * request/header, and the web host refuses a turn whose session selection names
27
+ * a provider no adapter serves. WHERE that copy lives depends on the harness
28
+ * generation — a directory under `$DSH_HOME/.agent-presets/loop-engine-<engine>`
29
+ * on the 0.1.5 line, a `@deepseek-ai/dsh-agent-preset` insert row in the profile
30
+ * patch on the 0.1.7 line, which stopped reading the directory (see
31
+ * `preset.ts`, {@link authorHostedPresets}). The preset is now only the
28
32
  * session's agent-plane composition (and the engine's own default for a session
29
33
  * with no record) — it is no longer the thing that decides a running session's
30
34
  * engine.
@@ -70,6 +74,14 @@ export interface Config extends ClaudeCodeConfig {
70
74
  piThinking?: string;
71
75
  /** Kimi CLI executable; `'kimi'` resolves through PATH when not pinned to an absolute path. */
72
76
  kimiBin?: string;
77
+ /**
78
+ * Idle window in milliseconds after which a hosted session's PERSISTENT child
79
+ * process (Kimi's `kimi acp`, Codex's app-server) is shut down and spawned
80
+ * again by the next step. Zero or omitted keeps the child for the session's
81
+ * whole life. Only these two engines hold a child between steps: the Pi and
82
+ * Claude Code drivers spawn per step, so the knob does not reach them.
83
+ */
84
+ childIdleMs?: number;
73
85
  /**
74
86
  * Default engine NEW sessions are created on. A live, profile-backed field:
75
87
  * the settings shell edits it and the running plugin reads it through this
@@ -69,6 +69,26 @@ export declare function hasManagedBlock(text: string): boolean;
69
69
  * engine this build does not recognize.
70
70
  */
71
71
  export declare function legacyBlockEngineOf(text: string): LoopEngineId | undefined;
72
+ /** Normalize a file so a managed span sits on its own lines with a blank separator. */
73
+ export declare function ensureTrailingNewline(text: string): string;
74
+ /**
75
+ * The profile seed template (`cordis.patch.yml` on a fresh profile) is a lone
76
+ * root-level empty flow sequence `[]`. The plugin's managed block is itself a
77
+ * root-level block sequence of loader entries, so a block coexisting with a
78
+ * surviving `[]` is TWO root collections in one document — YAML the harness
79
+ * rejects with "end of the stream or a document separator is expected", and the
80
+ * web app then fails to boot. Remove a whole-line root `[]` placeholder so the
81
+ * managed block is the sole top-level collection. Anchored to column 0 so an
82
+ * indented `[]` that is a real value inside an entry's nested config is never
83
+ * touched.
84
+ *
85
+ * Shared with the preset-rows region (`./preset.ts`): both regions are
86
+ * root-level collections appended to the same file, so either one can be the
87
+ * first thing that meets a surviving seed.
88
+ * @param text - the patch-file text.
89
+ * @returns the text without a whole-line root `[]` placeholder.
90
+ */
91
+ export declare function dropSeedPlaceholder(text: string): string;
72
92
  /**
73
93
  * Produce the next patch-file text carrying the managed block, preserving every
74
94
  * byte outside the managed span. Appends the span when absent and replaces it
@@ -10,9 +10,29 @@
10
10
  * advisory prompt text an external engine never assembles, dsh `/compact`
11
11
  * cannot shrink a context the engine's child process holds, and dsh skills
12
12
  * would sit next to the engine's own catalog. Those rows live inside the
13
- * agent-preset composition, which a profile patch cannot reach, so the plugin
14
- * authors a stripped preset per engine into the user preset root
15
- * (`$DSH_HOME/.agent-presets/<id>`).
13
+ * agent-preset composition, which a profile patch cannot otherwise reach, so
14
+ * the plugin authors a stripped copy of that composition per engine.
15
+ *
16
+ * WHICH mechanism carries that copy is the running harness generation's
17
+ * business, and the two share everything but the carrier:
18
+ *
19
+ * - the 0.1.5 line reads presets from a DIRECTORY per preset under the user
20
+ * preset root (`$DSH_HOME/.agent-presets/<id>`), so there it is two files
21
+ * per engine ({@link ensureEnginePresets});
22
+ * - the 0.1.7 line replaced that with composed plugin rows: a preset is one
23
+ * `@deepseek-ai/dsh-agent-preset` row whose `config.plugins` list IS the
24
+ * composition, declared wherever a composition is declared. Nothing reads
25
+ * `.agent-presets` any more, so there it is four `insert` rows in the
26
+ * profile patch the plugin already manages
27
+ * ({@link ensureEnginePresetRows}). The rows mirror the shipped shape
28
+ * (`packages/bundle/web-app/presets/standard.patch.yml` in the harness),
29
+ * with this plugin's own id, order, and stripped composition.
30
+ *
31
+ * Both mechanisms regenerate from the current `standard` composition on every
32
+ * boot: text on disk is never authoritative, so a harness upgrade that changes
33
+ * `standard` flows through. Neither re-parses YAML — the strip is a line
34
+ * transform that preserves everything it does not drop byte for byte, comments
35
+ * included, and the row form only re-indents that text.
16
36
  *
17
37
  * The preset id is ALSO the per-session engine selector: the harness resolves
18
38
  * one preset per session and hands its id to the agent factory at create time
@@ -21,12 +41,6 @@
21
41
  * makes "session A on Codex, session B on Kimi, concurrently" expressible in a
22
42
  * harness that admits exactly one AgentFactory.
23
43
  *
24
- * The presets are REGENERATED from the current `standard` composition on every
25
- * boot: text on disk is never authoritative, so a harness upgrade that changes
26
- * `standard` flows through. The file is plain YAML the loader already accepts —
27
- * the strip is a line transform that preserves everything it does not drop byte
28
- * for byte, comments included.
29
- *
30
44
  * @module dsh-loop-engine/preset
31
45
  */
32
46
  export { HOSTED_PRESET_PREFIX, SOURCE_PRESET_ID, engineOfPreset, enginePresetId, hostedEngineOf, sessionEngineOf, } from './agent-preset-ids.ts';
@@ -56,6 +70,47 @@ export declare const HOSTED_PRESET_IDS: readonly string[];
56
70
  * its context and its own `/compact` (Claude, Kimi).
57
71
  */
58
72
  export declare const STRIPPED_ROWS: readonly ["skill-filesystem", "tool-skill", "tool-goal", "command-goal", "planning", "compaction"];
73
+ /**
74
+ * Begin marker of the plugin-managed preset-rows region inside a profile patch
75
+ * file. The region is what registers the hosted engines' presets on the 0.1.7
76
+ * line, where a preset is a composed row rather than a directory.
77
+ *
78
+ * Deliberately NOT the managed block's marker: the two regions are located and
79
+ * rewritten independently, so editing one never rewrites the other's bytes.
80
+ */
81
+ export declare const PRESET_ROWS_BEGIN = "# -- dsh-loop-engine presets --";
82
+ /** End marker of the plugin-managed preset-rows region inside a profile patch file. */
83
+ export declare const PRESET_ROWS_END = "# -- /dsh-loop-engine presets --";
84
+ /**
85
+ * Roster order of every preset this plugin authors. The shipped presets occupy
86
+ * 1..4 (`standard` 1, `ptc` 2, `minimal` 3, `cordis` 4), and the roster sorts
87
+ * by order and then by id, so one constant past that range puts every managed
88
+ * preset after every shipped one — the deployment's own presets stay at the top
89
+ * of the picker and the engines' copies trail it, which is also where the
90
+ * order they are listed in (selection order) is readable.
91
+ */
92
+ export declare const ENGINE_PRESET_ORDER = 100;
93
+ /**
94
+ * The managed preset-rows region: one preset row per hosted engine, bracketed
95
+ * by the markers that make it locatable and rewritable.
96
+ * @param composition - the source `standard` composition.
97
+ * @returns the region's text, ending in a newline.
98
+ */
99
+ export declare function renderPresetRows(composition: string): string;
100
+ /**
101
+ * Produce the next patch-file text carrying the preset-rows region, preserving
102
+ * every byte outside that span — including the managed block, which is located
103
+ * by its own markers. Appends the region when absent and replaces it when
104
+ * present, so an unchanged composition leaves the text byte for byte identical.
105
+ *
106
+ * The region is a root-level collection like the managed block, so a leftover
107
+ * seed `[]` is dropped with it: two root collections in one document is YAML
108
+ * the harness rejects, and the profile would stop booting.
109
+ * @param text - current patch-file text.
110
+ * @param composition - the source `standard` composition.
111
+ * @returns the rewritten patch-file text.
112
+ */
113
+ export declare function applyEnginePresetRows(text: string, composition: string): string;
59
114
  /**
60
115
  * Remove top-level entries by id from a preset composition, preserving every
61
116
  * other byte. Each entry owns the comment/blank run directly above its opener
@@ -87,4 +142,24 @@ export interface PresetCompositionSource {
87
142
  * @throws when the source preset cannot be read or the writes fail.
88
143
  */
89
144
  export declare function ensureEnginePresets(dshHome: string, source: PresetCompositionSource): Promise<boolean>;
145
+ /**
146
+ * Regenerate every hosted engine's preset as an `insert` row in the profile
147
+ * patch the plugin already manages, from the roster's `standard` preset.
148
+ *
149
+ * This is the 0.1.7 mechanism: a preset there is a composed plugin row, and the
150
+ * user preset root is not read at all — so a preset written as a directory
151
+ * would simply never register, leaving the roster without the id every hosted
152
+ * session records. The rows go in the profile's own `cordis.patch.yml` because
153
+ * that is the one composition layer the plugin owns: it is already the file the
154
+ * managed block lives in, it is applied after the bundle's own preset rows, and
155
+ * a rewrite of it is what the harness's live patch reload picks up.
156
+ *
157
+ * Idempotent: an up-to-date region is left byte for byte alone, so a rewrite
158
+ * that changes nothing does not touch the file's stamp.
159
+ * @param patchPath - absolute path of the profile's patch file.
160
+ * @param source - the roster's composition reader.
161
+ * @returns whether the patch file was written.
162
+ * @throws when the source preset cannot be read or the write fails.
163
+ */
164
+ export declare function ensureEnginePresetRows(patchPath: string, source: PresetCompositionSource): Promise<boolean>;
90
165
  //# sourceMappingURL=preset.d.ts.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-loop-engine",
3
3
  "description": "Web-switchable agent loop engine selection for the DeepSeek Harness - out-of-tree plugin (Claude Code / Codex / Pi / Kimi Code drivers) maintained by @kuun993, zero main-repo changes",
4
- "version": "0.1.7-rc3",
4
+ "version": "0.1.7-rc5",
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },